-
+
diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.test.tsx b/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.test.tsx
new file mode 100644
index 0000000000000..d785a30324e4e
--- /dev/null
+++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.test.tsx
@@ -0,0 +1,45 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { NavigationPanel } from './navigation_panel';
+import { getDefaultMapSettings } from '../../reducers/default_map_settings';
+import { INITIAL_LOCATION } from '../../../common/constants';
+
+const defaultProps = {
+ center: { lat: 0, lon: 0 },
+ settings: getDefaultMapSettings(),
+ updateMapSetting: () => {},
+ zoom: 0,
+};
+
+test('should render', async () => {
+ const component = shallow(
);
+
+ expect(component).toMatchSnapshot();
+});
+
+test('should render fixed location form when initialLocation is FIXED_LOCATION', async () => {
+ const settings = {
+ ...defaultProps.settings,
+ initialLocation: INITIAL_LOCATION.FIXED_LOCATION,
+ };
+ const component = shallow(
);
+
+ expect(component).toMatchSnapshot();
+});
+
+test('should render browser location form when initialLocation is BROWSER_LOCATION', async () => {
+ const settings = {
+ ...defaultProps.settings,
+ initialLocation: INITIAL_LOCATION.BROWSER_LOCATION,
+ };
+ const component = shallow(
);
+
+ expect(component).toMatchSnapshot();
+});
diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.tsx b/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.tsx
index ed83e838f44f6..0e12f20dd9a7a 100644
--- a/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.tsx
+++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.tsx
@@ -4,25 +4,198 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import React from 'react';
-import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui';
+import React, { ChangeEvent } from 'react';
+import {
+ EuiButtonEmpty,
+ EuiFieldNumber,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiFormRow,
+ EuiPanel,
+ EuiRadioGroup,
+ EuiSpacer,
+ EuiTitle,
+} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { MapSettings } from '../../reducers/map';
import { ValidatedDualRange, Value } from '../../../../../../src/plugins/kibana_react/public';
-import { MAX_ZOOM, MIN_ZOOM } from '../../../common/constants';
+import { INITIAL_LOCATION, MAX_ZOOM, MIN_ZOOM } from '../../../common/constants';
+import { MapCenter } from '../../../common/descriptor_types';
+// @ts-ignore
+import { ValidatedRange } from '../../components/validated_range';
interface Props {
+ center: MapCenter;
settings: MapSettings;
- updateMapSetting: (settingKey: string, settingValue: string | number | boolean) => void;
+ updateMapSetting: (settingKey: string, settingValue: string | number | boolean | object) => void;
+ zoom: number;
}
-export function NavigationPanel({ settings, updateMapSetting }: Props) {
+const initialLocationOptions = [
+ {
+ id: INITIAL_LOCATION.LAST_SAVED_LOCATION,
+ label: i18n.translate('xpack.maps.mapSettingsPanel.lastSavedLocationLabel', {
+ defaultMessage: 'Map location at save',
+ }),
+ },
+ {
+ id: INITIAL_LOCATION.FIXED_LOCATION,
+ label: i18n.translate('xpack.maps.mapSettingsPanel.fixedLocationLabel', {
+ defaultMessage: 'Fixed location',
+ }),
+ },
+ {
+ id: INITIAL_LOCATION.BROWSER_LOCATION,
+ label: i18n.translate('xpack.maps.mapSettingsPanel.browserLocationLabel', {
+ defaultMessage: 'Browser location',
+ }),
+ },
+];
+
+export function NavigationPanel({ center, settings, updateMapSetting, zoom }: Props) {
const onZoomChange = (value: Value) => {
- updateMapSetting('minZoom', Math.max(MIN_ZOOM, parseInt(value[0] as string, 10)));
- updateMapSetting('maxZoom', Math.min(MAX_ZOOM, parseInt(value[1] as string, 10)));
+ const minZoom = Math.max(MIN_ZOOM, parseInt(value[0] as string, 10));
+ const maxZoom = Math.min(MAX_ZOOM, parseInt(value[1] as string, 10));
+ updateMapSetting('minZoom', minZoom);
+ updateMapSetting('maxZoom', maxZoom);
+
+ // ensure fixed zoom and browser zoom stay within defined min/max
+ if (settings.fixedLocation.zoom < minZoom) {
+ onFixedZoomChange(minZoom);
+ } else if (settings.fixedLocation.zoom > maxZoom) {
+ onFixedZoomChange(maxZoom);
+ }
+
+ if (settings.browserLocation.zoom < minZoom) {
+ onBrowserZoomChange(minZoom);
+ } else if (settings.browserLocation.zoom > maxZoom) {
+ onBrowserZoomChange(maxZoom);
+ }
+ };
+
+ const onInitialLocationChange = (optionId: string): void => {
+ updateMapSetting('initialLocation', optionId);
+ };
+
+ const onFixedLatChange = (event: ChangeEvent
) => {
+ let value = parseFloat(event.target.value);
+ if (isNaN(value)) {
+ value = 0;
+ } else if (value < -90) {
+ value = -90;
+ } else if (value > 90) {
+ value = 90;
+ }
+ updateMapSetting('fixedLocation', { ...settings.fixedLocation, lat: value });
+ };
+
+ const onFixedLonChange = (event: ChangeEvent) => {
+ let value = parseFloat(event.target.value);
+ if (isNaN(value)) {
+ value = 0;
+ } else if (value < -180) {
+ value = -180;
+ } else if (value > 180) {
+ value = 180;
+ }
+ updateMapSetting('fixedLocation', { ...settings.fixedLocation, lon: value });
+ };
+
+ const onFixedZoomChange = (value: number) => {
+ updateMapSetting('fixedLocation', { ...settings.fixedLocation, zoom: value });
+ };
+
+ const onBrowserZoomChange = (value: number) => {
+ updateMapSetting('browserLocation', { zoom: value });
+ };
+
+ const useCurrentView = () => {
+ updateMapSetting('fixedLocation', {
+ lat: center.lat,
+ lon: center.lon,
+ zoom: Math.round(zoom),
+ });
};
+ function renderInitialLocationInputs() {
+ if (settings.initialLocation === INITIAL_LOCATION.LAST_SAVED_LOCATION) {
+ return null;
+ }
+
+ const zoomFormRow = (
+
+
+
+ );
+
+ if (settings.initialLocation === INITIAL_LOCATION.BROWSER_LOCATION) {
+ return zoomFormRow;
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+ {zoomFormRow}
+
+
+
+
+
+
+
+ >
+ );
+ }
+
return (
@@ -50,6 +223,19 @@ export function NavigationPanel({ settings, updateMapSetting }: Props) {
allowEmptyRange={false}
compressed
/>
+
+
+
+
+ {renderInitialLocationInputs()}
);
}
diff --git a/x-pack/plugins/maps/public/reducers/default_map_settings.ts b/x-pack/plugins/maps/public/reducers/default_map_settings.ts
index fe21b37434edd..9c9b814ae6add 100644
--- a/x-pack/plugins/maps/public/reducers/default_map_settings.ts
+++ b/x-pack/plugins/maps/public/reducers/default_map_settings.ts
@@ -4,11 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { MAX_ZOOM, MIN_ZOOM } from '../../common/constants';
+import { INITIAL_LOCATION, MAX_ZOOM, MIN_ZOOM } from '../../common/constants';
import { MapSettings } from './map';
export function getDefaultMapSettings(): MapSettings {
return {
+ initialLocation: INITIAL_LOCATION.LAST_SAVED_LOCATION,
+ fixedLocation: { lat: 0, lon: 0, zoom: 2 },
+ browserLocation: { zoom: 2 },
maxZoom: MAX_ZOOM,
minZoom: MIN_ZOOM,
showSpatialFilters: true,
diff --git a/x-pack/plugins/maps/public/reducers/map.d.ts b/x-pack/plugins/maps/public/reducers/map.d.ts
index be0700d4bdd6d..20e1dc1035e19 100644
--- a/x-pack/plugins/maps/public/reducers/map.d.ts
+++ b/x-pack/plugins/maps/public/reducers/map.d.ts
@@ -15,6 +15,7 @@ import {
MapRefreshConfig,
TooltipState,
} from '../../common/descriptor_types';
+import { INITIAL_LOCATION } from '../../common/constants';
import { Filter, TimeRange } from '../../../../../src/plugins/data/public';
export type MapContext = {
@@ -40,6 +41,15 @@ export type MapContext = {
};
export type MapSettings = {
+ initialLocation: INITIAL_LOCATION;
+ fixedLocation: {
+ lat: number;
+ lon: number;
+ zoom: number;
+ };
+ browserLocation: {
+ zoom: number;
+ };
maxZoom: number;
minZoom: number;
showSpatialFilters: boolean;
diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json
index e9d4aff3484b1..038f61b3a33b7 100644
--- a/x-pack/plugins/ml/kibana.json
+++ b/x-pack/plugins/ml/kibana.json
@@ -13,9 +13,7 @@
"home",
"licensing",
"usageCollection",
- "share",
- "embeddable",
- "uiActions"
+ "share"
],
"optionalPlugins": [
"security",
diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx
index decd1275fe884..9cc42a4df2f66 100644
--- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx
+++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx
@@ -4,15 +4,56 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classNames from 'classnames';
-import TooltipTrigger from 'react-popper-tooltip';
+import React, { useRef, FC } from 'react';
import { TooltipValueFormatter } from '@elastic/charts';
+import useObservable from 'react-use/lib/useObservable';
-import './_index.scss';
+import { chartTooltip$, ChartTooltipState, ChartTooltipValue } from './chart_tooltip_service';
-import { ChildrenArg, TooltipTriggerProps } from 'react-popper-tooltip/dist/types';
-import { ChartTooltipService, ChartTooltipValue, TooltipData } from './chart_tooltip_service';
+type RefValue = HTMLElement | null;
+
+function useRefWithCallback(chartTooltipState?: ChartTooltipState) {
+ const ref = useRef(null);
+
+ return (node: RefValue) => {
+ ref.current = node;
+
+ if (
+ node !== null &&
+ node.parentElement !== null &&
+ chartTooltipState !== undefined &&
+ chartTooltipState.isTooltipVisible
+ ) {
+ const parentBounding = node.parentElement.getBoundingClientRect();
+
+ const { targetPosition, offset } = chartTooltipState;
+
+ const contentWidth = document.body.clientWidth - parentBounding.left;
+ const tooltipWidth = node.clientWidth;
+
+ let left = targetPosition.left + offset.x - parentBounding.left;
+ if (left + tooltipWidth > contentWidth) {
+ // the tooltip is hanging off the side of the page,
+ // so move it to the other side of the target
+ left = left - (tooltipWidth + offset.x);
+ }
+
+ const top = targetPosition.top + offset.y - parentBounding.top;
+
+ if (
+ chartTooltipState.tooltipPosition.left !== left ||
+ chartTooltipState.tooltipPosition.top !== top
+ ) {
+ // render the tooltip with adjusted position.
+ chartTooltip$.next({
+ ...chartTooltipState,
+ tooltipPosition: { left, top },
+ });
+ }
+ }
+ };
+}
const renderHeader = (headerData?: ChartTooltipValue, formatter?: TooltipValueFormatter) => {
if (!headerData) {
@@ -22,101 +63,48 @@ const renderHeader = (headerData?: ChartTooltipValue, formatter?: TooltipValueFo
return formatter ? formatter(headerData) : headerData.label;
};
-const Tooltip: FC<{ service: ChartTooltipService }> = React.memo(({ service }) => {
- const [tooltipData, setData] = useState([]);
- const refCallback = useRef();
+export const ChartTooltip: FC = () => {
+ const chartTooltipState = useObservable(chartTooltip$);
+ const chartTooltipElement = useRefWithCallback(chartTooltipState);
- useEffect(() => {
- const subscription = service.tooltipState$.subscribe(tooltipState => {
- if (refCallback.current) {
- // update trigger
- refCallback.current(tooltipState.target);
- }
- setData(tooltipState.tooltipData);
- });
- return () => {
- subscription.unsubscribe();
- };
- }, []);
-
- const triggerCallback = useCallback(
- (({ triggerRef }) => {
- // obtain the reference to the trigger setter callback
- // to update the target based on changes from the service.
- refCallback.current = triggerRef;
- // actual trigger is resolved by the service, hence don't render
- return null;
- }) as TooltipTriggerProps['children'],
- []
- );
-
- const tooltipCallback = useCallback(
- (({ tooltipRef, getTooltipProps }) => {
- return (
-
- {tooltipData.length > 0 && tooltipData[0].skipHeader === undefined && (
-
{renderHeader(tooltipData[0])}
- )}
- {tooltipData.length > 1 && (
-
- {tooltipData
- .slice(1)
- .map(({ label, value, color, isHighlighted, seriesIdentifier, valueAccessor }) => {
- const classes = classNames('mlChartTooltip__item', {
- /* eslint @typescript-eslint/camelcase:0 */
- echTooltip__rowHighlighted: isHighlighted,
- });
- return (
-
- {label}
- {value}
-
- );
- })}
-
- )}
-
- );
- }) as TooltipTriggerProps['tooltip'],
- [tooltipData]
- );
-
- const isTooltipShown = tooltipData.length > 0;
-
- return (
-
- {triggerCallback}
-
- );
-});
-
-interface MlTooltipComponentProps {
- children: (tooltipService: ChartTooltipService) => React.ReactElement;
-}
+ if (chartTooltipState === undefined || !chartTooltipState.isTooltipVisible) {
+ return ;
+ }
-export const MlTooltipComponent: FC = ({ children }) => {
- const service = useMemo(() => new ChartTooltipService(), []);
+ const { tooltipData, tooltipHeaderFormatter, tooltipPosition } = chartTooltipState;
+ const transform = `translate(${tooltipPosition.left}px, ${tooltipPosition.top}px)`;
return (
- <>
-
- {children(service)}
- >
+
+ {tooltipData.length > 0 && tooltipData[0].skipHeader === undefined && (
+
+ {renderHeader(tooltipData[0], tooltipHeaderFormatter)}
+
+ )}
+ {tooltipData.length > 1 && (
+
+ {tooltipData
+ .slice(1)
+ .map(({ label, value, color, isHighlighted, seriesIdentifier, valueAccessor }) => {
+ const classes = classNames('mlChartTooltip__item', {
+ /* eslint @typescript-eslint/camelcase:0 */
+ echTooltip__rowHighlighted: isHighlighted,
+ });
+ return (
+
+ {label}
+ {value}
+
+ );
+ })}
+
+ )}
+
);
};
diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.d.ts b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.d.ts
new file mode 100644
index 0000000000000..e6b0b6b4270bd
--- /dev/null
+++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.d.ts
@@ -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;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { BehaviorSubject } from 'rxjs';
+
+import { TooltipValue, TooltipValueFormatter } from '@elastic/charts';
+
+export declare const getChartTooltipDefaultState: () => ChartTooltipState;
+
+export interface ChartTooltipValue extends TooltipValue {
+ skipHeader?: boolean;
+}
+
+interface ChartTooltipState {
+ isTooltipVisible: boolean;
+ offset: ToolTipOffset;
+ targetPosition: ClientRect;
+ tooltipData: ChartTooltipValue[];
+ tooltipHeaderFormatter?: TooltipValueFormatter;
+ tooltipPosition: { left: number; top: number };
+}
+
+export declare const chartTooltip$: BehaviorSubject;
+
+interface ToolTipOffset {
+ x: number;
+ y: number;
+}
+
+interface MlChartTooltipService {
+ show: (
+ tooltipData: ChartTooltipValue[],
+ target?: HTMLElement | null,
+ offset?: ToolTipOffset
+ ) => void;
+ hide: () => void;
+}
+
+export declare const mlChartTooltipService: MlChartTooltipService;
diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.js b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.js
new file mode 100644
index 0000000000000..59cf98e5ffd71
--- /dev/null
+++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.js
@@ -0,0 +1,37 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { BehaviorSubject } from 'rxjs';
+
+export const getChartTooltipDefaultState = () => ({
+ isTooltipVisible: false,
+ tooltipData: [],
+ offset: { x: 0, y: 0 },
+ targetPosition: { left: 0, top: 0 },
+ tooltipPosition: { left: 0, top: 0 },
+});
+
+export const chartTooltip$ = new BehaviorSubject(getChartTooltipDefaultState());
+
+export const mlChartTooltipService = {
+ show: (tooltipData, target, offset = { x: 0, y: 0 }) => {
+ if (typeof target !== 'undefined' && target !== null) {
+ chartTooltip$.next({
+ ...chartTooltip$.getValue(),
+ isTooltipVisible: true,
+ offset,
+ targetPosition: target.getBoundingClientRect(),
+ tooltipData,
+ });
+ }
+ },
+ hide: () => {
+ chartTooltip$.next({
+ ...getChartTooltipDefaultState(),
+ isTooltipVisible: false,
+ });
+ },
+};
diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.test.ts b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.test.ts
index 231854cd264c2..aa1dbf92b0677 100644
--- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.test.ts
+++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.test.ts
@@ -4,61 +4,18 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import {
- ChartTooltipService,
- getChartTooltipDefaultState,
- TooltipData,
-} from './chart_tooltip_service';
+import { getChartTooltipDefaultState, mlChartTooltipService } from './chart_tooltip_service';
-describe('ChartTooltipService', () => {
- let service: ChartTooltipService;
-
- beforeEach(() => {
- service = new ChartTooltipService();
- });
-
- test('should update the tooltip state on show and hide', () => {
- const spy = jest.fn();
-
- service.tooltipState$.subscribe(spy);
-
- expect(spy).toHaveBeenCalledWith(getChartTooltipDefaultState());
-
- const update = [
- {
- label: 'new tooltip',
- },
- ] as TooltipData;
- const mockEl = document.createElement('div');
-
- service.show(update, mockEl);
-
- expect(spy).toHaveBeenCalledWith({
- isTooltipVisible: true,
- tooltipData: update,
- offset: { x: 0, y: 0 },
- target: mockEl,
- });
-
- service.hide();
-
- expect(spy).toHaveBeenCalledWith({
- isTooltipVisible: false,
- tooltipData: ([] as unknown) as TooltipData,
- offset: { x: 0, y: 0 },
- target: null,
- });
+describe('ML - mlChartTooltipService', () => {
+ it('service API duck typing', () => {
+ expect(typeof mlChartTooltipService).toBe('object');
+ expect(typeof mlChartTooltipService.show).toBe('function');
+ expect(typeof mlChartTooltipService.hide).toBe('function');
});
- test('update the tooltip state only on a new value', () => {
- const spy = jest.fn();
-
- service.tooltipState$.subscribe(spy);
-
- expect(spy).toHaveBeenCalledWith(getChartTooltipDefaultState());
-
- service.hide();
-
- expect(spy).toHaveBeenCalledTimes(1);
+ it('should fail silently when target is not defined', () => {
+ expect(() => {
+ mlChartTooltipService.show(getChartTooltipDefaultState().tooltipData, null);
+ }).not.toThrow('Call to show() should fail silently.');
});
});
diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.ts b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.ts
deleted file mode 100644
index b524e18102a95..0000000000000
--- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import { BehaviorSubject, Observable } from 'rxjs';
-import { isEqual } from 'lodash';
-import { TooltipValue, TooltipValueFormatter } from '@elastic/charts';
-import { distinctUntilChanged } from 'rxjs/operators';
-
-export interface ChartTooltipValue extends TooltipValue {
- skipHeader?: boolean;
-}
-
-export interface TooltipHeader {
- skipHeader: boolean;
-}
-
-export type TooltipData = ChartTooltipValue[];
-
-export interface ChartTooltipState {
- isTooltipVisible: boolean;
- offset: TooltipOffset;
- tooltipData: TooltipData;
- tooltipHeaderFormatter?: TooltipValueFormatter;
- target: HTMLElement | null;
-}
-
-interface TooltipOffset {
- x: number;
- y: number;
-}
-
-export const getChartTooltipDefaultState = (): ChartTooltipState => ({
- isTooltipVisible: false,
- tooltipData: ([] as unknown) as TooltipData,
- offset: { x: 0, y: 0 },
- target: null,
-});
-
-export class ChartTooltipService {
- private chartTooltip$ = new BehaviorSubject(getChartTooltipDefaultState());
-
- public tooltipState$: Observable = this.chartTooltip$
- .asObservable()
- .pipe(distinctUntilChanged(isEqual));
-
- public show(
- tooltipData: TooltipData,
- target: HTMLElement,
- offset: TooltipOffset = { x: 0, y: 0 }
- ) {
- if (!target) {
- throw new Error('target is required for the tooltip positioning');
- }
-
- this.chartTooltip$.next({
- ...this.chartTooltip$.getValue(),
- isTooltipVisible: true,
- offset,
- tooltipData,
- target,
- });
- }
-
- public hide() {
- this.chartTooltip$.next({
- ...getChartTooltipDefaultState(),
- isTooltipVisible: false,
- });
- }
-}
diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/index.ts b/x-pack/plugins/ml/public/application/components/chart_tooltip/index.ts
index ec19fe18bd324..75c65ebaa0f50 100644
--- a/x-pack/plugins/ml/public/application/components/chart_tooltip/index.ts
+++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/index.ts
@@ -4,5 +4,5 @@
* you may not use this file except in compliance with the Elastic License.
*/
-export { ChartTooltipService } from './chart_tooltip_service';
-export { MlTooltipComponent } from './chart_tooltip';
+export { mlChartTooltipService } from './chart_tooltip_service';
+export { ChartTooltip } from './chart_tooltip';
diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx b/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx
index f709c161bef17..381e5e75356c1 100644
--- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx
+++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx
@@ -4,23 +4,45 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useRef, useCallback } from 'react';
+import PropTypes from 'prop-types';
+
+import {
+ EuiButton,
+ EuiButtonEmpty,
+ EuiFlexItem,
+ EuiFlexGroup,
+ EuiFlyout,
+ EuiFlyoutBody,
+ EuiFlyoutFooter,
+ EuiFlyoutHeader,
+ EuiSwitch,
+ EuiTitle,
+} from '@elastic/eui';
-import { EuiButtonEmpty, EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
+import { useMlKibana } from '../../contexts/kibana';
import { Dictionary } from '../../../../common/types/common';
+import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs';
+import { ml } from '../../services/ml_api_service';
import { useUrlState } from '../../util/url_state';
// @ts-ignore
+import { JobSelectorTable } from './job_selector_table/index';
+// @ts-ignore
import { IdBadges } from './id_badges/index';
-import { BADGE_LIMIT, JobSelectorFlyout, JobSelectorFlyoutProps } from './job_selector_flyout';
-import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs';
+// @ts-ignore
+import { NewSelectionIdBadges } from './new_selection_id_badges/index';
+import {
+ getGroupsFromJobs,
+ getTimeRangeFromSelection,
+ normalizeTimes,
+} from './job_select_service_utils';
interface GroupObj {
groupId: string;
jobIds: string[];
}
-
function mergeSelection(
jobIds: string[],
groupObjs: GroupObj[],
@@ -49,7 +71,7 @@ function mergeSelection(
}
type GroupsMap = Dictionary;
-export function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap {
+function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap {
const map: GroupsMap = {};
if (selectedGroups.length) {
@@ -61,38 +83,81 @@ export function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap {
return map;
}
+const BADGE_LIMIT = 10;
+const DEFAULT_GANTT_BAR_WIDTH = 299; // pixels
+
interface JobSelectorProps {
dateFormatTz: string;
singleSelection: boolean;
timeseriesOnly: boolean;
}
-export interface JobSelectionMaps {
- jobsMap: Dictionary;
- groupsMap: Dictionary;
-}
-
export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: JobSelectorProps) {
const [globalState, setGlobalState] = useUrlState('_g');
const selectedJobIds = globalState?.ml?.jobIds ?? [];
const selectedGroups = globalState?.ml?.groups ?? [];
- const [maps, setMaps] = useState({
- groupsMap: getInitialGroupsMap(selectedGroups),
- jobsMap: {},
- });
+ const [jobs, setJobs] = useState([]);
+ const [groups, setGroups] = useState([]);
+ const [maps, setMaps] = useState({ groupsMap: getInitialGroupsMap(selectedGroups), jobsMap: {} });
const [selectedIds, setSelectedIds] = useState(
mergeSelection(selectedJobIds, selectedGroups, singleSelection)
);
+ const [newSelection, setNewSelection] = useState(
+ mergeSelection(selectedJobIds, selectedGroups, singleSelection)
+ );
+ const [showAllBadges, setShowAllBadges] = useState(false);
const [showAllBarBadges, setShowAllBarBadges] = useState(false);
+ const [applyTimeRange, setApplyTimeRange] = useState(true);
const [isFlyoutVisible, setIsFlyoutVisible] = useState(false);
+ const [ganttBarWidth, setGanttBarWidth] = useState(DEFAULT_GANTT_BAR_WIDTH);
+ const flyoutEl = useRef<{ flyout: HTMLElement }>(null);
+ const {
+ services: { notifications },
+ } = useMlKibana();
// Ensure JobSelectionBar gets updated when selection via globalState changes.
useEffect(() => {
setSelectedIds(mergeSelection(selectedJobIds, selectedGroups, singleSelection));
}, [JSON.stringify([selectedJobIds, selectedGroups])]);
+ // Ensure current selected ids always show up in flyout
+ useEffect(() => {
+ setNewSelection(selectedIds);
+ }, [isFlyoutVisible]); // eslint-disable-line
+
+ // Wrap handleResize in useCallback as it is a dependency for useEffect on line 131 below.
+ // Not wrapping it would cause this dependency to change on every render
+ const handleResize = useCallback(() => {
+ if (jobs.length > 0 && flyoutEl && flyoutEl.current && flyoutEl.current.flyout) {
+ // get all cols in flyout table
+ const tableHeaderCols: NodeListOf = flyoutEl.current.flyout.querySelectorAll(
+ 'table thead th'
+ );
+ // get the width of the last col
+ const derivedWidth = tableHeaderCols[tableHeaderCols.length - 1].offsetWidth - 16;
+ const normalizedJobs = normalizeTimes(jobs, dateFormatTz, derivedWidth);
+ setJobs(normalizedJobs);
+ const { groups: updatedGroups } = getGroupsFromJobs(normalizedJobs);
+ setGroups(updatedGroups);
+ setGanttBarWidth(derivedWidth);
+ }
+ }, [dateFormatTz, jobs]);
+
+ useEffect(() => {
+ // Ensure ganttBar width gets calculated on resize
+ window.addEventListener('resize', handleResize);
+
+ return () => {
+ window.removeEventListener('resize', handleResize);
+ };
+ }, [handleResize]);
+
+ useEffect(() => {
+ handleResize();
+ }, [handleResize, jobs]);
+
function closeFlyout() {
setIsFlyoutVisible(false);
}
@@ -103,26 +168,78 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J
function handleJobSelectionClick() {
showFlyout();
+
+ ml.jobs
+ .jobsWithTimerange(dateFormatTz)
+ .then(resp => {
+ const normalizedJobs = normalizeTimes(resp.jobs, dateFormatTz, DEFAULT_GANTT_BAR_WIDTH);
+ const { groups: groupsWithTimerange, groupsMap } = getGroupsFromJobs(normalizedJobs);
+ setJobs(normalizedJobs);
+ setGroups(groupsWithTimerange);
+ setMaps({ groupsMap, jobsMap: resp.jobsMap });
+ })
+ .catch((err: any) => {
+ console.error('Error fetching jobs with time range', err); // eslint-disable-line
+ const { toasts } = notifications;
+ toasts.addDanger({
+ title: i18n.translate('xpack.ml.jobSelector.jobFetchErrorMessage', {
+ defaultMessage: 'An error occurred fetching jobs. Refresh and try again.',
+ }),
+ });
+ });
+ }
+
+ function handleNewSelection({ selectionFromTable }: { selectionFromTable: any }) {
+ setNewSelection(selectionFromTable);
}
- const applySelection: JobSelectorFlyoutProps['onSelectionConfirmed'] = ({
- newSelection,
- jobIds,
- groups: newGroups,
- time,
- }) => {
+ function applySelection() {
+ // allNewSelection will be a list of all job ids (including those from groups) selected from the table
+ const allNewSelection: string[] = [];
+ const groupSelection: Array<{ groupId: string; jobIds: string[] }> = [];
+
+ newSelection.forEach(id => {
+ if (maps.groupsMap[id] !== undefined) {
+ // Push all jobs from selected groups into the newSelection list
+ allNewSelection.push(...maps.groupsMap[id]);
+ // if it's a group - push group obj to set in global state
+ groupSelection.push({ groupId: id, jobIds: maps.groupsMap[id] });
+ } else {
+ allNewSelection.push(id);
+ }
+ });
+ // create a Set to remove duplicate values
+ const allNewSelectionUnique = Array.from(new Set(allNewSelection));
+
setSelectedIds(newSelection);
+ setNewSelection([]);
+
+ closeFlyout();
+
+ const time = applyTimeRange
+ ? getTimeRangeFromSelection(jobs, allNewSelectionUnique)
+ : undefined;
setGlobalState({
ml: {
- jobIds,
- groups: newGroups,
+ jobIds: allNewSelectionUnique,
+ groups: groupSelection,
},
...(time !== undefined ? { time } : {}),
});
+ }
- closeFlyout();
- };
+ function toggleTimerangeSwitch() {
+ setApplyTimeRange(!applyTimeRange);
+ }
+
+ function removeId(id: string) {
+ setNewSelection(newSelection.filter(item => item !== id));
+ }
+
+ function clearSelection() {
+ setNewSelection([]);
+ }
function renderJobSelectionBar() {
return (
@@ -163,16 +280,103 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J
function renderFlyout() {
if (isFlyoutVisible) {
return (
-
+
+
+
+
+ {i18n.translate('xpack.ml.jobSelector.flyoutTitle', {
+ defaultMessage: 'Job selection',
+ })}
+
+
+
+
+
+
+
+ setShowAllBadges(!showAllBadges)}
+ showAllBadges={showAllBadges}
+ />
+
+
+
+
+
+ {!singleSelection && newSelection.length > 0 && (
+
+ {i18n.translate('xpack.ml.jobSelector.clearAllFlyoutButton', {
+ defaultMessage: 'Clear all',
+ })}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {i18n.translate('xpack.ml.jobSelector.applyFlyoutButton', {
+ defaultMessage: 'Apply',
+ })}
+
+
+
+
+ {i18n.translate('xpack.ml.jobSelector.closeFlyoutButton', {
+ defaultMessage: 'Close',
+ })}
+
+
+
+
+
);
}
}
@@ -184,3 +388,9 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J
);
}
+
+JobSelector.propTypes = {
+ selectedJobIds: PropTypes.array,
+ singleSelection: PropTypes.bool,
+ timeseriesOnly: PropTypes.bool,
+};
diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/index.ts b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/index.js
similarity index 100%
rename from x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/index.ts
rename to x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/index.js
diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.tsx b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.js
similarity index 68%
rename from x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.tsx
rename to x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.js
index b2cae278c0e77..4d2ab01e2a054 100644
--- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.tsx
+++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.js
@@ -4,32 +4,18 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import React, { FC } from 'react';
-import { EuiBadge, EuiBadgeProps } from '@elastic/eui';
-import { i18n } from '@kbn/i18n';
+import React from 'react';
+import { PropTypes } from 'prop-types';
+import { EuiBadge } from '@elastic/eui';
import { tabColor } from '../../../../../common/util/group_color_utils';
+import { i18n } from '@kbn/i18n';
-interface JobSelectorBadgeProps {
- icon?: boolean;
- id: string;
- isGroup?: boolean;
- numJobs?: number;
- removeId?: Function;
-}
-
-export const JobSelectorBadge: FC