Skip to content

Commit

Permalink
[ML] Embeddable Anomaly Swimlane (#65180)
Browse files Browse the repository at this point in the history
* [ML] Embeddable Anomaly Swimlane (#64056)

* [ML] embeddables setup

* [ML] fix initialization

* [ML] ts refactoring

* [ML] refactor time_buckets.js

* [ML] async services

* [ML] extract job_selector_flyout.tsx

* [ML] fetch overall swimlane data

* [ML] import explorer styles

* [ML] revert package_globs.ts

* [ML] refactor with container, services with DI

* [ML] resize throttle, fetch based on chart width

* [ML] swimlane embeddable setup

* [ML] explorer service

* [ML] chart_tooltip_service

* [ML] fix types

* [ML] overall type for single job with no influencers

* [ML] improve anomaly_swimlane_initializer ux

* [ML] fix services initialization, unsubscribe on destroy

* [ML] support custom time range

* [ML] add tooltip

* [ML] rollback initGetSwimlaneBucketInterval

* [ML] new tooltip service

* [ML] MlTooltipComponent with render props, fix warning

* [ML] fix typo in the filename

* [ML] remove redundant time range output

* [ML] fix time_buckets.test.js jest tests

* [ML] fix explorer chart tests

* [ML] swimlane tests

* [ML] store job ids instead of complete job objects

* [ML] swimlane limit input

* [ML] memo tooltip component, loading indicator

* [ML] scrollable content

* [ML] support query and filters

* [ML] handle query syntax errors

* [ML] rename anomaly_swimlane_service

* [ML] introduce constants

* [ML] edit panel title during setup

* [ML] withTimeRangeSelector

* [ML] rename explorer_service

* [ML] getJobs$ method with one API call

* [ML] fix groups selection

* [ML] swimlane input resolver hook

* [ML] useSwimlaneInputResolver tests

* [ML] factory test

* [ML] container test

* [ML] set wrapper

* [ML] tooltip tests

* [ML] fix displayScore

* [ML] label colors

* [ML] support edit mode

* [ML] call super render

Co-authored-by: Elastic Machine <[email protected]>
(cherry picked from commit f62df99)

* [ML] fix typing issues
  • Loading branch information
darnautov authored May 4, 2020
1 parent f9be590 commit 76df143
Show file tree
Hide file tree
Showing 61 changed files with 3,101 additions and 944 deletions.
4 changes: 3 additions & 1 deletion x-pack/plugins/ml/kibana.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
"home",
"licensing",
"usageCollection",
"share"
"share",
"embeddable",
"uiActions"
],
"optionalPlugins": [
"security",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,56 +4,15 @@
* 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 React, { useRef, FC } from 'react';
import TooltipTrigger from 'react-popper-tooltip';
import { TooltipValueFormatter } from '@elastic/charts';
import useObservable from 'react-use/lib/useObservable';

import { chartTooltip$, ChartTooltipState, ChartTooltipValue } from './chart_tooltip_service';
import './_index.scss';

type RefValue = HTMLElement | null;

function useRefWithCallback(chartTooltipState?: ChartTooltipState) {
const ref = useRef<RefValue>(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 },
});
}
}
};
}
import { ChildrenArg, TooltipTriggerProps } from 'react-popper-tooltip/dist/types';
import { ChartTooltipService, ChartTooltipValue, TooltipData } from './chart_tooltip_service';

const renderHeader = (headerData?: ChartTooltipValue, formatter?: TooltipValueFormatter) => {
if (!headerData) {
Expand All @@ -63,48 +22,101 @@ const renderHeader = (headerData?: ChartTooltipValue, formatter?: TooltipValueFo
return formatter ? formatter(headerData) : headerData.label;
};

export const ChartTooltip: FC = () => {
const chartTooltipState = useObservable(chartTooltip$);
const chartTooltipElement = useRefWithCallback(chartTooltipState);
const Tooltip: FC<{ service: ChartTooltipService }> = React.memo(({ service }) => {
const [tooltipData, setData] = useState<TooltipData>([]);
const refCallback = useRef<ChildrenArg['triggerRef']>();

if (chartTooltipState === undefined || !chartTooltipState.isTooltipVisible) {
return <div className="mlChartTooltip mlChartTooltip--hidden" ref={chartTooltipElement} />;
}
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 (
<div
{...getTooltipProps({
ref: tooltipRef,
className: 'mlChartTooltip',
})}
>
{tooltipData.length > 0 && tooltipData[0].skipHeader === undefined && (
<div className="mlChartTooltip__header">{renderHeader(tooltipData[0])}</div>
)}
{tooltipData.length > 1 && (
<div className="mlChartTooltip__list">
{tooltipData
.slice(1)
.map(({ label, value, color, isHighlighted, seriesIdentifier, valueAccessor }) => {
const classes = classNames('mlChartTooltip__item', {
/* eslint @typescript-eslint/camelcase:0 */
echTooltip__rowHighlighted: isHighlighted,
});
return (
<div
key={`${seriesIdentifier.key}__${valueAccessor}`}
className={classes}
style={{
borderLeftColor: color,
}}
>
<span className="mlChartTooltip__label">{label}</span>
<span className="mlChartTooltip__value">{value}</span>
</div>
);
})}
</div>
)}
</div>
);
}) as TooltipTriggerProps['tooltip'],
[tooltipData]
);

const { tooltipData, tooltipHeaderFormatter, tooltipPosition } = chartTooltipState;
const transform = `translate(${tooltipPosition.left}px, ${tooltipPosition.top}px)`;
const isTooltipShown = tooltipData.length > 0;

return (
<div className="mlChartTooltip" style={{ transform }} ref={chartTooltipElement}>
{tooltipData.length > 0 && tooltipData[0].skipHeader === undefined && (
<div className="mlChartTooltip__header">
{renderHeader(tooltipData[0], tooltipHeaderFormatter)}
</div>
)}
{tooltipData.length > 1 && (
<div className="mlChartTooltip__list">
{tooltipData
.slice(1)
.map(({ label, value, color, isHighlighted, seriesIdentifier, valueAccessor }) => {
const classes = classNames('mlChartTooltip__item', {
/* eslint @typescript-eslint/camelcase:0 */
echTooltip__rowHighlighted: isHighlighted,
});
return (
<div
key={`${seriesIdentifier.key}__${valueAccessor}`}
className={classes}
style={{
borderLeftColor: color,
}}
>
<span className="mlChartTooltip__label">{label}</span>
<span className="mlChartTooltip__value">{value}</span>
</div>
);
})}
</div>
)}
</div>
<TooltipTrigger
placement="right-start"
trigger="none"
tooltipShown={isTooltipShown}
tooltip={tooltipCallback}
>
{triggerCallback}
</TooltipTrigger>
);
});

interface MlTooltipComponentProps {
children: (tooltipService: ChartTooltipService) => React.ReactElement;
}

export const MlTooltipComponent: FC<MlTooltipComponentProps> = ({ children }) => {
const service = useMemo(() => new ChartTooltipService(), []);

return (
<>
<Tooltip service={service} />
{children(service)}
</>
);
};

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,61 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { getChartTooltipDefaultState, mlChartTooltipService } from './chart_tooltip_service';
import {
ChartTooltipService,
getChartTooltipDefaultState,
TooltipData,
} from './chart_tooltip_service';

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');
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,
});
});

it('should fail silently when target is not defined', () => {
expect(() => {
mlChartTooltipService.show(getChartTooltipDefaultState().tooltipData, null);
}).not.toThrow('Call to show() should fail silently.');
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);
});
});
Loading

0 comments on commit 76df143

Please sign in to comment.