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 = ({ - icon, - id, - isGroup = false, - numJobs, - removeId, -}) => { +export function JobSelectorBadge({ icon, id, isGroup = false, numJobs, removeId }) { const color = isGroup ? tabColor(id) : 'hollow'; - let props = { color } as EuiBadgeProps; + let props = { color }; let jobCount; - if (icon === true && removeId) { - // @ts-ignore + if (icon === true) { props = { ...props, iconType: 'cross', @@ -51,4 +37,11 @@ export const JobSelectorBadge: FC = ({ {`${id}${jobCount ? jobCount : ''}`} ); +} +JobSelectorBadge.propTypes = { + icon: PropTypes.bool, + id: PropTypes.string.isRequired, + isGroup: PropTypes.bool, + numJobs: PropTypes.number, + removeId: PropTypes.func, }; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx deleted file mode 100644 index 66aa05d2aaa97..0000000000000 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx +++ /dev/null @@ -1,289 +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 React, { FC, useCallback, useEffect, useRef, useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { - EuiButton, - EuiButtonEmpty, - EuiFlexItem, - EuiFlexGroup, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlyoutHeader, - EuiSwitch, - EuiTitle, -} from '@elastic/eui'; -import { NewSelectionIdBadges } from './new_selection_id_badges'; -// @ts-ignore -import { JobSelectorTable } from './job_selector_table'; -import { - getGroupsFromJobs, - getTimeRangeFromSelection, - normalizeTimes, -} from './job_select_service_utils'; -import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; -import { ml } from '../../services/ml_api_service'; -import { useMlKibana } from '../../contexts/kibana'; -import { JobSelectionMaps } from './job_selector'; - -export const BADGE_LIMIT = 10; -export const DEFAULT_GANTT_BAR_WIDTH = 299; // pixels - -export interface JobSelectorFlyoutProps { - dateFormatTz: string; - selectedIds?: string[]; - newSelection?: string[]; - onFlyoutClose: () => void; - onJobsFetched?: (maps: JobSelectionMaps) => void; - onSelectionChange?: (newSelection: string[]) => void; - onSelectionConfirmed: (payload: { - newSelection: string[]; - jobIds: string[]; - groups: Array<{ groupId: string; jobIds: string[] }>; - time: any; - }) => void; - singleSelection: boolean; - timeseriesOnly: boolean; - maps: JobSelectionMaps; - withTimeRangeSelector?: boolean; -} - -export const JobSelectorFlyout: FC = ({ - dateFormatTz, - selectedIds = [], - singleSelection, - timeseriesOnly, - onJobsFetched, - onSelectionChange, - onSelectionConfirmed, - onFlyoutClose, - maps, - withTimeRangeSelector = true, -}) => { - const { - services: { notifications }, - } = useMlKibana(); - - const [newSelection, setNewSelection] = useState(selectedIds); - - const [showAllBadges, setShowAllBadges] = useState(false); - const [applyTimeRange, setApplyTimeRange] = useState(true); - const [jobs, setJobs] = useState([]); - const [groups, setGroups] = useState([]); - const [ganttBarWidth, setGanttBarWidth] = useState(DEFAULT_GANTT_BAR_WIDTH); - const [jobGroupsMaps, setJobGroupsMaps] = useState(maps); - - const flyoutEl = useRef<{ flyout: HTMLElement }>(null); - - 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 (jobGroupsMaps.groupsMap[id] !== undefined) { - // Push all jobs from selected groups into the newSelection list - allNewSelection.push(...jobGroupsMaps.groupsMap[id]); - // if it's a group - push group obj to set in global state - groupSelection.push({ groupId: id, jobIds: jobGroupsMaps.groupsMap[id] }); - } else { - allNewSelection.push(id); - } - }); - // create a Set to remove duplicate values - const allNewSelectionUnique = Array.from(new Set(allNewSelection)); - - const time = applyTimeRange - ? getTimeRangeFromSelection(jobs, allNewSelectionUnique) - : undefined; - - onSelectionConfirmed({ - newSelection: allNewSelectionUnique, - jobIds: allNewSelectionUnique, - groups: groupSelection, - time, - }); - } - - function removeId(id: string) { - setNewSelection(newSelection.filter(item => item !== id)); - } - - function toggleTimerangeSwitch() { - setApplyTimeRange(!applyTimeRange); - } - - function clearSelection() { - setNewSelection([]); - } - - function handleNewSelection({ selectionFromTable }: { selectionFromTable: any }) { - setNewSelection(selectionFromTable); - } - - // 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]); - - // Fetch jobs list on flyout open - useEffect(() => { - fetchJobs(); - }, []); - - async function fetchJobs() { - try { - const resp = await ml.jobs.jobsWithTimerange(dateFormatTz); - const normalizedJobs = normalizeTimes(resp.jobs, dateFormatTz, DEFAULT_GANTT_BAR_WIDTH); - const { groups: groupsWithTimerange, groupsMap } = getGroupsFromJobs(normalizedJobs); - setJobs(normalizedJobs); - setGroups(groupsWithTimerange); - setJobGroupsMaps({ groupsMap, jobsMap: resp.jobsMap }); - - if (onJobsFetched) { - onJobsFetched({ groupsMap, jobsMap: resp.jobsMap }); - } - } catch (e) { - console.error('Error fetching jobs with time range', e); // 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.', - }), - }); - } - } - - useEffect(() => { - // Ensure ganttBar width gets calculated on resize - window.addEventListener('resize', handleResize); - - return () => { - window.removeEventListener('resize', handleResize); - }; - }, [handleResize]); - - useEffect(() => { - handleResize(); - }, [handleResize, jobs]); - - 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', - })} - - )} - - {withTimeRangeSelector && ( - - - - )} - - - - - - - - - - {i18n.translate('xpack.ml.jobSelector.applyFlyoutButton', { - defaultMessage: 'Apply', - })} - - - - - {i18n.translate('xpack.ml.jobSelector.closeFlyoutButton', { - defaultMessage: 'Close', - })} - - - - -
- ); -}; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js index c55e03776c09d..64793d15f1e4a 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js @@ -224,7 +224,7 @@ export function JobSelectorTable({ {jobs.length === 0 && } {jobs.length !== 0 && singleSelection === true && renderJobsTable()} - {jobs.length !== 0 && !singleSelection && renderTabs()} + {jobs.length !== 0 && singleSelection === undefined && renderTabs()} ); } diff --git a/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/index.ts b/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/index.js similarity index 100% rename from x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/index.ts rename to x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/index.js diff --git a/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx b/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.js similarity index 80% rename from x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx rename to x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.js index 4c018e72f3e10..67dce47323889 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx +++ b/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.js @@ -4,29 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, MouseEventHandler } from 'react'; +import React from 'react'; +import { PropTypes } from 'prop-types'; import { EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import { JobSelectorBadge } from '../job_selector_badge'; -import { JobSelectionMaps } from '../job_selector'; - -interface NewSelectionIdBadgesProps { - limit: number; - maps: JobSelectionMaps; - newSelection: string[]; - onDeleteClick?: Function; - onLinkClick?: MouseEventHandler; - showAllBadges?: boolean; -} +import { i18n } from '@kbn/i18n'; -export const NewSelectionIdBadges: FC = ({ +export function NewSelectionIdBadges({ limit, maps, newSelection, onDeleteClick, onLinkClick, showAllBadges, -}) => { +}) { const badges = []; for (let i = 0; i < newSelection.length; i++) { @@ -69,5 +60,16 @@ export const NewSelectionIdBadges: FC = ({ ); } - return <>{badges}; + return badges; +} +NewSelectionIdBadges.propTypes = { + limit: PropTypes.number, + maps: PropTypes.shape({ + jobsMap: PropTypes.object, + groupsMap: PropTypes.object, + }), + newSelection: PropTypes.array, + onDeleteClick: PropTypes.func, + onLinkClick: PropTypes.func, + showAllBadges: PropTypes.bool, }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx index 06d89ab782167..86ffc4a2614b9 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx @@ -41,7 +41,7 @@ import { useMlContext } from '../../contexts/ml'; import { kbnTypeToMLJobType } from '../../util/field_types_utils'; import { useTimefilter } from '../../contexts/kibana'; import { timeBasedIndexCheck, getQueryFromSavedSearch } from '../../util/index_utils'; -import { getTimeBucketsFromCache } from '../../util/time_buckets'; +import { TimeBuckets } from '../../util/time_buckets'; import { useUrlState } from '../../util/url_state'; import { FieldRequestConfig, FieldVisConfig } from './common'; import { ActionsPanel } from './components/actions_panel'; @@ -318,7 +318,7 @@ export const Page: FC = () => { // Obtain the interval to use for date histogram aggregations // (such as the document count chart). Aim for 75 bars. - const buckets = getTimeBucketsFromCache(); + const buckets = new TimeBuckets(); const tf = timefilter as any; let earliest: number | undefined; diff --git a/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap b/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.js.snap similarity index 100% rename from x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap rename to x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.js.snap diff --git a/x-pack/plugins/ml/public/application/explorer/_explorer.scss b/x-pack/plugins/ml/public/application/explorer/_explorer.scss index cfcba081983c2..9fb2f0c3bed94 100644 --- a/x-pack/plugins/ml/public/application/explorer/_explorer.scss +++ b/x-pack/plugins/ml/public/application/explorer/_explorer.scss @@ -106,6 +106,164 @@ padding: 0; margin-bottom: $euiSizeS; + div.ml-swimlanes { + margin: 0px 0px 0px 10px; + + div.cells-marker-container { + margin-left: 176px; + height: 22px; + white-space: nowrap; + + // background-color: #CCC; + .sl-cell { + height: 10px; + display: inline-block; + vertical-align: top; + margin-top: 16px; + text-align: center; + visibility: hidden; + cursor: default; + + i { + color: $euiColorDarkShade; + } + } + + .sl-cell-hover { + visibility: visible; + + i { + display: block; + margin-top: -6px; + } + } + + .sl-cell-active-hover { + visibility: visible; + + .floating-time-label { + display: inline-block; + } + } + } + + div.lane { + height: 30px; + border-bottom: 0px; + border-radius: 2px; + margin-top: -1px; + white-space: nowrap; + + div.lane-label { + display: inline-block; + font-size: 13px; + height: 30px; + text-align: right; + vertical-align: middle; + border-radius: 2px; + padding-right: 5px; + margin-right: 5px; + border: 1px solid transparent; + overflow: hidden; + text-overflow: ellipsis; + } + + div.lane-label.lane-label-masked { + opacity: 0.3; + } + + div.cells-container { + border: $euiBorderThin; + border-right: 0px; + display: inline-block; + height: 30px; + vertical-align: middle; + background-color: $euiColorEmptyShade; + + .sl-cell { + color: $euiColorEmptyShade; + cursor: default; + display: inline-block; + height: 29px; + border-right: $euiBorderThin; + vertical-align: top; + position: relative; + + .sl-cell-inner, + .sl-cell-inner-dragselect { + height: 26px; + margin: 1px; + border-radius: 2px; + text-align: center; + } + + .sl-cell-inner.sl-cell-inner-masked { + opacity: 0.2; + } + + .sl-cell-inner.sl-cell-inner-selected, + .sl-cell-inner-dragselect.sl-cell-inner-selected { + border: 2px solid $euiColorDarkShade; + } + + .sl-cell-inner.sl-cell-inner-selected.sl-cell-inner-masked, + .sl-cell-inner-dragselect.sl-cell-inner-selected.sl-cell-inner-masked { + border: 2px solid $euiColorFullShade; + opacity: 0.4; + } + } + + .sl-cell:hover { + .sl-cell-inner { + opacity: 0.8; + cursor: pointer; + } + } + + .sl-cell.ds-selected { + + .sl-cell-inner, + .sl-cell-inner-dragselect { + border: 2px solid $euiColorDarkShade; + border-radius: 2px; + opacity: 1; + } + } + + } + } + + div.lane:last-child { + div.cells-container { + .sl-cell { + border-bottom: $euiBorderThin; + } + } + } + + .time-tick-labels { + height: 25px; + margin-top: $euiSizeXS / 2; + margin-left: 175px; + + /* hide d3's domain line */ + path.domain { + display: none; + } + + /* hide d3's tick line */ + g.tick line { + display: none; + } + + /* override d3's default tick styles */ + g.tick text { + font-size: 11px; + fill: $euiColorMediumShade; + } + } + } + line.gridLine { stroke: $euiBorderColor; fill: none; @@ -170,161 +328,3 @@ } } } - -.ml-swimlanes { - margin: 0px 0px 0px 10px; - - div.cells-marker-container { - margin-left: 176px; - height: 22px; - white-space: nowrap; - - // background-color: #CCC; - .sl-cell { - height: 10px; - display: inline-block; - vertical-align: top; - margin-top: 16px; - text-align: center; - visibility: hidden; - cursor: default; - - i { - color: $euiColorDarkShade; - } - } - - .sl-cell-hover { - visibility: visible; - - i { - display: block; - margin-top: -6px; - } - } - - .sl-cell-active-hover { - visibility: visible; - - .floating-time-label { - display: inline-block; - } - } - } - - div.lane { - height: 30px; - border-bottom: 0px; - border-radius: 2px; - margin-top: -1px; - white-space: nowrap; - - div.lane-label { - display: inline-block; - font-size: 13px; - height: 30px; - text-align: right; - vertical-align: middle; - border-radius: 2px; - padding-right: 5px; - margin-right: 5px; - border: 1px solid transparent; - overflow: hidden; - text-overflow: ellipsis; - } - - div.lane-label.lane-label-masked { - opacity: 0.3; - } - - div.cells-container { - border: $euiBorderThin; - border-right: 0px; - display: inline-block; - height: 30px; - vertical-align: middle; - background-color: $euiColorEmptyShade; - - .sl-cell { - color: $euiColorEmptyShade; - cursor: default; - display: inline-block; - height: 29px; - border-right: $euiBorderThin; - vertical-align: top; - position: relative; - - .sl-cell-inner, - .sl-cell-inner-dragselect { - height: 26px; - margin: 1px; - border-radius: 2px; - text-align: center; - } - - .sl-cell-inner.sl-cell-inner-masked { - opacity: 0.2; - } - - .sl-cell-inner.sl-cell-inner-selected, - .sl-cell-inner-dragselect.sl-cell-inner-selected { - border: 2px solid $euiColorDarkShade; - } - - .sl-cell-inner.sl-cell-inner-selected.sl-cell-inner-masked, - .sl-cell-inner-dragselect.sl-cell-inner-selected.sl-cell-inner-masked { - border: 2px solid $euiColorFullShade; - opacity: 0.4; - } - } - - .sl-cell:hover { - .sl-cell-inner { - opacity: 0.8; - cursor: pointer; - } - } - - .sl-cell.ds-selected { - - .sl-cell-inner, - .sl-cell-inner-dragselect { - border: 2px solid $euiColorDarkShade; - border-radius: 2px; - opacity: 1; - } - } - - } - } - - div.lane:last-child { - div.cells-container { - .sl-cell { - border-bottom: $euiBorderThin; - } - } - } - - .time-tick-labels { - height: 25px; - margin-top: $euiSizeXS / 2; - margin-left: 175px; - - /* hide d3's domain line */ - path.domain { - display: none; - } - - /* hide d3's tick line */ - g.tick line { - display: none; - } - - /* override d3's default tick styles */ - g.tick text { - font-size: 11px; - fill: $euiColorMediumShade; - } - } -} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.js b/x-pack/plugins/ml/public/application/explorer/explorer.js index 86d16776b68e2..d61d56d07b644 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer.js @@ -36,8 +36,9 @@ import { ExplorerNoJobsFound, ExplorerNoResultsFound, } from './components'; +import { ChartTooltip } from '../components/chart_tooltip'; import { ExplorerSwimlane } from './explorer_swimlane'; -import { getTimeBucketsFromCache } from '../util/time_buckets'; +import { TimeBuckets } from '../util/time_buckets'; import { InfluencersList } from '../components/influencers_list'; import { ALLOW_CELL_RANGE_SELECTION, @@ -80,7 +81,6 @@ import { AnomaliesTable } from '../components/anomalies_table/anomalies_table'; import { ResizeChecker } from '../../../../../../src/plugins/kibana_utils/public'; import { getTimefilter, getToastNotifications } from '../util/dependency_cache'; -import { MlTooltipComponent } from '../components/chart_tooltip'; function mapSwimlaneOptionsToEuiOptions(options) { return options.map(option => ({ @@ -179,8 +179,6 @@ export class Explorer extends React.Component { // Required to redraw the time series chart when the container is resized. this.resizeChecker = new ResizeChecker(this.resizeRef.current); this.resizeChecker.on('resize', this.resizeHandler); - - this.timeBuckets = getTimeBucketsFromCache(); } componentWillUnmount() { @@ -360,6 +358,9 @@ export class Explorer extends React.Component { return (
+ {/* Make sure ChartTooltip is inside wrapping div with 0px left/right padding so positioning can be inferred correctly. */} + + {noInfluencersConfigured === false && influencers !== undefined && (
{showOverallSwimlane && ( - - {tooltipService => ( - - )} - + )}
@@ -498,22 +494,17 @@ export class Explorer extends React.Component { onMouseLeave={this.onSwimlaneLeaveHandler} data-test-subj="mlAnomalyExplorerSwimlaneViewBy" > - - {tooltipService => ( - - )} - +
)} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js index 03426869b0ccf..5fc1160093a49 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js @@ -29,8 +29,9 @@ import { removeLabelOverlap, } from '../../util/chart_utils'; import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator'; -import { getTimeBucketsFromCache } from '../../util/time_buckets'; +import { TimeBuckets } from '../../util/time_buckets'; import { mlFieldFormatService } from '../../services/field_format_service'; +import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service'; import { CHART_TYPE } from '../explorer_constants'; @@ -49,7 +50,6 @@ export class ExplorerChartDistribution extends React.Component { static propTypes = { seriesConfig: PropTypes.object, severity: PropTypes.number, - tooltipService: PropTypes.object.isRequired, }; componentDidMount() { @@ -61,7 +61,7 @@ export class ExplorerChartDistribution extends React.Component { } renderChart() { - const { tooManyBuckets, tooltipService } = this.props; + const { tooManyBuckets } = this.props; const element = this.rootNode; const config = this.props.seriesConfig; @@ -259,7 +259,7 @@ export class ExplorerChartDistribution extends React.Component { function drawRareChartAxes() { // Get the scaled date format to use for x axis tick labels. - const timeBuckets = getTimeBucketsFromCache(); + const timeBuckets = new TimeBuckets(); const bounds = { min: moment(config.plotEarliest), max: moment(config.plotLatest) }; timeBuckets.setBounds(bounds); timeBuckets.setInterval('auto'); @@ -397,7 +397,7 @@ export class ExplorerChartDistribution extends React.Component { .on('mouseover', function(d) { showLineChartTooltip(d, this); }) - .on('mouseout', () => tooltipService.hide()); + .on('mouseout', () => mlChartTooltipService.hide()); // Update all dots to new positions. dots @@ -550,7 +550,7 @@ export class ExplorerChartDistribution extends React.Component { }); } - tooltipService.show(tooltipData, circle, { + mlChartTooltipService.show(tooltipData, circle, { x: LINE_CHART_ANOMALY_RADIUS * 3, y: LINE_CHART_ANOMALY_RADIUS * 2, }); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js index 06fd82204c1e1..71d777db5b2ec 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js @@ -10,13 +10,11 @@ import seriesConfig from './__mocks__/mock_series_config_rare.json'; // Mock TimeBuckets and mlFieldFormatService, they don't play well // with the jest based test setup yet. jest.mock('../../util/time_buckets', () => ({ - getTimeBucketsFromCache: jest.fn(() => { - return { - setBounds: jest.fn(), - setInterval: jest.fn(), - getScaledDateFormat: jest.fn(), - }; - }), + TimeBuckets: function() { + this.setBounds = jest.fn(); + this.setInterval = jest.fn(); + this.getScaledDateFormat = jest.fn(); + }, })); jest.mock('../../services/field_format_service', () => ({ mlFieldFormatService: { @@ -45,16 +43,8 @@ describe('ExplorerChart', () => { afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox)); test('Initialize', () => { - const mockTooltipService = { - show: jest.fn(), - hide: jest.fn(), - }; - const wrapper = mountWithIntl( - + ); // without setting any attributes and corresponding data @@ -69,16 +59,10 @@ describe('ExplorerChart', () => { loading: true, }; - const mockTooltipService = { - show: jest.fn(), - hide: jest.fn(), - }; - const wrapper = mountWithIntl( ); @@ -99,18 +83,12 @@ describe('ExplorerChart', () => { chartLimits: chartLimits(chartData), }; - const mockTooltipService = { - show: jest.fn(), - hide: jest.fn(), - }; - // We create the element including a wrapper which sets the width: return mountWithIntl(
); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js index 82041af39ca15..dd9479be931a7 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js @@ -38,9 +38,10 @@ import { showMultiBucketAnomalyTooltip, } from '../../util/chart_utils'; import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator'; -import { getTimeBucketsFromCache } from '../../util/time_buckets'; +import { TimeBuckets } from '../../util/time_buckets'; import { mlEscape } from '../../util/string_utils'; import { mlFieldFormatService } from '../../services/field_format_service'; +import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service'; import { i18n } from '@kbn/i18n'; @@ -52,7 +53,6 @@ export class ExplorerChartSingleMetric extends React.Component { tooManyBuckets: PropTypes.bool, seriesConfig: PropTypes.object, severity: PropTypes.number.isRequired, - tooltipService: PropTypes.object.isRequired, }; componentDidMount() { @@ -64,7 +64,7 @@ export class ExplorerChartSingleMetric extends React.Component { } renderChart() { - const { tooManyBuckets, tooltipService } = this.props; + const { tooManyBuckets } = this.props; const element = this.rootNode; const config = this.props.seriesConfig; @@ -191,7 +191,7 @@ export class ExplorerChartSingleMetric extends React.Component { function drawLineChartAxes() { // Get the scaled date format to use for x axis tick labels. - const timeBuckets = getTimeBucketsFromCache(); + const timeBuckets = new TimeBuckets(); const bounds = { min: moment(config.plotEarliest), max: moment(config.plotLatest) }; timeBuckets.setBounds(bounds); timeBuckets.setInterval('auto'); @@ -309,7 +309,7 @@ export class ExplorerChartSingleMetric extends React.Component { .on('mouseover', function(d) { showLineChartTooltip(d, this); }) - .on('mouseout', () => tooltipService.hide()); + .on('mouseout', () => mlChartTooltipService.hide()); const isAnomalyVisible = d => _.has(d, 'anomalyScore') && Number(d.anomalyScore) >= severity; @@ -354,7 +354,7 @@ export class ExplorerChartSingleMetric extends React.Component { .on('mouseover', function(d) { showLineChartTooltip(d, this); }) - .on('mouseout', () => tooltipService.hide()); + .on('mouseout', () => mlChartTooltipService.hide()); // Add rectangular markers for any scheduled events. const scheduledEventMarkers = lineChartGroup @@ -503,7 +503,7 @@ export class ExplorerChartSingleMetric extends React.Component { }); } - tooltipService.show(tooltipData, circle, { + mlChartTooltipService.show(tooltipData, circle, { x: LINE_CHART_ANOMALY_RADIUS * 3, y: LINE_CHART_ANOMALY_RADIUS * 2, }); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js index 54f541ceb7c3d..ca3e52308a936 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js @@ -10,13 +10,11 @@ import seriesConfig from './__mocks__/mock_series_config_filebeat.json'; // Mock TimeBuckets and mlFieldFormatService, they don't play well // with the jest based test setup yet. jest.mock('../../util/time_buckets', () => ({ - getTimeBucketsFromCache: jest.fn(() => { - return { - setBounds: jest.fn(), - setInterval: jest.fn(), - getScaledDateFormat: jest.fn(), - }; - }), + TimeBuckets: function() { + this.setBounds = jest.fn(); + this.setInterval = jest.fn(); + this.getScaledDateFormat = jest.fn(); + }, })); jest.mock('../../services/field_format_service', () => ({ mlFieldFormatService: { @@ -45,16 +43,8 @@ describe('ExplorerChart', () => { afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox)); test('Initialize', () => { - const mockTooltipService = { - show: jest.fn(), - hide: jest.fn(), - }; - const wrapper = mountWithIntl( - + ); // without setting any attributes and corresponding data @@ -69,16 +59,10 @@ describe('ExplorerChart', () => { loading: true, }; - const mockTooltipService = { - show: jest.fn(), - hide: jest.fn(), - }; - const wrapper = mountWithIntl( ); @@ -99,18 +83,12 @@ describe('ExplorerChart', () => { chartLimits: chartLimits(chartData), }; - const mockTooltipService = { - show: jest.fn(), - hide: jest.fn(), - }; - // We create the element including a wrapper which sets the width: return mountWithIntl(
); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js index 5b95931d31ab6..99de38c1e0a84 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import $ from 'jquery'; + import React from 'react'; import { @@ -27,7 +29,6 @@ import { ExplorerChartLabel } from './components/explorer_chart_label'; import { CHART_TYPE } from '../explorer_constants'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { MlTooltipComponent } from '../../components/chart_tooltip'; const textTooManyBuckets = i18n.translate('xpack.ml.explorer.charts.tooManyBucketsDescription', { defaultMessage: @@ -120,29 +121,19 @@ function ExplorerChartContainer({ series, severity, tooManyBuckets, wrapLabel }) chartType === CHART_TYPE.POPULATION_DISTRIBUTION ) { return ( - - {tooltipService => ( - - )} - + ); } return ( - - {tooltipService => ( - - )} - + ); })()} @@ -150,36 +141,48 @@ function ExplorerChartContainer({ series, severity, tooManyBuckets, wrapLabel }) } // Flex layout wrapper for all explorer charts -export const ExplorerChartsContainer = ({ - chartsPerRow, - seriesToPlot, - severity, - tooManyBuckets, -}) => { - // doesn't allow a setting of `columns={1}` when chartsPerRow would be 1. - // If that's the case we trick it doing that with the following settings: - const chartsWidth = chartsPerRow === 1 ? 'calc(100% - 20px)' : 'auto'; - const chartsColumns = chartsPerRow === 1 ? 0 : chartsPerRow; - - const wrapLabel = seriesToPlot.some(series => isLabelLengthAboveThreshold(series)); +export class ExplorerChartsContainer extends React.Component { + componentDidMount() { + // Create a div for the tooltip. + $('.ml-explorer-charts-tooltip').remove(); + $('body').append( + '