Migrate time slider control (#184958)

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

### Note for design review
index.scss file is just copy of
https://github.com/elastic/kibana/blob/main/src/plugins/controls/public/time_slider/components/index.scss.
We are migrating controls in the examples folder and then will migrate
these back into src folder once the migration is complete

### Changes

Changes to ControlGroupApi
* Implement timeslice$ API
* Implement autoApplySelections$
* Runtime state `autoApplySelections` is inverse of serialized state
`showApplySelections`

<img width="1000" alt="Screenshot 2024-06-11 at 12 56 45 PM"
src="dc27e3e3-6c25-4d5a-ab25-bfcc9bb38178">

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Hannah Mudge <hannah.wright@elastic.co>
Co-authored-by: Hannah Mudge <Heenawter@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2024-06-20 14:05:54 -06:00 committed by GitHub
parent 963a178b3d
commit ee03a1a6ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1532 additions and 34 deletions

View file

@ -15,26 +15,33 @@ import {
EuiFlexItem,
EuiLoadingSpinner,
EuiSpacer,
EuiSuperDatePicker,
OnTimeChangeProps,
} from '@elastic/eui';
import { CONTROL_GROUP_TYPE } from '@kbn/controls-plugin/common';
import { CoreStart } from '@kbn/core/public';
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { ReactEmbeddableRenderer, ViewMode } from '@kbn/embeddable-plugin/public';
import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import { PresentationContainer } from '@kbn/presentation-containers';
import { combineCompatibleChildrenApis, PresentationContainer } from '@kbn/presentation-containers';
import {
apiPublishesDataLoading,
HasUniqueId,
PublishesDataLoading,
PublishesUnifiedSearch,
PublishesViewMode,
useBatchedPublishingSubjects,
useStateFromPublishingSubject,
ViewMode as ViewModeType,
} from '@kbn/presentation-publishing';
import { toMountPoint } from '@kbn/react-kibana-mount';
import React, { useEffect, useState } from 'react';
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 { 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';
const toggleViewButtons = [
{
@ -49,11 +56,43 @@ const toggleViewButtons = [
},
];
const searchControlId = 'searchControl1';
const timesliderControlId = 'timesliderControl1';
const controlGroupPanels = {
[searchControlId]: {
type: SEARCH_CONTROL_TYPE,
order: 0,
grow: true,
width: 'medium',
explicitInput: {
id: searchControlId,
fieldName: 'message',
title: 'Message',
grow: true,
width: 'medium',
searchString: 'this',
enhancements: {},
},
},
[timesliderControlId]: {
type: TIMESLIDER_CONTROL_TYPE,
order: 0,
grow: true,
width: 'medium',
explicitInput: {
id: timesliderControlId,
title: 'Time slider',
enhancements: {},
},
},
};
/**
* I am mocking the dashboard API so that the data table embeddble responds to changes to the
* data view publishing subject from the control group
*/
type MockedDashboardApi = PresentationContainer &
PublishesDataLoading &
PublishesViewMode &
PublishesUnifiedSearch & {
publishFilters: (newFilters: Filter[] | undefined) => void;
@ -68,6 +107,20 @@ export const ReactControlExample = ({
core: CoreStart;
dataViews: DataViewsPublicPluginStart;
}) => {
const dataLoading$ = useMemo(() => {
return new BehaviorSubject<boolean | undefined>(false);
}, []);
const timeRange$ = useMemo(() => {
return new BehaviorSubject<TimeRange | undefined>({
from: 'now-24h',
to: 'now',
});
}, []);
const timeslice$ = useMemo(() => {
return new BehaviorSubject<[number, number] | undefined>(undefined);
}, []);
const [dataLoading, timeRange] = useBatchedPublishingSubjects(dataLoading$, timeRange$);
const [dashboardApi, setDashboardApi] = useState<MockedDashboardApi | undefined>(undefined);
const [controlGroupApi, setControlGroupApi] = useState<ControlGroupApi | undefined>(undefined);
const viewModeSelected = useStateFromPublishingSubject(dashboardApi?.viewMode);
@ -76,14 +129,15 @@ export const ReactControlExample = ({
const viewMode = new BehaviorSubject<ViewModeType>(ViewMode.EDIT as ViewModeType);
const filters$ = new BehaviorSubject<Filter[] | undefined>([]);
const query$ = new BehaviorSubject<Query | AggregateQuery | undefined>(undefined);
const timeRange$ = new BehaviorSubject<TimeRange | undefined>(undefined);
const children$ = new BehaviorSubject<{ [key: string]: unknown }>({});
setDashboardApi({
dataLoading: dataLoading$,
viewMode,
filters$,
query$,
timeRange$,
timeslice$,
children$,
publishFilters: (newFilters) => filters$.next(newFilters),
setViewMode: (newViewMode) => viewMode.next(newViewMode),
@ -101,6 +155,25 @@ export const ReactControlExample = ({
});
});
useEffect(() => {
const subscription = combineCompatibleChildrenApis<PublishesDataLoading, boolean | undefined>(
dashboardApi,
'dataLoading',
apiPublishesDataLoading,
undefined,
// flatten method
(values) => {
return values.some((isLoading) => isLoading);
}
).subscribe((isAtLeastOneChildLoading) => {
dataLoading$.next(isAtLeastOneChildLoading);
});
return () => {
subscription.unsubscribe();
};
}, [dashboardApi, dataLoading$]);
// TODO: Maybe remove `useAsync` - see https://github.com/elastic/kibana/pull/182842#discussion_r1624909709
const {
loading,
@ -122,6 +195,18 @@ export const ReactControlExample = ({
};
}, [dashboardApi, controlGroupApi]);
useEffect(() => {
if (!controlGroupApi) return;
const subscription = controlGroupApi.timeslice$.subscribe((timeslice) => {
timeslice$.next(timeslice);
});
return () => {
subscription.unsubscribe();
};
}, [controlGroupApi, timeslice$]);
if (error || (!dataViews?.[0]?.id && !loading))
return (
<EuiEmptyPrompt
@ -180,6 +265,18 @@ export const ReactControlExample = ({
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiSuperDatePicker
isLoading={dataLoading}
start={timeRange?.from}
end={timeRange?.to}
onTimeChange={({ start, end }: OnTimeChangeProps) => {
timeRange$.next({
from: start,
to: end,
});
}}
/>
<EuiSpacer size="m" />
<ReactEmbeddableRenderer
onApiAvailable={(api) => {
dashboardApi?.setChild(api);
@ -194,14 +291,13 @@ export const ReactControlExample = ({
controlStyle: 'oneLine',
chainingSystem: 'HIERARCHICAL',
showApplySelections: false,
panelsJSON:
'{"a957862f-beae-4f0c-8a3a-a6ea4c235651":{"type":"searchControl","order":0,"grow":true,"width":"medium","explicitInput":{"id":"a957862f-beae-4f0c-8a3a-a6ea4c235651","fieldName":"message","title":"Message","grow":true,"width":"medium","searchString": "this","enhancements":{}}}}',
panelsJSON: JSON.stringify(controlGroupPanels),
ignoreParentSettingsJSON:
'{"ignoreFilters":false,"ignoreQuery":false,"ignoreTimerange":false,"ignoreValidations":false}',
} as object,
references: [
{
name: 'controlGroup_a957862f-beae-4f0c-8a3a-a6ea4c235651:searchControlDataView',
name: `controlGroup_${searchControlId}:searchControlDataView`,
type: 'index-pattern',
id: dataViews?.[0].id!,
},
@ -217,9 +313,7 @@ export const ReactControlExample = ({
getParentApi={() => ({
...dashboardApi,
getSerializedStateForChild: () => ({
rawState: {
timeRange: { from: 'now-60d/d', to: 'now+60d/d' },
},
rawState: {},
references: [],
}),
})}

View file

@ -18,6 +18,7 @@ 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 { SEARCH_CONTROL_TYPE } from './react_controls/data_controls/search_control/types';
import { TIMESLIDER_CONTROL_TYPE } from './react_controls/timeslider_control/types';
interface SetupDeps {
developerExamples: DeveloperExamplesSetup;
@ -61,6 +62,17 @@ export class ControlsExamplePlugin
});
});
registerControlFactory(TIMESLIDER_CONTROL_TYPE, async () => {
const [{ getTimesliderControlFactory }, [coreStart, depsStart]] = await Promise.all([
import('./react_controls/timeslider_control/get_timeslider_control_factory'),
core.getStartServices(),
]);
return getTimesliderControlFactory({
core: coreStart,
data: depsStart.data,
});
});
core.application.register({
id: PLUGIN_ID,
title: 'Controls examples',

View file

@ -55,13 +55,13 @@ export const ControlGroupEditor = ({
children,
selectedLabelPosition,
selectedChainingSystem,
selectedShowApplySelections,
selectedAutoApplySelections,
selectedIgnoreParentSettings,
] = useBatchedPublishingSubjects(
api.children$,
stateManager.labelPosition,
stateManager.chainingSystem,
stateManager.showApplySelections,
stateManager.autoApplySelections,
stateManager.ignoreParentSettings
);
@ -172,8 +172,8 @@ export const ControlGroupEditor = ({
tooltip={ControlGroupEditorStrings.management.selectionSettings.showApplySelections.getShowApplySelectionsTooltip()}
/>
}
checked={!selectedShowApplySelections}
onChange={(e) => stateManager.showApplySelections.next(!e.target.checked)}
checked={selectedAutoApplySelections}
onChange={(e) => stateManager.autoApplySelections.next(e.target.checked)}
/>
</div>
</EuiFormRow>

View file

@ -28,8 +28,10 @@ import { combineCompatibleChildrenApis } from '@kbn/presentation-containers';
import {
apiPublishesDataViews,
apiPublishesFilters,
apiPublishesTimeslice,
PublishesDataViews,
PublishesFilters,
PublishesTimeslice,
PublishingSubject,
useStateFromPublishingSubject,
} from '@kbn/presentation-publishing';
@ -64,15 +66,16 @@ export const getControlGroupEmbeddableFactory = (services: {
defaultControlWidth,
labelPosition,
chainingSystem,
showApplySelections: initialShowApply,
autoApplySelections,
ignoreParentSettings: initialParentSettings,
} = initialState;
const autoApplySelections$ = new BehaviorSubject<boolean>(autoApplySelections);
const timeslice$ = new BehaviorSubject<[number, number] | undefined>(undefined);
const children$ = new BehaviorSubject<{ [key: string]: DefaultControlApi }>({});
const filters$ = new BehaviorSubject<Filter[] | undefined>([]);
const dataViews = new BehaviorSubject<DataView[] | undefined>(undefined);
const chainingSystem$ = new BehaviorSubject<ControlGroupChainingSystem>(chainingSystem);
const showApplySelections = new BehaviorSubject<boolean | undefined>(initialShowApply);
const ignoreParentSettings = new BehaviorSubject<ParentIgnoreSettings | undefined>(
initialParentSettings
);
@ -87,7 +90,7 @@ export const getControlGroupEmbeddableFactory = (services: {
);
/** TODO: Handle loading; loading should be true if any child is loading */
const dataLoading$ = new BehaviorSubject<boolean | undefined>(true);
const dataLoading$ = new BehaviorSubject<boolean | undefined>(false);
/** TODO: Handle unsaved changes
* - Each child has an unsaved changed behaviour subject it pushes to
@ -111,6 +114,7 @@ export const getControlGroupEmbeddableFactory = (services: {
.sort((a, b) => (a.order > b.order ? 1 : -1))
);
const api = setApi({
autoApplySelections$,
unsavedChanges,
resetUnsavedChanges: () => {
// TODO: Implement this
@ -129,7 +133,7 @@ export const getControlGroupEmbeddableFactory = (services: {
{
chainingSystem: chainingSystem$,
labelPosition: labelPosition$,
showApplySelections,
autoApplySelections: autoApplySelections$,
ignoreParentSettings,
},
{ core: services.core }
@ -150,7 +154,7 @@ export const getControlGroupEmbeddableFactory = (services: {
{
labelPosition: labelPosition$.getValue(),
chainingSystem: chainingSystem$.getValue(),
showApplySelections: showApplySelections.getValue(),
autoApplySelections: autoApplySelections$.getValue(),
ignoreParentSettings: ignoreParentSettings.getValue(),
}
);
@ -174,15 +178,16 @@ export const getControlGroupEmbeddableFactory = (services: {
filters$,
dataViews,
labelPosition: labelPosition$,
timeslice$,
});
/**
* Subscribe to all children's output filters, combine them, and output them
* TODO: If `showApplySelections` is true, publish to "unpublishedFilters" instead
* TODO: If `autoApplySelections` is false, publish to "unpublishedFilters" instead
* and only output to filters$ when the apply button is clicked.
* OR
* Always publish to "unpublishedFilters" and publish them manually on click
* (when `showApplySelections` is true) or after a small debounce (when false)
* (when `autoApplySelections` is false) or after a small debounce (when false)
* See: https://github.com/elastic/kibana/pull/182842#discussion_r1624929511
* - Note: Unsaved changes of control group **should** take into consideration the
* output filters, but not the "unpublishedFilters"
@ -194,6 +199,24 @@ export const getControlGroupEmbeddableFactory = (services: {
[]
).subscribe((newFilters) => filters$.next(newFilters));
const childrenTimesliceSubscription = combineCompatibleChildrenApis<
PublishesTimeslice,
[number, number] | undefined
>(
api,
'timeslice$',
apiPublishesTimeslice,
undefined,
// flatten method
(values) => {
// control group should never allow multiple timeslider controls
// returns first timeslider control value
return values.length === 0 ? undefined : values[0];
}
).subscribe((timeslice) => {
timeslice$.next(timeslice);
});
/** Subscribe to all children's output data views, combine them, and output them */
const childDataViewsSubscription = combineCompatibleChildrenApis<
PublishesDataViews,
@ -211,6 +234,7 @@ export const getControlGroupEmbeddableFactory = (services: {
return () => {
outputFiltersSubscription.unsubscribe();
childDataViewsSubscription.unsubscribe();
childrenTimesliceSubscription.unsubscribe();
};
}, []);
@ -218,14 +242,14 @@ export const getControlGroupEmbeddableFactory = (services: {
<EuiFlexGroup className={'controlGroup'} alignItems="center" gutterSize="s" wrap={true}>
{controlsInOrder.map(({ id, type }) => (
<ControlRenderer
key={uuid}
key={id}
maybeId={id}
type={type}
getParentApi={() => api}
onApiAvailable={(controlApi) => {
children$.next({
...children$.getValue(),
[controlApi.uuid]: controlApi,
[id]: controlApi,
});
}}
/>

View file

@ -43,6 +43,10 @@ export const deserializeControlGroup = (
...omit(state.rawState, ['panelsJSON', 'ignoreParentSettingsJSON']),
initialChildControlState: flattenedPanels,
ignoreParentSettings,
autoApplySelections:
typeof state.rawState.showApplySelections === 'boolean'
? !state.rawState.showApplySelections
: false,
labelPosition: state.rawState.controlStyle, // Rename "controlStyle" to "labelPosition"
defaultControlGrow: DEFAULT_CONTROL_GROW,
defaultControlWidth: DEFAULT_CONTROL_WIDTH,
@ -91,6 +95,7 @@ export const serializeControlGroup = (
rawState: {
...omit(state, ['ignoreParentSettings', 'labelPosition']),
controlStyle: state.labelPosition, // Rename "labelPosition" to "controlStyle"
showApplySelections: !state.autoApplySelections,
ignoreParentSettingsJSON: JSON.stringify(state.ignoreParentSettings),
panelsJSON: JSON.stringify(explicitInputPanels),
},

View file

@ -17,6 +17,7 @@ import {
HasParentApi,
PublishesDataLoading,
PublishesFilters,
PublishesTimeslice,
PublishesUnifiedSearch,
PublishesUnsavedChanges,
PublishingSubject,
@ -50,14 +51,17 @@ export type ControlGroupApi = PresentationContainer &
PublishesDataLoading &
PublishesUnsavedChanges &
PublishesControlGroupDisplaySettings &
Partial<HasParentApi<PublishesUnifiedSearch>>;
PublishesTimeslice &
Partial<HasParentApi<PublishesUnifiedSearch>> & {
autoApplySelections$: PublishingSubject<boolean>;
};
export interface ControlGroupRuntimeState {
chainingSystem: ControlGroupChainingSystem;
defaultControlGrow?: boolean;
defaultControlWidth?: ControlWidth;
labelPosition: ControlStyle; // TODO: Rename this type to ControlLabelPosition
showApplySelections?: boolean;
autoApplySelections: boolean;
ignoreParentSettings?: ParentIgnoreSettings;
initialChildControlState: ControlPanelsState<ControlPanelState>;
@ -71,7 +75,7 @@ export interface ControlGroupRuntimeState {
export type ControlGroupEditorState = Pick<
ControlGroupRuntimeState,
'chainingSystem' | 'labelPosition' | 'showApplySelections' | 'ignoreParentSettings'
'chainingSystem' | 'labelPosition' | 'autoApplySelections' | 'ignoreParentSettings'
>;
export type ControlGroupSerializedState = Omit<
@ -82,10 +86,14 @@ export type ControlGroupSerializedState = Omit<
| 'defaultControlWidth'
| 'anyChildHasUnsavedChanges'
| 'initialChildControlState'
| 'autoApplySelections'
> & {
panelsJSON: string;
ignoreParentSettingsJSON: string;
// In runtime state, we refer to this property as `labelPosition`; however, to avoid migrations, we will
// continue to refer to this property as the legacy `controlStyle` in the serialized state
// In runtime state, we refer to this property as `labelPosition`;
// to avoid migrations, we will continue to refer to this property as `controlStyle` in the serialized state
controlStyle: ControlStyle;
// In runtime state, we refer to the inverse of this property as `autoApplySelections`
// to avoid migrations, we will continue to refer to this property as `showApplySelections` in the serialized state
showApplySelections: boolean | undefined;
};

View file

@ -131,8 +131,8 @@ export const ControlPanel = <ApiType extends DefaultControlApi = DefaultControlA
fullWidth
isLoading={Boolean(dataLoading)}
prepend={
api?.getCustomPrepend ? (
<>{api.getCustomPrepend()}</>
api?.CustomPrependComponent ? (
<api.CustomPrependComponent />
) : usingTwoLineLayout ? (
<DragHandle
isEditable={isEditable}

View file

@ -0,0 +1,43 @@
.timeSlider__popoverOverride {
width: 100%;
max-inline-size: 100% !important;
}
.timeSlider-playToggle:enabled {
background-color: $euiColorPrimary !important;
}
.timeSlider__anchor {
width: 100%;
height: 100%;
box-shadow: none;
overflow: hidden;
@include euiFormControlSideBorderRadius($euiFormControlBorderRadius, $side: 'right', $internal: true);
.euiText {
background-color: $euiFormBackgroundColor !important;
// background-color: transparent !important; TODO revert to this rule once control group provides background color
&:hover {
text-decoration: underline;
}
&:not(.euiFormControlLayoutDelimited__delimiter) {
cursor: pointer !important;
}
}
.timeSlider__anchorText {
font-weight: $euiFontWeightMedium;
}
.timeSlider__anchorText--default {
color: $euiColorMediumShade;
}
.timeSlider__anchorText--invalid {
text-decoration: line-through;
color: $euiColorMediumShade;
}
}

View file

@ -0,0 +1,50 @@
/*
* 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 { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import { ViewMode } from '@kbn/presentation-publishing';
import { Observable } from 'rxjs';
import { TimeSliderStrings } from './time_slider_strings';
interface Props {
onPlay: () => void;
onPause: () => void;
waitForControlOutputConsumersToLoad$?: Observable<void>;
viewMode: ViewMode;
disablePlayButton: boolean;
isPaused: boolean;
}
export function PlayButton(props: Props) {
if (
props.waitForControlOutputConsumersToLoad$ === undefined ||
(props.disablePlayButton && props.viewMode === 'view')
) {
return null;
}
const Button = (
<EuiButtonIcon
className="timeSlider-playToggle"
onClick={props.isPaused ? props.onPlay : props.onPause}
disabled={props.disablePlayButton}
iconType={props.isPaused ? 'playFilled' : 'pause'}
size="s"
display="fill"
aria-label={TimeSliderStrings.control.getPlayButtonAriaLabel(props.isPaused)}
/>
);
return props.disablePlayButton ? (
<EuiToolTip content={TimeSliderStrings.control.getPlayButtonDisabledTooltip()}>
{Button}
</EuiToolTip>
) : (
Button
);
}

View file

@ -0,0 +1,44 @@
/*
* 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 { EuiRange, EuiRangeTick } from '@elastic/eui';
import { _SingleRangeChangeEvent } from '@elastic/eui/src/components/form/range/types';
import { Timeslice } from '../types';
interface Props {
value: Timeslice;
onChange: (value?: Timeslice) => void;
stepSize: number;
ticks: EuiRangeTick[];
timeRangeMin: number;
timeRangeMax: number;
}
export function TimeSliderAnchoredRange(props: Props) {
function onChange(e: _SingleRangeChangeEvent) {
const from = parseInt(e.currentTarget.value, 10);
if (!isNaN(from)) {
props.onChange([props.timeRangeMin, from]);
}
}
return (
<EuiRange
fullWidth={true}
value={props.value[1]}
onChange={onChange}
showRange
showTicks={true}
min={props.timeRangeMin}
max={props.timeRangeMax}
step={props.stepSize}
ticks={props.ticks}
/>
);
}

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 React from 'react';
import { EuiText } from '@elastic/eui';
interface Props {
onClick: () => void;
formatDate: (epoch: number) => string;
from: number;
to: number;
}
export function TimeSliderPopoverButton(props: Props) {
return (
<button
className="timeSlider__anchor eui-textTruncate"
color="text"
onClick={props.onClick}
data-test-subj="timeSlider-popoverToggleButton"
>
<EuiText className="timeSlider__anchorText eui-textTruncate" size="s">
<span>{props.formatDate(props.from)}</span>
&nbsp;&nbsp;&nbsp;&nbsp;
<span>{props.formatDate(props.to)}</span>
</EuiText>
</button>
);
}

View file

@ -0,0 +1,78 @@
/*
* 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 { EuiButtonIcon, EuiRangeTick, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { TimeSliderStrings } from './time_slider_strings';
import { TimeSliderAnchoredRange } from './time_slider_anchored_range';
import { TimeSliderSlidingWindowRange } from './time_slider_sliding_window_range';
import { Timeslice } from '../types';
interface Props {
isAnchored: boolean;
setIsAnchored: (isAnchored: boolean) => void;
value: Timeslice;
onChange: (value?: Timeslice) => void;
stepSize: number;
ticks: EuiRangeTick[];
timeRangeMin: number;
timeRangeMax: number;
}
export function TimeSliderPopoverContent(props: Props) {
const rangeInput = props.isAnchored ? (
<TimeSliderAnchoredRange
value={props.value}
onChange={props.onChange}
stepSize={props.stepSize}
ticks={props.ticks}
timeRangeMin={props.timeRangeMin}
timeRangeMax={props.timeRangeMax}
/>
) : (
<TimeSliderSlidingWindowRange
value={props.value}
onChange={props.onChange}
stepSize={props.stepSize}
ticks={props.ticks}
timeRangeMin={props.timeRangeMin}
timeRangeMax={props.timeRangeMax}
/>
);
const anchorStartToggleButtonLabel = props.isAnchored
? TimeSliderStrings.control.getUnpinStart()
: TimeSliderStrings.control.getPinStart();
return (
<EuiFlexGroup
className="rangeSlider__actions"
gutterSize="none"
data-test-subj="timeSlider-popoverContents"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiToolTip content={anchorStartToggleButtonLabel} position="left">
<EuiButtonIcon
iconType={props.isAnchored ? 'pinFilled' : 'pin'}
onClick={() => {
const nextIsAnchored = !props.isAnchored;
if (nextIsAnchored) {
props.onChange([props.timeRangeMin, props.value[1]]);
}
props.setIsAnchored(nextIsAnchored);
}}
aria-label={anchorStartToggleButtonLabel}
data-test-subj="timeSlider__anchorStartToggleButton"
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem>{rangeInput}</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,100 @@
/*
* 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 { EuiButtonIcon } from '@elastic/eui';
import React, { FC, useCallback, useState } from 'react';
import { Observable, Subscription } from 'rxjs';
import { first } from 'rxjs';
import { ViewMode } from '@kbn/presentation-publishing';
import { TimeSliderStrings } from './time_slider_strings';
import { PlayButton } from './play_button';
interface Props {
onNext: () => void;
onPrevious: () => void;
waitForControlOutputConsumersToLoad$?: Observable<void>;
viewMode: ViewMode;
disablePlayButton: boolean;
setIsPopoverOpen: (isPopoverOpen: boolean) => void;
}
export const TimeSliderPrepend: FC<Props> = (props: Props) => {
const [isPaused, setIsPaused] = useState(true);
const [timeoutId, setTimeoutId] = useState<number | undefined>(undefined);
const [subscription, setSubscription] = useState<Subscription | undefined>(undefined);
const playNextFrame = useCallback(() => {
// advance to next frame
props.onNext();
if (props.waitForControlOutputConsumersToLoad$) {
const nextFrameSubscription = props.waitForControlOutputConsumersToLoad$
.pipe(first())
.subscribe(() => {
// use timeout to display frame for small time period before moving to next frame
const nextTimeoutId = window.setTimeout(() => {
playNextFrame();
}, 1750);
setTimeoutId(nextTimeoutId);
});
setSubscription(nextFrameSubscription);
}
}, [props]);
const onPlay = useCallback(() => {
props.setIsPopoverOpen(true);
setIsPaused(false);
playNextFrame();
}, [props, playNextFrame]);
const onPause = useCallback(() => {
props.setIsPopoverOpen(true);
setIsPaused(true);
if (subscription) {
subscription.unsubscribe();
setSubscription(undefined);
}
if (timeoutId) {
clearTimeout(timeoutId);
setTimeoutId(undefined);
}
}, [props, subscription, timeoutId]);
return (
<div>
<EuiButtonIcon
onClick={() => {
onPause();
props.onPrevious();
}}
iconType="framePrevious"
color="text"
aria-label={TimeSliderStrings.control.getPreviousButtonAriaLabel()}
data-test-subj="timeSlider-previousTimeWindow"
/>
<PlayButton
onPlay={onPlay}
onPause={onPause}
waitForControlOutputConsumersToLoad$={props.waitForControlOutputConsumersToLoad$}
viewMode={props.viewMode}
disablePlayButton={props.disablePlayButton}
isPaused={isPaused}
/>
<EuiButtonIcon
onClick={() => {
onPause();
props.onNext();
}}
iconType="frameNext"
color="text"
aria-label={TimeSliderStrings.control.getNextButtonAriaLabel()}
data-test-subj="timeSlider-nextTimeWindow"
/>
</div>
);
};

View file

@ -0,0 +1,40 @@
/*
* 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 { EuiDualRange, EuiRangeTick } from '@elastic/eui';
import { Timeslice } from '../types';
interface Props {
value: Timeslice;
onChange: (value?: Timeslice) => void;
stepSize: number;
ticks: EuiRangeTick[];
timeRangeMin: number;
timeRangeMax: number;
}
export function TimeSliderSlidingWindowRange(props: Props) {
function onChange(value?: [number | string, number | string]) {
props.onChange(value as Timeslice);
}
return (
<EuiDualRange
fullWidth={true}
value={props.value}
onChange={onChange}
showTicks={true}
min={props.timeRangeMin}
max={props.timeRangeMax}
step={props.stepSize}
ticks={props.ticks}
isDraggable
/>
);
}

View file

@ -0,0 +1,42 @@
/*
* 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 TimeSliderStrings = {
control: {
getPinStart: () =>
i18n.translate('controls.timeSlider.settings.pinStart', {
defaultMessage: 'Pin start',
}),
getUnpinStart: () =>
i18n.translate('controls.timeSlider.settings.unpinStart', {
defaultMessage: 'Unpin start',
}),
getPlayButtonAriaLabel: (isPaused: boolean) =>
isPaused
? i18n.translate('controls.timeSlider.playLabel', {
defaultMessage: 'Play',
})
: i18n.translate('controls.timeSlider.pauseLabel', {
defaultMessage: 'Pause',
}),
getPreviousButtonAriaLabel: () =>
i18n.translate('controls.timeSlider.previousLabel', {
defaultMessage: 'Previous time window',
}),
getNextButtonAriaLabel: () =>
i18n.translate('controls.timeSlider.nextLabel', {
defaultMessage: 'Next time window',
}),
getPlayButtonDisabledTooltip: () =>
i18n.translate('controls.timeSlider.playButtonTooltip.disabled', {
defaultMessage: '"Apply selections automatically" is disabled in Control Settings.',
}),
},
};

View file

@ -0,0 +1,63 @@
/*
* 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 { EuiRangeTick } from '@elastic/eui';
import { TimeRange } from '@kbn/es-query';
import {
FROM_INDEX,
getStepSize,
getTicks,
roundDownToNextStepSizeFactor,
roundUpToNextStepSizeFactor,
TO_INDEX,
} from './time_utils';
import { Services } from './types';
export interface TimeRangeMeta {
format: string;
stepSize: number;
ticks: EuiRangeTick[];
timeRange: number;
timeRangeBounds: [number, number];
timeRangeMax: number;
timeRangeMin: number;
}
export function getTimeRangeMeta(
timeRange: TimeRange | undefined,
services: Services
): TimeRangeMeta {
const nextBounds = timeRangeToBounds(timeRange ?? getDefaultTimeRange(services), services);
const ticks = getTicks(nextBounds[FROM_INDEX], nextBounds[TO_INDEX], getTimezone(services));
const { format, stepSize } = getStepSize(ticks);
return {
format,
stepSize,
ticks,
timeRange: nextBounds[TO_INDEX] - nextBounds[FROM_INDEX],
timeRangeBounds: nextBounds,
timeRangeMax: roundUpToNextStepSizeFactor(nextBounds[TO_INDEX], stepSize),
timeRangeMin: roundDownToNextStepSizeFactor(nextBounds[FROM_INDEX], stepSize),
};
}
export function getTimezone(services: Services) {
return services.core.uiSettings.get('dateFormat:tz', 'Browser');
}
function getDefaultTimeRange(services: Services) {
const defaultTimeRange = services.core.uiSettings.get('timepicker:timeDefaults');
return defaultTimeRange ? defaultTimeRange : { from: 'now-15m', to: 'now' };
}
function timeRangeToBounds(timeRange: TimeRange, services: Services): [number, number] {
const timeRangeBounds = services.data.query.timefilter.timefilter.calculateBounds(timeRange);
return timeRangeBounds.min === undefined || timeRangeBounds.max === undefined
? [Date.now() - 1000 * 60 * 15, Date.now()]
: [timeRangeBounds.min.valueOf(), timeRangeBounds.max.valueOf()];
}

View file

@ -0,0 +1,228 @@
/*
* 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 { render, fireEvent } from '@testing-library/react';
import { TimeRange } from '@kbn/es-query';
import { StateComparators } from '@kbn/presentation-publishing';
import { coreMock } from '@kbn/core/public/mocks';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import dateMath from '@kbn/datemath';
import { BehaviorSubject } from 'rxjs';
import { ControlGroupApi } from '../control_group/types';
import { ControlApiRegistration } from '../types';
import { getTimesliderControlFactory } from './get_timeslider_control_factory';
import { TimesliderControlApi, TimesliderControlState } from './types';
describe('TimesliderControlApi', () => {
const uuid = 'myControl1';
const dashboardApi = {
timeRange$: new BehaviorSubject<TimeRange | undefined>(undefined),
};
const controlGroupApi = {
autoApplySelections$: new BehaviorSubject(true),
parentApi: dashboardApi,
} as unknown as ControlGroupApi;
const dataStartServiceMock = dataPluginMock.createStartContract();
dataStartServiceMock.query.timefilter.timefilter.calculateBounds = (timeRange: TimeRange) => {
const now = new Date();
return {
min: dateMath.parse(timeRange.from, { forceNow: now }),
max: dateMath.parse(timeRange.to, { roundUp: true, forceNow: now }),
};
};
const factory = getTimesliderControlFactory({
core: coreMock.createStart(),
data: dataStartServiceMock,
});
let comparators: StateComparators<TimesliderControlState> | undefined;
function buildApiMock(
api: ControlApiRegistration<TimesliderControlApi>,
nextComparators: StateComparators<TimesliderControlState>
) {
comparators = nextComparators;
return {
...api,
uuid,
parentApi: controlGroupApi,
unsavedChanges: new BehaviorSubject<Partial<TimesliderControlState> | undefined>(undefined),
resetUnsavedChanges: () => {},
type: factory.type,
};
}
beforeEach(() => {
dashboardApi.timeRange$.next({
from: '2024-06-09T00:00:00.000Z',
to: '2024-06-10T00:00:00.000Z',
});
});
test('Should set timeslice to undefined when state does not provide percentage of timeRange', () => {
const { api } = factory.buildControl({}, buildApiMock, uuid, controlGroupApi);
expect(api.timeslice$.value).toBe(undefined);
});
test('Should set timeslice to values within time range when state provides percentage of timeRange', () => {
const { api } = factory.buildControl(
{
timesliceStartAsPercentageOfTimeRange: 0.25,
timesliceEndAsPercentageOfTimeRange: 0.5,
},
buildApiMock,
uuid,
controlGroupApi
);
expect(new Date(api.timeslice$.value![0]).toISOString()).toEqual('2024-06-09T06:00:00.000Z');
expect(new Date(api.timeslice$.value![1]).toISOString()).toEqual('2024-06-09T12:00:00.000Z');
});
test('Should update timeslice when time range changes', async () => {
const { api } = factory.buildControl(
{
timesliceStartAsPercentageOfTimeRange: 0.25,
timesliceEndAsPercentageOfTimeRange: 0.5,
},
buildApiMock,
uuid,
controlGroupApi
);
// change time range to single hour
dashboardApi.timeRange$.next({
from: '2024-06-08T00:00:00.000Z',
to: '2024-06-08T01:00:00.000Z',
});
await new Promise((resolve) => setTimeout(resolve, 0));
// update time slice to same percentage in new hour interval
expect(new Date(api.timeslice$.value![0]).toISOString()).toEqual('2024-06-08T00:15:00.000Z');
expect(new Date(api.timeslice$.value![1]).toISOString()).toEqual('2024-06-08T00:30:00.000Z');
});
test('Clicking previous button should advance timeslice backward', async () => {
const { api } = factory.buildControl(
{
timesliceStartAsPercentageOfTimeRange: 0.25,
timesliceEndAsPercentageOfTimeRange: 0.5,
},
buildApiMock,
uuid,
controlGroupApi
);
if (!api.CustomPrependComponent) {
throw new Error('API does not return CustomPrependComponent');
}
const { findByTestId } = render(<api.CustomPrependComponent />);
fireEvent.click(await findByTestId('timeSlider-previousTimeWindow'));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(new Date(api.timeslice$.value![0]).toISOString()).toEqual('2024-06-09T00:00:00.000Z');
expect(new Date(api.timeslice$.value![1]).toISOString()).toEqual('2024-06-09T06:00:00.000Z');
});
test('Clicking previous button should wrap when time range start is reached', async () => {
const { api } = factory.buildControl(
{
timesliceStartAsPercentageOfTimeRange: 0.25,
timesliceEndAsPercentageOfTimeRange: 0.5,
},
buildApiMock,
uuid,
controlGroupApi
);
if (!api.CustomPrependComponent) {
throw new Error('API does not return CustomPrependComponent');
}
const { findByTestId } = render(<api.CustomPrependComponent />);
fireEvent.click(await findByTestId('timeSlider-previousTimeWindow'));
fireEvent.click(await findByTestId('timeSlider-previousTimeWindow'));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(new Date(api.timeslice$.value![0]).toISOString()).toEqual('2024-06-09T18:00:00.000Z');
expect(new Date(api.timeslice$.value![1]).toISOString()).toEqual('2024-06-10T00:00:00.000Z');
});
test('Clicking next button should advance timeslice forward', async () => {
const { api } = factory.buildControl(
{
timesliceStartAsPercentageOfTimeRange: 0.25,
timesliceEndAsPercentageOfTimeRange: 0.5,
},
buildApiMock,
uuid,
controlGroupApi
);
if (!api.CustomPrependComponent) {
throw new Error('API does not return CustomPrependComponent');
}
const { findByTestId } = render(<api.CustomPrependComponent />);
fireEvent.click(await findByTestId('timeSlider-nextTimeWindow'));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(new Date(api.timeslice$.value![0]).toISOString()).toEqual('2024-06-09T12:00:00.000Z');
expect(new Date(api.timeslice$.value![1]).toISOString()).toEqual('2024-06-09T18:00:00.000Z');
});
test('Clicking next button should wrap when time range end is reached', async () => {
const { api } = factory.buildControl(
{
timesliceStartAsPercentageOfTimeRange: 0.25,
timesliceEndAsPercentageOfTimeRange: 0.5,
},
buildApiMock,
uuid,
controlGroupApi
);
if (!api.CustomPrependComponent) {
throw new Error('API does not return CustomPrependComponent');
}
const { findByTestId } = render(<api.CustomPrependComponent />);
fireEvent.click(await findByTestId('timeSlider-nextTimeWindow'));
fireEvent.click(await findByTestId('timeSlider-nextTimeWindow'));
fireEvent.click(await findByTestId('timeSlider-nextTimeWindow'));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(new Date(api.timeslice$.value![0]).toISOString()).toEqual('2024-06-09T00:00:00.000Z');
expect(new Date(api.timeslice$.value![1]).toISOString()).toEqual('2024-06-09T06:00:00.000Z');
});
test('Resetting state with comparators should reset timeslice', async () => {
const { api } = factory.buildControl(
{
timesliceStartAsPercentageOfTimeRange: 0.25,
timesliceEndAsPercentageOfTimeRange: 0.5,
},
buildApiMock,
uuid,
controlGroupApi
);
if (!api.CustomPrependComponent) {
throw new Error('API does not return CustomPrependComponent');
}
const { findByTestId } = render(<api.CustomPrependComponent />);
fireEvent.click(await findByTestId('timeSlider-nextTimeWindow'));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(new Date(api.timeslice$.value![0]).toISOString()).toEqual('2024-06-09T12:00:00.000Z');
expect(new Date(api.timeslice$.value![1]).toISOString()).toEqual('2024-06-09T18:00:00.000Z');
comparators!.timesliceStartAsPercentageOfTimeRange[1](0.25);
comparators!.timesliceEndAsPercentageOfTimeRange[1](0.5);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(new Date(api.timeslice$.value![0]).toISOString()).toEqual('2024-06-09T06:00:00.000Z');
expect(new Date(api.timeslice$.value![1]).toISOString()).toEqual('2024-06-09T12:00:00.000Z');
});
});

View file

@ -0,0 +1,309 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { BehaviorSubject, debounceTime, first, map } from 'rxjs';
import { EuiInputPopover } from '@elastic/eui';
import {
apiHasParentApi,
apiPublishesDataLoading,
getViewModeSubject,
useBatchedPublishingSubjects,
ViewMode,
} from '@kbn/presentation-publishing';
import { ControlFactory } from '../types';
import {
TimesliderControlState,
TimesliderControlApi,
TIMESLIDER_CONTROL_TYPE,
Services,
Timeslice,
} from './types';
import { initializeDefaultControlApi } from '../initialize_default_control_api';
import { TimeSliderPopoverButton } from './components/time_slider_popover_button';
import { TimeSliderPopoverContent } from './components/time_slider_popover_content';
import { initTimeRangeSubscription } from './init_time_range_subscription';
import {
FROM_INDEX,
roundDownToNextStepSizeFactor,
roundUpToNextStepSizeFactor,
TO_INDEX,
} from './time_utils';
import { initTimeRangePercentage } from './init_time_range_percentage';
import './components/index.scss';
import { TimeSliderPrepend } from './components/time_slider_prepend';
export const getTimesliderControlFactory = (
services: Services
): ControlFactory<TimesliderControlState, TimesliderControlApi> => {
return {
type: TIMESLIDER_CONTROL_TYPE,
getIconType: () => 'search',
getDisplayName: () =>
i18n.translate('controlsExamples.timesliderControl.displayName', {
defaultMessage: 'Time slider',
}),
buildControl: (initialState, buildApi, uuid, controlGroupApi) => {
const { timeRangeMeta$, formatDate, cleanupTimeRangeSubscription } =
initTimeRangeSubscription(controlGroupApi, services);
const timeslice$ = new BehaviorSubject<[number, number] | undefined>(undefined);
function syncTimesliceWithTimeRangePercentage(
startPercentage: number | undefined,
endPercentage: number | undefined
) {
if (startPercentage === undefined || endPercentage === undefined) {
if (timeslice$.value !== undefined) {
timeslice$.next(undefined);
}
return;
}
const { stepSize, timeRange, timeRangeBounds } = timeRangeMeta$.value;
const from = timeRangeBounds[FROM_INDEX] + startPercentage * timeRange;
const to = timeRangeBounds[FROM_INDEX] + endPercentage * timeRange;
timeslice$.next([
roundDownToNextStepSizeFactor(from, stepSize),
roundUpToNextStepSizeFactor(to, stepSize),
]);
setSelectedRange(to - from);
}
const timeRangePercentage = initTimeRangePercentage(
initialState,
syncTimesliceWithTimeRangePercentage
);
function setTimeslice(timeslice?: Timeslice) {
timeRangePercentage.setTimeRangePercentage(timeslice, timeRangeMeta$.value);
timeslice$.next(timeslice);
}
const isAnchored$ = new BehaviorSubject<boolean | undefined>(initialState.isAnchored);
function setIsAnchored(isAnchored: boolean | undefined) {
isAnchored$.next(isAnchored);
}
let selectedRange: number | undefined;
function setSelectedRange(nextSelectedRange?: number) {
selectedRange =
nextSelectedRange !== undefined && nextSelectedRange < timeRangeMeta$.value.timeRange
? nextSelectedRange
: undefined;
}
function onChange(timeslice?: Timeslice) {
setTimeslice(timeslice);
const nextSelectedRange = timeslice
? timeslice[TO_INDEX] - timeslice[FROM_INDEX]
: undefined;
setSelectedRange(nextSelectedRange);
}
function onPrevious() {
const { ticks, timeRangeMax, timeRangeMin } = timeRangeMeta$.value;
const value = timeslice$.value;
const tickRange = ticks[1].value - ticks[0].value;
if (isAnchored$.value) {
const prevTick = value
? [...ticks].reverse().find((tick) => {
return tick.value < value[TO_INDEX];
})
: ticks[ticks.length - 1];
setTimeslice([timeRangeMin, prevTick ? prevTick.value : timeRangeMax]);
return;
}
if (value === undefined || value[FROM_INDEX] <= timeRangeMin) {
const to = timeRangeMax;
if (selectedRange === undefined || selectedRange === tickRange) {
const lastTickValue = ticks[ticks.length - 1].value;
const secondToLastTickValue = ticks[ticks.length - 2].value;
const from = lastTickValue === to ? secondToLastTickValue : lastTickValue;
setTimeslice([from, to]);
setSelectedRange(tickRange);
} else {
const from = to - selectedRange;
setTimeslice([Math.max(from, timeRangeMin), to]);
}
return;
}
const to = value[FROM_INDEX];
const safeRange = selectedRange === undefined ? tickRange : selectedRange;
const from = to - safeRange;
setTimeslice([Math.max(from, timeRangeMin), to]);
}
function onNext() {
const { ticks, timeRangeMax, timeRangeMin } = timeRangeMeta$.value;
const value = timeslice$.value;
const tickRange = ticks[1].value - ticks[0].value;
if (isAnchored$.value) {
if (value === undefined || value[TO_INDEX] >= timeRangeMax) {
setTimeslice([timeRangeMin, ticks[0].value]);
return;
}
const nextTick = ticks.find((tick) => {
return tick.value > value[TO_INDEX];
});
setTimeslice([timeRangeMin, nextTick ? nextTick.value : timeRangeMax]);
return;
}
if (value === undefined || value[TO_INDEX] >= timeRangeMax) {
const from = timeRangeMin;
if (selectedRange === undefined || selectedRange === tickRange) {
const firstTickValue = ticks[0].value;
const secondTickValue = ticks[1].value;
const to = firstTickValue === from ? secondTickValue : firstTickValue;
setTimeslice([from, to]);
setSelectedRange(tickRange);
} else {
const to = from + selectedRange;
setTimeslice([from, Math.min(to, timeRangeMax)]);
}
return;
}
const from = value[TO_INDEX];
const safeRange = selectedRange === undefined ? tickRange : selectedRange;
const to = from + safeRange;
setTimeslice([from, Math.min(to, timeRangeMax)]);
}
const isPopoverOpen$ = new BehaviorSubject(false);
function setIsPopoverOpen(value: boolean) {
isPopoverOpen$.next(value);
}
const viewModeSubject =
getViewModeSubject(controlGroupApi) ?? new BehaviorSubject('view' as ViewMode);
const defaultControl = initializeDefaultControlApi(initialState);
const dashboardDataLoading$ =
apiHasParentApi(controlGroupApi) && apiPublishesDataLoading(controlGroupApi.parentApi)
? controlGroupApi.parentApi.dataLoading
: new BehaviorSubject<boolean | undefined>(false);
const waitForDashboardPanelsToLoad$ = dashboardDataLoading$.pipe(
// debounce to give time for panels to start loading if they are going to load from time changes
debounceTime(300),
first((isLoading: boolean | undefined) => {
return !isLoading;
}),
map(() => {
// Observable notifies subscriber when loading is finished
// Return void to not expose internal implementation details of observable
return;
})
);
const api = buildApi(
{
...defaultControl.api,
timeslice$,
serializeState: () => {
const { rawState: defaultControlState } = defaultControl.serialize();
return {
rawState: {
...defaultControlState,
...timeRangePercentage.serializeState(),
isAnchored: isAnchored$.value,
},
references: [],
};
},
CustomPrependComponent: () => {
const [autoApplySelections, viewMode] = useBatchedPublishingSubjects(
controlGroupApi.autoApplySelections$,
viewModeSubject
);
return (
<TimeSliderPrepend
onNext={onNext}
onPrevious={onPrevious}
viewMode={viewMode}
disablePlayButton={!autoApplySelections}
setIsPopoverOpen={setIsPopoverOpen}
waitForControlOutputConsumersToLoad$={waitForDashboardPanelsToLoad$}
/>
);
},
},
{
...defaultControl.comparators,
...timeRangePercentage.comparators,
isAnchored: [isAnchored$, setIsAnchored],
}
);
const timeRangeMetaSubscription = timeRangeMeta$.subscribe((timeRangeMeta) => {
const { timesliceStartAsPercentageOfTimeRange, timesliceEndAsPercentageOfTimeRange } =
timeRangePercentage.serializeState();
syncTimesliceWithTimeRangePercentage(
timesliceStartAsPercentageOfTimeRange,
timesliceEndAsPercentageOfTimeRange
);
});
return {
api,
Component: (controlStyleProps) => {
const [isAnchored, isPopoverOpen, timeRangeMeta, timeslice] =
useBatchedPublishingSubjects(isAnchored$, isPopoverOpen$, timeRangeMeta$, timeslice$);
useEffect(() => {
return () => {
cleanupTimeRangeSubscription();
timeRangeMetaSubscription.unsubscribe();
};
}, []);
const from = useMemo(() => {
return timeslice ? timeslice[FROM_INDEX] : timeRangeMeta.timeRangeMin;
}, [timeslice, timeRangeMeta.timeRangeMin]);
const to = useMemo(() => {
return timeslice ? timeslice[TO_INDEX] : timeRangeMeta.timeRangeMax;
}, [timeslice, timeRangeMeta.timeRangeMax]);
return (
<EuiInputPopover
{...controlStyleProps}
className="timeSlider__popoverOverride"
panelClassName="timeSlider__panelOverride"
input={
<TimeSliderPopoverButton
onClick={() => {
setIsPopoverOpen(!isPopoverOpen);
}}
formatDate={formatDate}
from={from}
to={to}
/>
}
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
panelPaddingSize="s"
>
<TimeSliderPopoverContent
isAnchored={typeof isAnchored === 'boolean' ? isAnchored : false}
setIsAnchored={setIsAnchored}
value={[from, to]}
onChange={onChange}
stepSize={timeRangeMeta.stepSize}
ticks={timeRangeMeta.ticks}
timeRangeMin={timeRangeMeta.timeRangeMin}
timeRangeMax={timeRangeMeta.timeRangeMax}
/>
</EuiInputPopover>
);
},
};
},
};
};

View file

@ -0,0 +1,81 @@
/*
* 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 { StateComparators } from '@kbn/presentation-publishing';
import { debounce } from 'lodash';
import { BehaviorSubject } from 'rxjs';
import { TimeRangeMeta } from './get_time_range_meta';
import { FROM_INDEX, TO_INDEX } from './time_utils';
import { Timeslice, TimesliderControlState } from './types';
export function initTimeRangePercentage(
state: TimesliderControlState,
onReset: (
timesliceStartAsPercentageOfTimeRange: number | undefined,
timesliceEndAsPercentageOfTimeRange: number | undefined
) => void
) {
const timesliceStartAsPercentageOfTimeRange$ = new BehaviorSubject<number | undefined>(
state.timesliceStartAsPercentageOfTimeRange
);
const timesliceEndAsPercentageOfTimeRange$ = new BehaviorSubject<number | undefined>(
state.timesliceEndAsPercentageOfTimeRange
);
// debounce to avoid calling 'resetTimeslice' on each comparator reset
const debouncedOnReset = debounce(() => {
onReset(
timesliceStartAsPercentageOfTimeRange$.value,
timesliceEndAsPercentageOfTimeRange$.value
);
}, 0);
return {
setTimeRangePercentage(timeslice: Timeslice | undefined, timeRangeMeta: TimeRangeMeta) {
let timesliceStartAsPercentageOfTimeRange: number | undefined;
let timesliceEndAsPercentageOfTimeRange: number | undefined;
if (timeslice) {
timesliceStartAsPercentageOfTimeRange =
(timeslice[FROM_INDEX] - timeRangeMeta.timeRangeBounds[FROM_INDEX]) /
timeRangeMeta.timeRange;
timesliceEndAsPercentageOfTimeRange =
(timeslice[TO_INDEX] - timeRangeMeta.timeRangeBounds[FROM_INDEX]) /
timeRangeMeta.timeRange;
}
timesliceStartAsPercentageOfTimeRange$.next(timesliceStartAsPercentageOfTimeRange);
timesliceEndAsPercentageOfTimeRange$.next(timesliceEndAsPercentageOfTimeRange);
},
serializeState: () => {
return {
timesliceStartAsPercentageOfTimeRange: timesliceStartAsPercentageOfTimeRange$.value,
timesliceEndAsPercentageOfTimeRange: timesliceEndAsPercentageOfTimeRange$.value,
};
},
comparators: {
timesliceStartAsPercentageOfTimeRange: [
timesliceStartAsPercentageOfTimeRange$,
(value: number | undefined) => {
timesliceStartAsPercentageOfTimeRange$.next(value);
debouncedOnReset();
},
],
timesliceEndAsPercentageOfTimeRange: [
timesliceEndAsPercentageOfTimeRange$,
(value: number | undefined) => {
timesliceEndAsPercentageOfTimeRange$.next(value);
debouncedOnReset();
},
],
} as StateComparators<
Pick<
TimesliderControlState,
'timesliceStartAsPercentageOfTimeRange' | 'timesliceEndAsPercentageOfTimeRange'
>
>,
};
}

View file

@ -0,0 +1,43 @@
/*
* 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 { TimeRange } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { apiHasParentApi, apiPublishesTimeRange } from '@kbn/presentation-publishing';
import moment from 'moment';
import { BehaviorSubject, skip } from 'rxjs';
import { getTimeRangeMeta, getTimezone, TimeRangeMeta } from './get_time_range_meta';
import { getMomentTimezone } from './time_utils';
import { Services } from './types';
export function initTimeRangeSubscription(controlGroupApi: unknown, services: Services) {
const timeRange$ =
apiHasParentApi(controlGroupApi) && apiPublishesTimeRange(controlGroupApi.parentApi)
? controlGroupApi.parentApi.timeRange$
: new BehaviorSubject<TimeRange | undefined>(undefined);
const timeRangeMeta$ = new BehaviorSubject<TimeRangeMeta>(
getTimeRangeMeta(timeRange$.value, services)
);
const timeRangeSubscription = timeRange$.pipe(skip(1)).subscribe((timeRange) => {
timeRangeMeta$.next(getTimeRangeMeta(timeRange, services));
});
return {
timeRangeMeta$,
formatDate: (epoch: number) => {
return moment
.tz(epoch, getMomentTimezone(getTimezone(services)))
.locale(i18n.getLocale())
.format(timeRangeMeta$.value.format);
},
cleanupTimeRangeSubscription: () => {
timeRangeSubscription.unsubscribe();
},
};
}

View file

@ -0,0 +1,162 @@
/*
* 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 moment from 'moment-timezone';
import { EuiRangeTick } from '@elastic/eui';
import { calcAutoIntervalNear } from '@kbn/data-plugin/common';
const MAX_TICKS = 20; // eui range has hard limit of 20 ticks and throws when exceeded
export const FROM_INDEX = 0;
export const TO_INDEX = 1;
export function getMomentTimezone(dateFormatTZ: string) {
const detectedTimezone = moment.tz.guess();
return dateFormatTZ === undefined || dateFormatTZ === 'Browser' ? detectedTimezone : dateFormatTZ;
}
function getScaledDateFormat(interval: number): string {
if (interval >= moment.duration(1, 'y').asMilliseconds()) {
return 'YYYY';
}
if (interval >= moment.duration(30, 'd').asMilliseconds()) {
return 'MMM YYYY';
}
if (interval >= moment.duration(1, 'd').asMilliseconds()) {
return 'MMM D';
}
if (interval >= moment.duration(6, 'h').asMilliseconds()) {
return 'Do HH';
}
if (interval >= moment.duration(1, 'h').asMilliseconds()) {
return 'HH:mm';
}
if (interval >= moment.duration(1, 'm').asMilliseconds()) {
return 'HH:mm';
}
if (interval >= moment.duration(1, 's').asMilliseconds()) {
return 'mm:ss';
}
return 'ss.SSS';
}
export function getInterval(min: number, max: number, steps = MAX_TICKS): number {
const duration = max - min;
let interval = calcAutoIntervalNear(MAX_TICKS, duration).asMilliseconds();
// Sometimes auto interval is not quite right and returns 2X, 3X, 1/2X, or 1/3X requested ticks
const actualSteps = duration / interval;
if (actualSteps > MAX_TICKS) {
// EuiRange throws if ticks exceeds MAX_TICKS
// Adjust the interval to ensure MAX_TICKS is never exceeded
const factor = Math.ceil(actualSteps / MAX_TICKS);
interval = interval * factor;
} else if (actualSteps < MAX_TICKS / 2) {
// Increase number of ticks when ticks is less then half MAX_TICKS
interval = interval / 2;
}
return interval;
}
export function getTicks(min: number, max: number, timezone: string): EuiRangeTick[] {
const interval = getInterval(min, max);
const format = getScaledDateFormat(interval);
let tickValue = Math.ceil(min / interval) * interval;
const ticks: EuiRangeTick[] = [];
while (tickValue <= max) {
ticks.push({
value: tickValue,
label: moment.tz(tickValue, getMomentTimezone(timezone)).format(format),
});
tickValue += interval;
}
return ticks.length <= 12
? ticks
: ticks.map((tick, index) => {
return {
...tick,
value: tick.value,
// to avoid label overlap, only display even tick labels
// Passing empty string as tick label results in tick not rendering, so must wrap empty label in react element
label: index % 2 === 0 ? tick.label : <span>&nbsp;</span>,
};
});
}
export function getStepSize(ticks: EuiRangeTick[]): {
stepSize: number;
format: string;
} {
if (ticks.length < 2) {
return {
stepSize: 1,
format: 'MMM D, YYYY @ HH:mm:ss.SSS',
};
}
const tickRange = ticks[1].value - ticks[0].value;
if (tickRange >= moment.duration(2, 'y').asMilliseconds()) {
return {
stepSize: moment.duration(1, 'y').asMilliseconds(),
format: 'YYYY',
};
}
if (tickRange >= moment.duration(2, 'd').asMilliseconds()) {
return {
stepSize: moment.duration(1, 'd').asMilliseconds(),
format: 'MMM D, YYYY',
};
}
if (tickRange >= moment.duration(2, 'h').asMilliseconds()) {
return {
stepSize: moment.duration(1, 'h').asMilliseconds(),
format: 'MMM D, YYYY @ HH:mm',
};
}
if (tickRange >= moment.duration(2, 'm').asMilliseconds()) {
return {
stepSize: moment.duration(1, 'm').asMilliseconds(),
format: 'MMM D, YYYY @ HH:mm',
};
}
if (tickRange >= moment.duration(2, 's').asMilliseconds()) {
return {
stepSize: moment.duration(1, 's').asMilliseconds(),
format: 'MMM D, YYYY @ HH:mm:ss',
};
}
return {
stepSize: 1,
format: 'MMM D, YYYY @ HH:mm:ss.SSS',
};
}
export function roundDownToNextStepSizeFactor(value: number, stepSize: number) {
const remainder = value % stepSize;
return remainder === 0 ? value : value - remainder;
}
export function roundUpToNextStepSizeFactor(value: number, stepSize: number) {
const remainder = value % stepSize;
return remainder === 0 ? value : value + (stepSize - remainder);
}

View file

@ -0,0 +1,30 @@
/*
* 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 type { PublishesTimeslice } from '@kbn/presentation-publishing';
import type { DefaultControlApi, DefaultControlState } from '../types';
export const TIMESLIDER_CONTROL_TYPE = 'timesliderControl';
export type Timeslice = [number, number];
export interface TimesliderControlState extends DefaultControlState {
isAnchored?: boolean;
// Encode value as percentage of time range to support relative time ranges.
timesliceStartAsPercentageOfTimeRange?: number;
timesliceEndAsPercentageOfTimeRange?: number;
}
export type TimesliderControlApi = DefaultControlApi & PublishesTimeslice;
export interface Services {
core: CoreStart;
data: DataPublicPluginStart;
}

View file

@ -33,7 +33,7 @@ export interface PublishesControlDisplaySettings {
}
export interface HasCustomPrepend {
getCustomPrepend: () => React.FC<{}>;
CustomPrependComponent: React.FC<{}>;
}
export type DefaultControlApi = PublishesDataLoading &

View file

@ -34,5 +34,6 @@
"@kbn/ui-theme",
"@kbn/core-lifecycle-browser",
"@kbn/presentation-panel-plugin",
"@kbn/datemath",
]
}

View file

@ -38,6 +38,7 @@ export {
apiPublishesFilters,
apiPublishesPartialUnifiedSearch,
apiPublishesTimeRange,
apiPublishesTimeslice,
apiPublishesUnifiedSearch,
apiPublishesWritableUnifiedSearch,
useSearchApi,

View file

@ -12,10 +12,10 @@ import { BehaviorSubject } from 'rxjs';
import { PublishingSubject } from '../../publishing_subject';
export interface PublishesTimeslice {
timeslice$?: PublishingSubject<[number, number] | undefined>;
timeslice$: PublishingSubject<[number, number] | undefined>;
}
export interface PublishesTimeRange extends PublishesTimeslice {
export interface PublishesTimeRange extends Partial<PublishesTimeslice> {
timeRange$: PublishingSubject<TimeRange | undefined>;
timeRestore$?: PublishingSubject<boolean | undefined>;
}
@ -40,6 +40,12 @@ export type PublishesWritableUnifiedSearch = PublishesUnifiedSearch &
setQuery: (query: Query | undefined) => void;
};
export const apiPublishesTimeslice = (
unknownApi: null | unknown
): unknownApi is PublishesTimeslice => {
return Boolean(unknownApi && (unknownApi as PublishesTimeslice)?.timeslice$ !== undefined);
};
export const apiPublishesTimeRange = (
unknownApi: null | unknown
): unknownApi is PublishesTimeRange => {
@ -56,7 +62,7 @@ export const apiPublishesUnifiedSearch = (
return Boolean(
unknownApi &&
apiPublishesTimeRange(unknownApi) &&
(unknownApi as PublishesUnifiedSearch)?.filters$ !== undefined &&
apiPublishesFilters(unknownApi) &&
(unknownApi as PublishesUnifiedSearch)?.query$ !== undefined
);
};
@ -66,7 +72,7 @@ export const apiPublishesPartialUnifiedSearch = (
): unknownApi is Partial<PublishesUnifiedSearch> => {
return Boolean(
apiPublishesTimeRange(unknownApi) ||
(unknownApi as PublishesUnifiedSearch)?.filters$ !== undefined ||
apiPublishesFilters(unknownApi) ||
(unknownApi as PublishesUnifiedSearch)?.query$ !== undefined
);
};