[ML] Performance improvements to annotations editing in Single Metric Viewer & buttons placement (#83216)

This commit is contained in:
Quynh Nguyen 2020-11-18 10:49:26 -06:00 committed by GitHub
parent 21c0258e6b
commit 7a7057eba7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 239 additions and 114 deletions

View file

@ -1,3 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AnnotationFlyout Initialization. 1`] = `""`;

View file

@ -5,58 +5,102 @@
*/ */
import useObservable from 'react-use/lib/useObservable'; import useObservable from 'react-use/lib/useObservable';
import mockAnnotations from '../annotations_table/__mocks__/mock_annotations.json'; import mockAnnotations from '../annotations_table/__mocks__/mock_annotations.json';
import React from 'react'; import React from 'react';
import { mountWithIntl, shallowWithIntl } from '@kbn/test/jest'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { Annotation } from '../../../../../common/types/annotations'; import { Annotation } from '../../../../../common/types/annotations';
import { annotation$ } from '../../../services/annotations_service'; import { AnnotationUpdatesService } from '../../../services/annotations_service';
import { AnnotationFlyout } from './index'; import { AnnotationFlyout } from './index';
import { MlAnnotationUpdatesContext } from '../../../contexts/ml/ml_annotation_updates_context';
jest.mock('../../../util/dependency_cache', () => ({
getToastNotifications: () => ({ addSuccess: jest.fn(), addDanger: jest.fn() }),
}));
const MlAnnotationUpdatesContextProvider = ({
annotationUpdatesService,
children,
}: {
annotationUpdatesService: AnnotationUpdatesService;
children: React.ReactElement;
}) => {
return (
<MlAnnotationUpdatesContext.Provider value={annotationUpdatesService}>
<IntlProvider>{children}</IntlProvider>
</MlAnnotationUpdatesContext.Provider>
);
};
const ObservableComponent = (props: any) => {
const { annotationUpdatesService } = props;
const annotationProp = useObservable(annotationUpdatesService!.isAnnotationInitialized$());
if (annotationProp === undefined) {
return null;
}
return (
<AnnotationFlyout
annotation={annotationProp}
annotationUpdatesService={annotationUpdatesService}
{...props}
/>
);
};
describe('AnnotationFlyout', () => { describe('AnnotationFlyout', () => {
test('Initialization.', () => { let annotationUpdatesService: AnnotationUpdatesService | null = null;
const wrapper = shallowWithIntl(<AnnotationFlyout />); beforeEach(() => {
expect(wrapper).toMatchSnapshot(); annotationUpdatesService = new AnnotationUpdatesService();
}); });
test('Update button is disabled with empty annotation', () => { test('Update button is disabled with empty annotation', async () => {
const annotation = mockAnnotations[1] as Annotation; const annotation = mockAnnotations[1] as Annotation;
annotation$.next(annotation);
// useObservable wraps the observable in a new component annotationUpdatesService!.setValue(annotation);
const ObservableComponent = (props: any) => {
const annotationProp = useObservable(annotation$);
if (annotationProp === undefined) {
return null;
}
return <AnnotationFlyout annotation={annotationProp} {...props} />;
};
const wrapper = mountWithIntl(<ObservableComponent />); const { getByTestId } = render(
const updateBtn = wrapper.find('EuiButton').first(); <MlAnnotationUpdatesContextProvider annotationUpdatesService={annotationUpdatesService!}>
expect(updateBtn.prop('isDisabled')).toEqual(true); <ObservableComponent annotationUpdatesService={annotationUpdatesService!} />
</MlAnnotationUpdatesContextProvider>
);
const updateBtn = getByTestId('annotationFlyoutUpdateButton');
expect(updateBtn).toBeDisabled();
}); });
test('Error displayed and update button displayed if annotation text is longer than max chars', () => { test('Error displayed and update button displayed if annotation text is longer than max chars', async () => {
const annotation = mockAnnotations[2] as Annotation; const annotation = mockAnnotations[2] as Annotation;
annotation$.next(annotation); annotationUpdatesService!.setValue(annotation);
// useObservable wraps the observable in a new component const { getByTestId } = render(
const ObservableComponent = (props: any) => { <MlAnnotationUpdatesContextProvider annotationUpdatesService={annotationUpdatesService!}>
const annotationProp = useObservable(annotation$); <ObservableComponent annotationUpdatesService={annotationUpdatesService!} />
if (annotationProp === undefined) { </MlAnnotationUpdatesContextProvider>
return null; );
} const updateBtn = getByTestId('annotationFlyoutUpdateButton');
return <AnnotationFlyout annotation={annotationProp} {...props} />; expect(updateBtn).toBeDisabled();
}; await waitFor(() => {
const errorText = screen.queryByText(/characters above maximum length/);
expect(errorText).not.toBe(undefined);
});
});
const wrapper = mountWithIntl(<ObservableComponent />); test('Flyout disappears when annotation is updated', async () => {
const updateBtn = wrapper.find('EuiButton').first(); const annotation = mockAnnotations[0] as Annotation;
expect(updateBtn.prop('isDisabled')).toEqual(true);
expect(wrapper.find('EuiFormErrorText')).toHaveLength(1); annotationUpdatesService!.setValue(annotation);
const { getByTestId } = render(
<MlAnnotationUpdatesContextProvider annotationUpdatesService={annotationUpdatesService!}>
<ObservableComponent annotationUpdatesService={annotationUpdatesService!} />
</MlAnnotationUpdatesContextProvider>
);
const updateBtn = getByTestId('annotationFlyoutUpdateButton');
expect(updateBtn).not.toBeDisabled();
expect(screen.queryByTestId('mlAnnotationFlyout')).toBeInTheDocument();
await fireEvent.click(updateBtn);
expect(screen.queryByTestId('mlAnnotationFlyout')).not.toBeInTheDocument();
}); });
}); });

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import React, { Component, FC, ReactNode, useCallback } from 'react'; import React, { Component, FC, ReactNode, useCallback, useContext } from 'react';
import useObservable from 'react-use/lib/useObservable'; import useObservable from 'react-use/lib/useObservable';
import * as Rx from 'rxjs'; import * as Rx from 'rxjs';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
@ -28,15 +28,14 @@ import {
import { CommonProps } from '@elastic/eui'; import { CommonProps } from '@elastic/eui';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react'; import { FormattedMessage } from '@kbn/i18n/react';
import { distinctUntilChanged } from 'rxjs/operators';
import { import {
ANNOTATION_MAX_LENGTH_CHARS, ANNOTATION_MAX_LENGTH_CHARS,
ANNOTATION_EVENT_USER, ANNOTATION_EVENT_USER,
} from '../../../../../common/constants/annotations'; } from '../../../../../common/constants/annotations';
import { import {
annotation$,
annotationsRefreshed, annotationsRefreshed,
AnnotationState, AnnotationState,
AnnotationUpdatesService,
} from '../../../services/annotations_service'; } from '../../../services/annotations_service';
import { AnnotationDescriptionList } from '../annotation_description_list'; import { AnnotationDescriptionList } from '../annotation_description_list';
import { DeleteAnnotationModal } from '../delete_annotation_modal'; import { DeleteAnnotationModal } from '../delete_annotation_modal';
@ -48,6 +47,7 @@ import {
} from '../../../../../common/types/annotations'; } from '../../../../../common/types/annotations';
import { PartitionFieldsType } from '../../../../../common/types/anomalies'; import { PartitionFieldsType } from '../../../../../common/types/anomalies';
import { PARTITION_FIELDS } from '../../../../../common/constants/anomalies'; import { PARTITION_FIELDS } from '../../../../../common/constants/anomalies';
import { MlAnnotationUpdatesContext } from '../../../contexts/ml/ml_annotation_updates_context';
interface ViewableDetector { interface ViewableDetector {
index: number; index: number;
@ -67,6 +67,7 @@ interface Props {
}; };
detectorIndex: number; detectorIndex: number;
detectors: ViewableDetector[]; detectors: ViewableDetector[];
annotationUpdatesService: AnnotationUpdatesService;
} }
interface State { interface State {
@ -85,7 +86,8 @@ export class AnnotationFlyoutUI extends Component<CommonProps & Props> {
public annotationSub: Rx.Subscription | null = null; public annotationSub: Rx.Subscription | null = null;
componentDidMount() { componentDidMount() {
this.annotationSub = annotation$.subscribe((v) => { const { annotationUpdatesService } = this.props;
this.annotationSub = annotationUpdatesService.update$().subscribe((v) => {
this.setState({ this.setState({
annotationState: v, annotationState: v,
}); });
@ -100,15 +102,17 @@ export class AnnotationFlyoutUI extends Component<CommonProps & Props> {
if (this.state.annotationState === null) { if (this.state.annotationState === null) {
return; return;
} }
const { annotationUpdatesService } = this.props;
annotation$.next({ annotationUpdatesService.setValue({
...this.state.annotationState, ...this.state.annotationState,
annotation: e.target.value, annotation: e.target.value,
}); });
}; };
public cancelEditingHandler = () => { public cancelEditingHandler = () => {
annotation$.next(null); const { annotationUpdatesService } = this.props;
annotationUpdatesService.setValue(null);
}; };
public deleteConfirmHandler = () => { public deleteConfirmHandler = () => {
@ -148,7 +152,10 @@ export class AnnotationFlyoutUI extends Component<CommonProps & Props> {
} }
this.closeDeleteModal(); this.closeDeleteModal();
annotation$.next(null);
const { annotationUpdatesService } = this.props;
annotationUpdatesService.setValue(null);
annotationsRefreshed(); annotationsRefreshed();
}; };
@ -193,7 +200,8 @@ export class AnnotationFlyoutUI extends Component<CommonProps & Props> {
public saveOrUpdateAnnotation = () => { public saveOrUpdateAnnotation = () => {
const { annotationState: originalAnnotation } = this.state; const { annotationState: originalAnnotation } = this.state;
const { chartDetails, detectorIndex } = this.props; const { chartDetails, detectorIndex, annotationUpdatesService } = this.props;
if (originalAnnotation === null) { if (originalAnnotation === null) {
return; return;
} }
@ -218,8 +226,7 @@ export class AnnotationFlyoutUI extends Component<CommonProps & Props> {
} }
// Mark the annotation created by `user` if and only if annotation is being created, not updated // Mark the annotation created by `user` if and only if annotation is being created, not updated
annotation.event = annotation.event ?? ANNOTATION_EVENT_USER; annotation.event = annotation.event ?? ANNOTATION_EVENT_USER;
annotationUpdatesService.setValue(null);
annotation$.next(null);
ml.annotations ml.annotations
.indexAnnotation(annotation) .indexAnnotation(annotation)
@ -356,16 +363,16 @@ export class AnnotationFlyoutUI extends Component<CommonProps & Props> {
</EuiFormRow> </EuiFormRow>
</EuiFlyoutBody> </EuiFlyoutBody>
<EuiFlyoutFooter> <EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween"> <EuiFlexGroup>
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="cross" onClick={this.cancelEditingHandler} flush="left"> <EuiButtonEmpty onClick={this.cancelEditingHandler} flush="left">
<FormattedMessage <FormattedMessage
id="xpack.ml.timeSeriesExplorer.annotationFlyout.cancelButtonLabel" id="xpack.ml.timeSeriesExplorer.annotationFlyout.cancelButtonLabel"
defaultMessage="Cancel" defaultMessage="Cancel"
/> />
</EuiButtonEmpty> </EuiButtonEmpty>
</EuiFlexItem> </EuiFlexItem>
<EuiFlexItem grow={false}> <EuiFlexItem grow={false} style={{ marginLeft: 'auto' }}>
{isExistingAnnotation && ( {isExistingAnnotation && (
<EuiButtonEmpty color="danger" onClick={this.deleteConfirmHandler}> <EuiButtonEmpty color="danger" onClick={this.deleteConfirmHandler}>
<FormattedMessage <FormattedMessage
@ -376,7 +383,12 @@ export class AnnotationFlyoutUI extends Component<CommonProps & Props> {
)} )}
</EuiFlexItem> </EuiFlexItem>
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
<EuiButton fill isDisabled={isInvalid === true} onClick={this.saveOrUpdateAnnotation}> <EuiButton
fill
isDisabled={isInvalid === true}
onClick={this.saveOrUpdateAnnotation}
data-test-subj={'annotationFlyoutUpdateButton'}
>
{isExistingAnnotation ? ( {isExistingAnnotation ? (
<FormattedMessage <FormattedMessage
id="xpack.ml.timeSeriesExplorer.annotationFlyout.updateButtonLabel" id="xpack.ml.timeSeriesExplorer.annotationFlyout.updateButtonLabel"
@ -403,17 +415,11 @@ export class AnnotationFlyoutUI extends Component<CommonProps & Props> {
} }
export const AnnotationFlyout: FC<any> = (props) => { export const AnnotationFlyout: FC<any> = (props) => {
const annotationProp = useObservable( const annotationUpdatesService = useContext(MlAnnotationUpdatesContext);
annotation$.pipe( const annotationProp = useObservable(annotationUpdatesService.isAnnotationInitialized$());
distinctUntilChanged((prev, curr) => {
// prevent re-rendering
return prev !== null && curr !== null;
})
)
);
const cancelEditingHandler = useCallback(() => { const cancelEditingHandler = useCallback(() => {
annotation$.next(null); annotationUpdatesService.setValue(null);
}, []); }, []);
if (annotationProp === undefined || annotationProp === null) { if (annotationProp === undefined || annotationProp === null) {
@ -423,7 +429,12 @@ export const AnnotationFlyout: FC<any> = (props) => {
const isExistingAnnotation = typeof annotationProp._id !== 'undefined'; const isExistingAnnotation = typeof annotationProp._id !== 'undefined';
return ( return (
<EuiFlyout onClose={cancelEditingHandler} size="m" aria-labelledby="Add annotation"> <EuiFlyout
onClose={cancelEditingHandler}
size="m"
aria-labelledby="Add annotation"
data-test-subj={'mlAnnotationFlyout'}
>
<EuiFlyoutHeader hasBorder> <EuiFlyoutHeader hasBorder>
<EuiTitle size="s"> <EuiTitle size="s">
<h2 id="mlAnnotationFlyoutTitle"> <h2 id="mlAnnotationFlyoutTitle">
@ -441,7 +452,7 @@ export const AnnotationFlyout: FC<any> = (props) => {
</h2> </h2>
</EuiTitle> </EuiTitle>
</EuiFlyoutHeader> </EuiFlyoutHeader>
<AnnotationFlyoutUI {...props} /> <AnnotationFlyoutUI {...props} annotationUpdatesService={annotationUpdatesService} />
</EuiFlyout> </EuiFlyout>
); );
}; };

View file

@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AnnotationsTable Initialization with annotations prop. 1`] = ` exports[`AnnotationsTable Initialization with annotations prop. 1`] = `
<AnnotationsTableUI <Component
annotations={ annotations={
Array [ Array [
Object { Object {
@ -141,7 +141,7 @@ exports[`AnnotationsTable Initialization with annotations prop. 1`] = `
`; `;
exports[`AnnotationsTable Initialization with job config prop. 1`] = ` exports[`AnnotationsTable Initialization with job config prop. 1`] = `
<AnnotationsTableUI <Component
intl={ intl={
Object { Object {
"defaultFormats": Object {}, "defaultFormats": Object {},
@ -403,7 +403,7 @@ exports[`AnnotationsTable Initialization with job config prop. 1`] = `
`; `;
exports[`AnnotationsTable Minimal initialization without props. 1`] = ` exports[`AnnotationsTable Minimal initialization without props. 1`] = `
<AnnotationsTableUI <Component
intl={ intl={
Object { Object {
"defaultFormats": Object {}, "defaultFormats": Object {},

View file

@ -13,7 +13,7 @@
import { uniq } from 'lodash'; import { uniq } from 'lodash';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component, Fragment } from 'react'; import React, { Component, Fragment, useContext } from 'react';
import memoizeOne from 'memoize-one'; import memoizeOne from 'memoize-one';
import { import {
EuiBadge, EuiBadge,
@ -41,11 +41,7 @@ import {
isTimeSeriesViewJob, isTimeSeriesViewJob,
} from '../../../../../common/util/job_utils'; } from '../../../../../common/util/job_utils';
import { import { annotationsRefresh$, annotationsRefreshed } from '../../../services/annotations_service';
annotation$,
annotationsRefresh$,
annotationsRefreshed,
} from '../../../services/annotations_service';
import { import {
ANNOTATION_EVENT_USER, ANNOTATION_EVENT_USER,
ANNOTATION_EVENT_DELAYED_DATA, ANNOTATION_EVENT_DELAYED_DATA,
@ -54,6 +50,7 @@ import { withKibana } from '../../../../../../../../src/plugins/kibana_react/pub
import { ML_APP_URL_GENERATOR, ML_PAGES } from '../../../../../common/constants/ml_url_generator'; import { ML_APP_URL_GENERATOR, ML_PAGES } from '../../../../../common/constants/ml_url_generator';
import { PLUGIN_ID } from '../../../../../common/constants/app'; import { PLUGIN_ID } from '../../../../../common/constants/app';
import { timeFormatter } from '../../../../../common/util/date_utils'; import { timeFormatter } from '../../../../../common/util/date_utils';
import { MlAnnotationUpdatesContext } from '../../../contexts/ml/ml_annotation_updates_context';
const CURRENT_SERIES = 'current_series'; const CURRENT_SERIES = 'current_series';
/** /**
@ -319,7 +316,11 @@ class AnnotationsTableUI extends Component {
}; };
render() { render() {
const { isSingleMetricViewerLinkVisible = true, isNumberBadgeVisible = false } = this.props; const {
isSingleMetricViewerLinkVisible = true,
isNumberBadgeVisible = false,
annotationUpdatesService,
} = this.props;
const { queryText, searchError } = this.state; const { queryText, searchError } = this.state;
@ -474,7 +475,7 @@ class AnnotationsTableUI extends Component {
return ( return (
<EuiToolTip position="bottom" content={editAnnotationsTooltipText}> <EuiToolTip position="bottom" content={editAnnotationsTooltipText}>
<EuiButtonIcon <EuiButtonIcon
onClick={() => annotation$.next(originalAnnotation ?? annotation)} onClick={() => annotationUpdatesService.setValue(originalAnnotation ?? annotation)}
iconType="pencil" iconType="pencil"
aria-label={editAnnotationsTooltipAriaLabelText} aria-label={editAnnotationsTooltipAriaLabelText}
/> />
@ -693,4 +694,7 @@ class AnnotationsTableUI extends Component {
} }
} }
export const AnnotationsTable = withKibana(AnnotationsTableUI); export const AnnotationsTable = withKibana((props) => {
const annotationUpdatesService = useContext(MlAnnotationUpdatesContext);
return <AnnotationsTableUI annotationUpdatesService={annotationUpdatesService} {...props} />;
});

View file

@ -0,0 +1,14 @@
/*
* 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 { createContext } from 'react';
import { AnnotationUpdatesService } from '../../services/annotations_service';
export type MlAnnotationUpdatesContextValue = AnnotationUpdatesService;
export const MlAnnotationUpdatesContext = createContext<MlAnnotationUpdatesContextValue>(
new AnnotationUpdatesService()
);

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import React, { FC, useEffect, useState, useCallback } from 'react'; import React, { FC, useEffect, useState, useCallback, useMemo } from 'react';
import useObservable from 'react-use/lib/useObservable'; import useObservable from 'react-use/lib/useObservable';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
@ -34,6 +34,8 @@ import { getBreadcrumbWithUrlForApp } from '../breadcrumbs';
import { useTimefilter } from '../../contexts/kibana'; import { useTimefilter } from '../../contexts/kibana';
import { isViewBySwimLaneData } from '../../explorer/swimlane_container'; import { isViewBySwimLaneData } from '../../explorer/swimlane_container';
import { JOB_ID } from '../../../../common/constants/anomalies'; import { JOB_ID } from '../../../../common/constants/anomalies';
import { MlAnnotationUpdatesContext } from '../../contexts/ml/ml_annotation_updates_context';
import { AnnotationUpdatesService } from '../../services/annotations_service';
export const explorerRouteFactory = ( export const explorerRouteFactory = (
navigateToPath: NavigateToPath, navigateToPath: NavigateToPath,
@ -59,10 +61,13 @@ const PageWrapper: FC<PageProps> = ({ deps }) => {
jobs: mlJobService.loadJobsWrapper, jobs: mlJobService.loadJobsWrapper,
jobsWithTimeRange: () => ml.jobs.jobsWithTimerange(getDateFormatTz()), jobsWithTimeRange: () => ml.jobs.jobsWithTimerange(getDateFormatTz()),
}); });
const annotationUpdatesService = useMemo(() => new AnnotationUpdatesService(), []);
return ( return (
<PageLoader context={context}> <PageLoader context={context}>
<ExplorerUrlStateManager jobsWithTimeRange={results.jobsWithTimeRange.jobs} /> <MlAnnotationUpdatesContext.Provider value={annotationUpdatesService}>
<ExplorerUrlStateManager jobsWithTimeRange={results.jobsWithTimeRange.jobs} />
</MlAnnotationUpdatesContext.Provider>
</PageLoader> </PageLoader>
); );
}; };

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import React, { useEffect, FC } from 'react'; import React, { useEffect, FC, useMemo } from 'react';
import useObservable from 'react-use/lib/useObservable'; import useObservable from 'react-use/lib/useObservable';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
@ -19,6 +19,8 @@ import { basicResolvers } from '../resolvers';
import { JobsPage } from '../../jobs/jobs_list'; import { JobsPage } from '../../jobs/jobs_list';
import { useTimefilter } from '../../contexts/kibana'; import { useTimefilter } from '../../contexts/kibana';
import { getBreadcrumbWithUrlForApp } from '../breadcrumbs'; import { getBreadcrumbWithUrlForApp } from '../breadcrumbs';
import { AnnotationUpdatesService } from '../../services/annotations_service';
import { MlAnnotationUpdatesContext } from '../../contexts/ml/ml_annotation_updates_context';
export const jobListRouteFactory = (navigateToPath: NavigateToPath, basePath: string): MlRoute => ({ export const jobListRouteFactory = (navigateToPath: NavigateToPath, basePath: string): MlRoute => ({
path: '/jobs', path: '/jobs',
@ -57,10 +59,13 @@ const PageWrapper: FC<PageProps> = ({ deps }) => {
setGlobalState({ refreshInterval }); setGlobalState({ refreshInterval });
timefilter.setRefreshInterval(refreshInterval); timefilter.setRefreshInterval(refreshInterval);
}, []); }, []);
const annotationUpdatesService = useMemo(() => new AnnotationUpdatesService(), []);
return ( return (
<PageLoader context={context}> <PageLoader context={context}>
<JobsPage blockRefresh={blockRefresh} lastRefresh={lastRefresh} /> <MlAnnotationUpdatesContext.Provider value={annotationUpdatesService}>
<JobsPage blockRefresh={blockRefresh} lastRefresh={lastRefresh} />
</MlAnnotationUpdatesContext.Provider>
</PageLoader> </PageLoader>
); );
}; };

View file

@ -5,7 +5,7 @@
*/ */
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import React, { FC, useCallback, useEffect, useState } from 'react'; import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import usePrevious from 'react-use/lib/usePrevious'; import usePrevious from 'react-use/lib/usePrevious';
import moment from 'moment'; import moment from 'moment';
@ -39,7 +39,8 @@ import { basicResolvers } from '../resolvers';
import { getBreadcrumbWithUrlForApp } from '../breadcrumbs'; import { getBreadcrumbWithUrlForApp } from '../breadcrumbs';
import { useTimefilter } from '../../contexts/kibana'; import { useTimefilter } from '../../contexts/kibana';
import { useToastNotificationService } from '../../services/toast_notification_service'; import { useToastNotificationService } from '../../services/toast_notification_service';
import { AnnotationUpdatesService } from '../../services/annotations_service';
import { MlAnnotationUpdatesContext } from '../../contexts/ml/ml_annotation_updates_context';
export const timeSeriesExplorerRouteFactory = ( export const timeSeriesExplorerRouteFactory = (
navigateToPath: NavigateToPath, navigateToPath: NavigateToPath,
basePath: string basePath: string
@ -64,13 +65,16 @@ const PageWrapper: FC<PageProps> = ({ deps }) => {
jobs: mlJobService.loadJobsWrapper, jobs: mlJobService.loadJobsWrapper,
jobsWithTimeRange: () => ml.jobs.jobsWithTimerange(getDateFormatTz()), jobsWithTimeRange: () => ml.jobs.jobsWithTimerange(getDateFormatTz()),
}); });
const annotationUpdatesService = useMemo(() => new AnnotationUpdatesService(), []);
return ( return (
<PageLoader context={context}> <PageLoader context={context}>
<TimeSeriesExplorerUrlStateManager <MlAnnotationUpdatesContext.Provider value={annotationUpdatesService}>
config={deps.config} <TimeSeriesExplorerUrlStateManager
jobsWithTimeRange={results.jobsWithTimeRange.jobs} config={deps.config}
/> jobsWithTimeRange={results.jobsWithTimeRange.jobs}
/>
</MlAnnotationUpdatesContext.Provider>
</PageLoader> </PageLoader>
); );
}; };

View file

@ -7,20 +7,29 @@
import mockAnnotations from '../components/annotations/annotations_table/__mocks__/mock_annotations.json'; import mockAnnotations from '../components/annotations/annotations_table/__mocks__/mock_annotations.json';
import { Annotation } from '../../../common/types/annotations'; import { Annotation } from '../../../common/types/annotations';
import { annotation$, annotationsRefresh$, annotationsRefreshed } from './annotations_service'; import {
annotationsRefresh$,
annotationsRefreshed,
AnnotationUpdatesService,
} from './annotations_service';
describe('annotations_service', () => { describe('annotations_service', () => {
test('annotation$', () => { let annotationUpdatesService: AnnotationUpdatesService | null = null;
beforeEach(() => {
annotationUpdatesService = new AnnotationUpdatesService();
});
test('annotationUpdatesService', () => {
const subscriber = jest.fn(); const subscriber = jest.fn();
annotation$.subscribe(subscriber); annotationUpdatesService!.update$().subscribe(subscriber);
// the subscriber should have been triggered with the initial value of null // the subscriber should have been triggered with the initial value of null
expect(subscriber.mock.calls).toHaveLength(1); expect(subscriber.mock.calls).toHaveLength(1);
expect(subscriber.mock.calls[0][0]).toBe(null); expect(subscriber.mock.calls[0][0]).toBe(null);
const annotation = mockAnnotations[0] as Annotation; const annotation = mockAnnotations[0] as Annotation;
annotation$.next(annotation); annotationUpdatesService!.setValue(annotation);
// the subscriber should have been triggered with the updated annotation value // the subscriber should have been triggered with the updated annotation value
expect(subscriber.mock.calls).toHaveLength(2); expect(subscriber.mock.calls).toHaveLength(2);

View file

@ -4,8 +4,8 @@
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject, Observable } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators';
import { Annotation } from '../../../common/types/annotations'; import { Annotation } from '../../../common/types/annotations';
/* /*
@ -79,3 +79,25 @@ export const annotation$ = new BehaviorSubject<AnnotationState>(null);
*/ */
export const annotationsRefresh$ = new BehaviorSubject(Date.now()); export const annotationsRefresh$ = new BehaviorSubject(Date.now());
export const annotationsRefreshed = () => annotationsRefresh$.next(Date.now()); export const annotationsRefreshed = () => annotationsRefresh$.next(Date.now());
export class AnnotationUpdatesService {
private _annotation$: BehaviorSubject<AnnotationState> = new BehaviorSubject<AnnotationState>(
null
);
public update$() {
return this._annotation$.asObservable();
}
public isAnnotationInitialized$(): Observable<AnnotationState> {
return this._annotation$.asObservable().pipe(
distinctUntilChanged((prev, curr) => {
// prevent re-rendering
return prev !== null && curr !== null;
})
);
}
public setValue(annotation: AnnotationState) {
this._annotation$.next(annotation);
}
}

View file

@ -10,6 +10,7 @@ import React from 'react';
import { Annotation } from '../../../../../common/types/annotations'; import { Annotation } from '../../../../../common/types/annotations';
import { CombinedJob } from '../../../../../common/types/anomaly_detection_jobs'; import { CombinedJob } from '../../../../../common/types/anomaly_detection_jobs';
import { ChartTooltipService } from '../../../components/chart_tooltip'; import { ChartTooltipService } from '../../../components/chart_tooltip';
import { AnnotationState, AnnotationUpdatesService } from '../../../services/annotations_service';
interface Props { interface Props {
selectedJob: CombinedJob; selectedJob: CombinedJob;
@ -47,6 +48,11 @@ interface TimeseriesChartProps {
tooltipService: object; tooltipService: object;
} }
declare class TimeseriesChart extends React.Component<Props, any> { interface TimeseriesChartIntProps {
annotationUpdatesService: AnnotationUpdatesService;
annotationProps: AnnotationState;
}
declare class TimeseriesChart extends React.Component<Props & TimeseriesChartIntProps, any> {
focusXScale: d3.scale.Ordinal<{}, number>; focusXScale: d3.scale.Ordinal<{}, number>;
} }

View file

@ -10,7 +10,7 @@
*/ */
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component, useContext } from 'react';
import useObservable from 'react-use/lib/useObservable'; import useObservable from 'react-use/lib/useObservable';
import { isEqual, reduce, each, get } from 'lodash'; import { isEqual, reduce, each, get } from 'lodash';
import d3 from 'd3'; import d3 from 'd3';
@ -21,7 +21,6 @@ import {
getSeverityWithLow, getSeverityWithLow,
getMultiBucketImpactLabel, getMultiBucketImpactLabel,
} from '../../../../../common/util/anomaly_utils'; } from '../../../../../common/util/anomaly_utils';
import { annotation$ } from '../../../services/annotations_service';
import { formatValue } from '../../../formatters/format_value'; import { formatValue } from '../../../formatters/format_value';
import { import {
LINE_CHART_ANOMALY_RADIUS, LINE_CHART_ANOMALY_RADIUS,
@ -51,7 +50,7 @@ import {
unhighlightFocusChartAnnotation, unhighlightFocusChartAnnotation,
ANNOTATION_MIN_WIDTH, ANNOTATION_MIN_WIDTH,
} from './timeseries_chart_annotations'; } from './timeseries_chart_annotations';
import { distinctUntilChanged } from 'rxjs/operators'; import { MlAnnotationUpdatesContext } from '../../../contexts/ml/ml_annotation_updates_context';
const focusZoomPanelHeight = 25; const focusZoomPanelHeight = 25;
const focusChartHeight = 310; const focusChartHeight = 310;
@ -571,7 +570,6 @@ class TimeseriesChartIntl extends Component {
} }
renderFocusChart() { renderFocusChart() {
console.log('renderFocusChart');
const { const {
focusAggregationInterval, focusAggregationInterval,
focusAnnotationData: focusAnnotationDataOriginalPropValue, focusAnnotationData: focusAnnotationDataOriginalPropValue,
@ -742,7 +740,8 @@ class TimeseriesChartIntl extends Component {
this.focusXScale, this.focusXScale,
showAnnotations, showAnnotations,
showFocusChartTooltip, showFocusChartTooltip,
hideFocusChartTooltip hideFocusChartTooltip,
this.props.annotationUpdatesService
); );
// disable brushing (creation of annotations) when annotations aren't shown // disable brushing (creation of annotations) when annotations aren't shown
@ -1800,17 +1799,17 @@ class TimeseriesChartIntl extends Component {
} }
export const TimeseriesChart = (props) => { export const TimeseriesChart = (props) => {
const annotationProp = useObservable( const annotationUpdatesService = useContext(MlAnnotationUpdatesContext);
annotation$.pipe( const annotationProp = useObservable(annotationUpdatesService.isAnnotationInitialized$());
distinctUntilChanged((prev, curr) => {
// prevent re-rendering
return prev !== null && curr !== null;
})
)
);
if (annotationProp === undefined) { if (annotationProp === undefined) {
return null; return null;
} }
return <TimeseriesChartIntl annotation={annotationProp} {...props} />; return (
<TimeseriesChartIntl
annotation={annotationProp}
{...props}
annotationUpdatesService={annotationUpdatesService}
/>
);
}; };

View file

@ -13,13 +13,14 @@ import { Dictionary } from '../../../../../common/types/common';
import { TimeseriesChart } from './timeseries_chart'; import { TimeseriesChart } from './timeseries_chart';
import { annotation$ } from '../../../services/annotations_service'; import { AnnotationUpdatesService } from '../../../services/annotations_service';
export const ANNOTATION_MASK_ID = 'mlAnnotationMask'; export const ANNOTATION_MASK_ID = 'mlAnnotationMask';
// getAnnotationBrush() is expected to be called like getAnnotationBrush.call(this) // getAnnotationBrush() is expected to be called like getAnnotationBrush.call(this)
// so it gets passed on the context of the component it gets called from. // so it gets passed on the context of the component it gets called from.
export function getAnnotationBrush(this: TimeseriesChart) { export function getAnnotationBrush(this: TimeseriesChart) {
const { annotationUpdatesService } = this.props;
const focusXScale = this.focusXScale; const focusXScale = this.focusXScale;
const annotateBrush = d3.svg.brush().x(focusXScale).on('brushend', brushend.bind(this)); const annotateBrush = d3.svg.brush().x(focusXScale).on('brushend', brushend.bind(this));
@ -35,7 +36,7 @@ export function getAnnotationBrush(this: TimeseriesChart) {
const endTimestamp = extent[1].getTime(); const endTimestamp = extent[1].getTime();
if (timestamp === endTimestamp) { if (timestamp === endTimestamp) {
annotation$.next(null); annotationUpdatesService.setValue(null);
return; return;
} }
@ -47,7 +48,7 @@ export function getAnnotationBrush(this: TimeseriesChart) {
type: ANNOTATION_TYPE.ANNOTATION, type: ANNOTATION_TYPE.ANNOTATION,
}; };
annotation$.next(annotation); annotationUpdatesService.setValue(annotation);
} }
return annotateBrush; return annotateBrush;
@ -105,7 +106,8 @@ export function renderAnnotations(
focusXScale: TimeseriesChart['focusXScale'], focusXScale: TimeseriesChart['focusXScale'],
showAnnotations: boolean, showAnnotations: boolean,
showFocusChartTooltip: (d: Annotation, t: object) => {}, showFocusChartTooltip: (d: Annotation, t: object) => {},
hideFocusChartTooltip: () => void hideFocusChartTooltip: () => void,
annotationUpdatesService: AnnotationUpdatesService
) { ) {
const upperRectMargin = ANNOTATION_UPPER_RECT_MARGIN; const upperRectMargin = ANNOTATION_UPPER_RECT_MARGIN;
const upperTextMargin = ANNOTATION_UPPER_TEXT_MARGIN; const upperTextMargin = ANNOTATION_UPPER_TEXT_MARGIN;
@ -153,9 +155,9 @@ export function renderAnnotations(
// clear a possible existing annotation set up for editing before setting the new one. // clear a possible existing annotation set up for editing before setting the new one.
// this needs to be done explicitly here because a new annotation created using the brush tool // this needs to be done explicitly here because a new annotation created using the brush tool
// could still be present in the chart. // could still be present in the chart.
annotation$.next(null); annotationUpdatesService.setValue(null);
// set the actual annotation and trigger the flyout // set the actual annotation and trigger the flyout
annotation$.next(d); annotationUpdatesService.setValue(d);
}); });
rects rects

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import React, { FC, useEffect, useState, useCallback } from 'react'; import React, { FC, useEffect, useState, useCallback, useContext } from 'react';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { MlTooltipComponent } from '../../../components/chart_tooltip'; import { MlTooltipComponent } from '../../../components/chart_tooltip';
import { TimeseriesChart } from './timeseries_chart'; import { TimeseriesChart } from './timeseries_chart';
@ -16,6 +16,7 @@ import { useMlKibana, useNotifications } from '../../../contexts/kibana';
import { getBoundsRoundedToInterval } from '../../../util/time_buckets'; import { getBoundsRoundedToInterval } from '../../../util/time_buckets';
import { ANNOTATION_EVENT_USER } from '../../../../../common/constants/annotations'; import { ANNOTATION_EVENT_USER } from '../../../../../common/constants/annotations';
import { getControlsForDetector } from '../../get_controls_for_detector'; import { getControlsForDetector } from '../../get_controls_for_detector';
import { MlAnnotationUpdatesContext } from '../../../contexts/ml/ml_annotation_updates_context';
interface TimeSeriesChartWithTooltipsProps { interface TimeSeriesChartWithTooltipsProps {
bounds: any; bounds: any;
@ -50,6 +51,8 @@ export const TimeSeriesChartWithTooltips: FC<TimeSeriesChartWithTooltipsProps> =
}, },
} = useMlKibana(); } = useMlKibana();
const annotationUpdatesService = useContext(MlAnnotationUpdatesContext);
const [annotationData, setAnnotationData] = useState<Annotation[]>([]); const [annotationData, setAnnotationData] = useState<Annotation[]>([]);
const showAnnotationErrorToastNotification = useCallback((error?: string) => { const showAnnotationErrorToastNotification = useCallback((error?: string) => {
@ -123,6 +126,7 @@ export const TimeSeriesChartWithTooltips: FC<TimeSeriesChartWithTooltipsProps> =
{(tooltipService) => ( {(tooltipService) => (
<TimeseriesChart <TimeseriesChart
{...chartProps} {...chartProps}
annotationUpdatesService={annotationUpdatesService}
annotationData={annotationData} annotationData={annotationData}
bounds={bounds} bounds={bounds}
detectorIndex={detectorIndex} detectorIndex={detectorIndex}

View file

@ -1014,7 +1014,6 @@ export class TimeSeriesExplorer extends React.Component {
this.previousShowForecast = showForecast; this.previousShowForecast = showForecast;
this.previousShowModelBounds = showModelBounds; this.previousShowModelBounds = showModelBounds;
console.log('Timeseriesexplorer rerendered');
return ( return (
<TimeSeriesExplorerPage dateFormatTz={dateFormatTz} resizeRef={this.resizeRef}> <TimeSeriesExplorerPage dateFormatTz={dateFormatTz} resizeRef={this.resizeRef}>
{fieldNamesWithEmptyValues.length > 0 && ( {fieldNamesWithEmptyValues.length > 0 && (