mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
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:
parent
963a178b3d
commit
ee03a1a6ba
26 changed files with 1532 additions and 34 deletions
|
@ -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: [],
|
||||
}),
|
||||
})}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
→
|
||||
<span>{props.formatDate(props.to)}</span>
|
||||
</EuiText>
|
||||
</button>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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.',
|
||||
}),
|
||||
},
|
||||
};
|
|
@ -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()];
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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'
|
||||
>
|
||||
>,
|
||||
};
|
||||
}
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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> </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);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -33,7 +33,7 @@ export interface PublishesControlDisplaySettings {
|
|||
}
|
||||
|
||||
export interface HasCustomPrepend {
|
||||
getCustomPrepend: () => React.FC<{}>;
|
||||
CustomPrependComponent: React.FC<{}>;
|
||||
}
|
||||
|
||||
export type DefaultControlApi = PublishesDataLoading &
|
||||
|
|
|
@ -34,5 +34,6 @@
|
|||
"@kbn/ui-theme",
|
||||
"@kbn/core-lifecycle-browser",
|
||||
"@kbn/presentation-panel-plugin",
|
||||
"@kbn/datemath",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -38,6 +38,7 @@ export {
|
|||
apiPublishesFilters,
|
||||
apiPublishesPartialUnifiedSearch,
|
||||
apiPublishesTimeRange,
|
||||
apiPublishesTimeslice,
|
||||
apiPublishesUnifiedSearch,
|
||||
apiPublishesWritableUnifiedSearch,
|
||||
useSearchApi,
|
||||
|
|
|
@ -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
|
||||
);
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue