[ML] Migrate Anomaly Explorer to React (except job selector) (#28234)

* [ML] Move Anomaly Explorer Loading indicator to React.
* [ML] Move no-jobs message to React/Eui.
* [ML] Move no-results message to React/Eui.
* [ML] Refactored explorer.js to return earlier.
* [ML] Refactored influencers column to react.
* [ML] Refactored Overall Swimlane and view-by dropdown to react/eui.
* [ML] Refactored limit dropdown to react/eui.
* [ML] Refactored view-by swimlanes to React/Eui.
* [ML] Refactored annotations table to React/Eui.
* [ML] Refactored table controls to React/Eui.
* [ML] Refactored explorer charts to use React/Eui.
* [ML] Refactored anomalies table to React/Eui.
* [ML] Move explorer charts data listener to ExplorerChartsContainer component.
* [ML] Make AppState dependent services importable by React components.
* [ML] Removes deprecated code.
* [ML] Simplify state handling for anomaly charts.
* [ML] Simplify swimlaneCellClick().
* [ML] Review feedback: Fix file structure, add propTypes.
* [ML] Review feedback: Avoid anonymous inline functions.
* [ML] Fixes tests to reflect code changes.
* [ML] Fixes InfluencerList DOM position.
* [ML] Show a loading indicator when the view-by swimlane updates.
* [ML] Review feedback: Import only relevant lodash bits. Use querySelector instead of jQuery.
* [ML] Adds snapshot tests for new smallish components.
* [ML] Fix test stub.
* [ML] More resilient getChartContainerWidth().
* [ML] Review feedback: Comment on legacy utils and dropdown widths.
This commit is contained in:
Walter Rafelsberger 2019-01-11 10:34:56 +01:00 committed by GitHub
parent 38163f2517
commit c58c357115
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 1023 additions and 726 deletions

View file

@ -17,12 +17,18 @@ import {
import makeId from '@elastic/eui/lib/components/form/form_row/make_id';
// This service will be populated by the corresponding angularjs based one.
export const mlCheckboxShowChartsService = {
intialized: false,
state: null
};
class CheckboxShowCharts extends Component {
constructor(props) {
super(props);
// Restore the checked setting from the state.
this.mlCheckboxShowChartsService = this.props.mlCheckboxShowChartsService;
this.mlCheckboxShowChartsService = mlCheckboxShowChartsService;
const showCharts = this.mlCheckboxShowChartsService.state.get('showCharts');
this.state = {

View file

@ -12,22 +12,21 @@ import { stateFactoryProvider } from 'plugins/ml/factories/state_factory';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml', ['react']);
import { CheckboxShowCharts } from './checkbox_showcharts';
import { CheckboxShowCharts, mlCheckboxShowChartsService } from './checkbox_showcharts';
module.service('mlCheckboxShowChartsService', function (Private) {
const stateFactory = Private(stateFactoryProvider);
this.state = stateFactory('mlCheckboxShowCharts', {
this.state = mlCheckboxShowChartsService.state = stateFactory('mlCheckboxShowCharts', {
showCharts: true
});
mlCheckboxShowChartsService.initialized = true;
})
.directive('mlCheckboxShowCharts', function ($injector) {
const reactDirective = $injector.get('reactDirective');
const mlCheckboxShowChartsService = $injector.get('mlCheckboxShowChartsService');
return reactDirective(
CheckboxShowCharts,
undefined,
{ restrict: 'E' },
{ mlCheckboxShowChartsService }
);
});

View file

@ -9,7 +9,7 @@
/*
* React component for rendering a select element with various aggregation interval levels.
*/
import _ from 'lodash';
import { get } from 'lodash';
import React, { Component } from 'react';
import {
@ -38,14 +38,20 @@ function optionValueToInterval(value) {
return interval;
}
// This service will be populated by the corresponding angularjs based one.
export const mlSelectIntervalService = {
intialized: false,
state: null
};
class SelectInterval extends Component {
constructor(props) {
super(props);
// Restore the interval from the state, or default to auto.
this.mlSelectIntervalService = this.props.mlSelectIntervalService;
this.mlSelectIntervalService = mlSelectIntervalService;
const intervalState = this.mlSelectIntervalService.state.get('interval');
const intervalValue = _.get(intervalState, 'val', 'auto');
const intervalValue = get(intervalState, 'val', 'auto');
const interval = optionValueToInterval(intervalValue);
this.mlSelectIntervalService.state.set('interval', interval);

View file

@ -12,22 +12,21 @@ import { stateFactoryProvider } from 'plugins/ml/factories/state_factory';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml', ['react']);
import { SelectInterval } from './select_interval';
import { SelectInterval, mlSelectIntervalService } from './select_interval';
module.service('mlSelectIntervalService', function (Private) {
const stateFactory = Private(stateFactoryProvider);
this.state = stateFactory('mlSelectInterval', {
this.state = mlSelectIntervalService.state = stateFactory('mlSelectInterval', {
interval: { display: 'Auto', val: 'auto' }
});
mlSelectIntervalService.initialized = true;
})
.directive('mlSelectInterval', function ($injector) {
const reactDirective = $injector.get('reactDirective');
const mlSelectIntervalService = $injector.get('mlSelectIntervalService');
return reactDirective(
SelectInterval,
undefined,
{ restrict: 'E' },
{ mlSelectIntervalService }
{ restrict: 'E' }
);
});

View file

@ -10,7 +10,7 @@
* React component for rendering a select element with threshold levels.
*/
import PropTypes from 'prop-types';
import _ from 'lodash';
import { get } from 'lodash';
import React, { Component, Fragment } from 'react';
import {
@ -30,7 +30,7 @@ const optionsMap = {
'critical': 75,
};
const SEVERITY_OPTIONS = [
export const SEVERITY_OPTIONS = [
{ val: 0, display: 'warning', color: getSeverityColor(0) },
{ val: 25, display: 'minor', color: getSeverityColor(25) },
{ val: 50, display: 'major', color: getSeverityColor(50) },
@ -49,13 +49,19 @@ function optionValueToThreshold(value) {
return threshold;
}
// This service will be populated by the corresponding angularjs based one.
export const mlSelectSeverityService = {
intialized: false,
state: null
};
class SelectSeverity extends Component {
constructor(props) {
super(props);
// Restore the threshold from the state, or default to warning.
if (this.props.mlSelectSeverityService) {
this.mlSelectSeverityService = this.props.mlSelectSeverityService;
if (mlSelectSeverityService.intialized) {
this.mlSelectSeverityService = mlSelectSeverityService;
}
this.state = {
@ -67,7 +73,7 @@ class SelectSeverity extends Component {
// set initial state from service if available
if (this.mlSelectSeverityService !== undefined) {
const thresholdState = this.mlSelectSeverityService.state.get('threshold');
const thresholdValue = _.get(thresholdState, 'val', 0);
const thresholdValue = get(thresholdState, 'val', 0);
const threshold = optionValueToThreshold(thresholdValue);
// set initial selected option equal to threshold value
const selectedOption = SEVERITY_OPTIONS.find(opt => (opt.val === threshold.val));

View file

@ -12,22 +12,21 @@ import { stateFactoryProvider } from 'plugins/ml/factories/state_factory';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml', ['react']);
import { SelectSeverity } from './select_severity';
import { SelectSeverity, mlSelectSeverityService } from './select_severity';
module.service('mlSelectSeverityService', function (Private) {
const stateFactory = Private(stateFactoryProvider);
this.state = stateFactory('mlSelectSeverity', {
this.state = mlSelectSeverityService.state = stateFactory('mlSelectSeverity', {
threshold: { display: 'warning', val: 0 }
});
mlSelectSeverityService.intialized = true;
})
.directive('mlSelectSeverity', function ($injector) {
const reactDirective = $injector.get('reactDirective');
const mlSelectSeverityService = $injector.get('mlSelectSeverityService');
return reactDirective(
SelectSeverity,
undefined,
{ restrict: 'E' },
{ mlSelectSeverityService }
);
});

View file

@ -4,5 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
import './influencers_list_directive';
export * from './influencers_list';

View file

@ -1,23 +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 'ngreact';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml', ['react']);
import { InfluencersList } from './influencers_list';
module.directive('mlInfluencersList', function ($injector) {
const reactDirective = $injector.get('reactDirective');
return reactDirective(
InfluencersList,
undefined,
{ restrict: 'E' }
);
});

View file

@ -9,14 +9,18 @@
import PropTypes from 'prop-types';
import React from 'react';
export function LoadingIndicator({ height }) {
export function LoadingIndicator({ height, label }) {
height = height ? +height : 100;
return (
<div className="ml-loading-indicator" style={{ height: `${height}px` }}>
<div className="loading-spinner"><i className="fa fa-spinner fa-spin" /></div>
{label &&
<div ml-loading-indicator-label="true">{label}</div>
}
</div>
);
}
LoadingIndicator.propTypes = {
height: PropTypes.number
height: PropTypes.number,
label: PropTypes.string
};

View file

@ -62,7 +62,6 @@
.panel-sub-title {
color: $euiColorDarkShade;
display: inline-block;
font-size: $euiFontSizeXS;
}
}

View file

@ -0,0 +1,22 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ExplorerNoInfluencersFound snapshot 1`] = `
<EuiEmptyPrompt
iconColor="subdued"
iconType="alert"
title={
<h2>
<FormattedMessage
defaultMessage="No {swimlaneViewByFieldName} influencers found"
id="xpack.ml.explorer.noInfluencersFoundTitle"
values={
Object {
"swimlaneViewByFieldName": "field_name",
}
}
/>
</h2>
}
titleSize="xs"
/>
`;

View file

@ -0,0 +1,35 @@
/*
* 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.
*/
/*
* React component for rendering EuiEmptyPrompt when no influencers were found.
*/
import PropTypes from 'prop-types';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiEmptyPrompt } from '@elastic/eui';
export const ExplorerNoInfluencersFound = ({ swimlaneViewByFieldName }) => (
<EuiEmptyPrompt
iconType="alert"
titleSize="xs"
title={
<h2>
<FormattedMessage
id="xpack.ml.explorer.noInfluencersFoundTitle"
defaultMessage="No {swimlaneViewByFieldName} influencers found"
values={{ swimlaneViewByFieldName }}
/>
</h2>
}
/>
);
ExplorerNoInfluencersFound.propTypes = {
swimlaneViewByFieldName: PropTypes.string.isRequired,
};

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { ExplorerNoInfluencersFound } from './explorer_no_influencers_found';
describe('ExplorerNoInfluencersFound', () => {
test('snapshot', () => {
const wrapper = shallow(<ExplorerNoInfluencersFound swimlaneViewByFieldName="field_name" />);
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -0,0 +1,7 @@
/*
* 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.
*/
export * from './explorer_no_influencers_found';

View file

@ -0,0 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ExplorerNoInfluencersFound snapshot 1`] = `
<EuiEmptyPrompt
actions={
<EuiButton
color="primary"
fill={true}
href="ml#/jobs"
iconSide="left"
type="button"
>
<FormattedMessage
defaultMessage="Create new job"
id="xpack.ml.explorer.createNewJobLinkText"
values={Object {}}
/>
</EuiButton>
}
iconColor="subdued"
iconType="alert"
title={
<h2>
<FormattedMessage
defaultMessage="No jobs found"
id="xpack.ml.explorer.noJobsFoundLabel"
values={Object {}}
/>
</h2>
}
/>
`;

View file

@ -0,0 +1,33 @@
/*
* 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.
*/
/*
* React component for rendering EuiEmptyPrompt when no jobs were found.
*/
import React from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiEmptyPrompt, EuiButton } from '@elastic/eui';
export const ExplorerNoJobsFound = () => (
<EuiEmptyPrompt
iconType="alert"
title={
<h2>
<FormattedMessage id="xpack.ml.explorer.noJobsFoundLabel" defaultMessage="No jobs found" />
</h2>
}
actions={
<EuiButton color="primary" fill href="ml#/jobs">
<FormattedMessage
id="xpack.ml.explorer.createNewJobLinkText"
defaultMessage="Create new job"
/>
</EuiButton>
}
/>
);

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { ExplorerNoJobsFound } from './explorer_no_jobs_found';
describe('ExplorerNoInfluencersFound', () => {
test('snapshot', () => {
const wrapper = shallow(<ExplorerNoJobsFound />);
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -0,0 +1,7 @@
/*
* 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.
*/
export * from './explorer_no_jobs_found';

View file

@ -0,0 +1,28 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ExplorerNoInfluencersFound snapshot 1`] = `
<EuiEmptyPrompt
body={
<React.Fragment>
<p>
<FormattedMessage
defaultMessage="Try widening the time selection or moving further back in time"
id="xpack.ml.explorer.tryWideningTimeSelectionLabel"
values={Object {}}
/>
</p>
</React.Fragment>
}
iconColor="subdued"
iconType="iInCircle"
title={
<h2>
<FormattedMessage
defaultMessage="No results found"
id="xpack.ml.explorer.noResultsFoundLabel"
values={Object {}}
/>
</h2>
}
/>
`;

View file

@ -0,0 +1,38 @@
/*
* 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.
*/
/*
* React component for rendering EuiEmptyPrompt when no results were found.
*/
import React from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiEmptyPrompt } from '@elastic/eui';
export const ExplorerNoResultsFound = () => (
<EuiEmptyPrompt
iconType="iInCircle"
title={
<h2>
<FormattedMessage
id="xpack.ml.explorer.noResultsFoundLabel"
defaultMessage="No results found"
/>
</h2>
}
body={
<React.Fragment>
<p>
<FormattedMessage
id="xpack.ml.explorer.tryWideningTimeSelectionLabel"
defaultMessage="Try widening the time selection or moving further back in time"
/>
</p>
</React.Fragment>
}
/>
);

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { ExplorerNoResultsFound } from './explorer_no_results_found';
describe('ExplorerNoInfluencersFound', () => {
test('snapshot', () => {
const wrapper = shallow(<ExplorerNoResultsFound />);
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -0,0 +1,7 @@
/*
* 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.
*/
export * from './explorer_no_results_found';

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export * from './explorer_no_influencers_found';
export * from './explorer_no_jobs_found';
export * from './explorer_no_results_found';

View file

@ -10,205 +10,5 @@
state="appState">
</filter-bar>
<ml-loading-indicator
label="{{ ::'xpack.ml.explorer.loadingLabel' | i18n: {defaultMessage: 'Loading'} }}"
is-loading="loading === true"
/>
<div class="no-results-container" ng-if="jobs.length === 0 && loading === false">
<div class="no-results euiText">
<div
i18n-id="xpack.ml.explorer.noJobsFoundLabel"
i18n-default-message="{icon} No jobs found"
i18n-values="{ html_icon: '<i class=\'fa fa-exclamation-triangle\' ></i>' }"
></div>
<div>
<a
href="ml#/jobs"
i18n-id="xpack.ml.explorer.createNewJobLinkText"
i18n-default-message="Create new job"
></a>
</div>
</div>
</div>
<div class="no-results-container" ng-show="jobs.length > 0 && loading === false && hasResults === false">
<div class="no-results euiText">
<div
i18n-id="xpack.ml.explorer.noResultsFoundLabel"
i18n-default-message="{icon} No results found"
i18n-values="{ html_icon: '<i class=\'fa fa-info-circle\' ></i>' }"
></div>
<div
i18n-id="xpack.ml.explorer.tryWideningTimeSelectionLabel"
i18n-default-message="Try widening the time selection or moving further back in time"
></div>
</div>
</div>
<div ng-if="jobs.length > 0 && loading === false && hasResults === true">
<div class="results-container">
<div ng-if="noInfluencersConfigured === true" class="no-influencers-warning">
<i aria-hidden="true" class="fa fa-info-circle" tooltip-placement="right" tooltip-append-to-body="true"
tooltip-html-unsafe="{{ ::'xpack.ml.explorer.noConfiguredInfluencersTooltip' | i18n: {defaultMessage: 'The Top Influencers list is hidden because no influencers have been configured for the selected jobs.'} }}" ></i>
</div>
<div ng-if="noInfluencersConfigured === false" class="column col-xs-2 euiText">
<span
class="panel-title"
i18n-id="xpack.ml.explorer.topInfuencersTitle"
i18n-default-message="Top Influencers"
></span>
<ml-influencers-list
influencers="influencers"
/>
</div>
<div class="column" ng-class="noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10'">
<span class="panel-title euiText"
i18n-id="xpack.ml.explorer.anomalyTimelineTitle"
i18n-default-message="Anomaly timeline"
></span>
<div
class="ml-explorer-swimlane euiText"
ng-mouseenter="setSwimlaneSelectActive(true)"
ng-mouseleave="setSwimlaneSelectActive(false)"
>
<ml-explorer-swimlane swimlane-type="overall" />
</div>
<div ng-if="viewBySwimlaneOptions.length > 0">
<div class="ml-controls">
<label
for="selectViewBy"
class="kuiLabel"
i18n-id="xpack.ml.explorer.viewByLabel"
i18n-default-message="View by:"
></label>
<div class="kuiButtonGroup" dropdown>
<button id="selectViewBy" type="button" class="form-control dropdown-toggle" dropdown-toggle ng-disabled="disabled">
<span>{{swimlaneViewByFieldName}}</span> <span class="caret"></span>
</button>
<ul class="dropdown-menu" role="menu">
<li ng-repeat="swimlaneOption in viewBySwimlaneOptions">
<a href="" ng-click="setSwimlaneViewBy(swimlaneOption)">{{swimlaneOption}}</a>
</li>
</ul>
</div>
<ml-select-limit />
<span
ng-if="viewByLoadedForTimeFormatted"
class="panel-sub-title"
i18n-id="xpack.ml.explorer.sortedByMaxAnomalyScoreForTimeFormattedLabel"
i18n-default-message="(Sorted by max anomaly score for {viewByLoadedForTimeFormatted})"
i18n-values="{ viewByLoadedForTimeFormatted }"
></span>
<span
ng-if="!viewByLoadedForTimeFormatted"
class="panel-sub-title"
i18n-id="xpack.ml.explorer.sortedByMaxAnomalyScoreLabel"
i18n-default-message="(Sorted by max anomaly score)"
></span>
</div>
<div
class="ml-explorer-swimlane euiText"
ng-if="showViewBySwimlane"
ng-mouseenter="setSwimlaneSelectActive(true)"
ng-mouseleave="setSwimlaneSelectActive(false)"
>
<ml-explorer-swimlane swimlane-type="viewBy" />
</div>
<div ng-if="!showViewBySwimlane" class="text-center visError euiText">
<div class="item top"></div>
<div class="item">
<h4
class="euiTitle euiTitle--small"
i18n-id="xpack.ml.explorer.noInfluencersFoundTitle"
i18n-default-message="No {swimlaneViewByFieldName} influencers found"
i18n-values="{ swimlaneViewByFieldName }"
></h4>
</div>
<div class="item bottom"></div>
</div>
</div>
<div ng-show="annotationsData.length > 0">
<span
class="panel-title euiText"
i18n-id="xpack.ml.explorer.annotationsTitle"
i18n-default-message="Annotations"
></span>
<ml-annotation-table
annotations="annotationsData"
drill-down="true"
number-badge="false"
/>
<br /><br />
</div>
<span
class="panel-title euiText"
i18n-id="xpack.ml.explorer.anomaliesTitle"
i18n-default-message="Anomalies"
></span>
<div class="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--directionRow euiFlexGroup--responsive ml-anomalies-controls">
<div class="euiFlexItem euiFlexItem--flexGrowZero" style="width:170px">
<div class="euiFormRow" id="select_severity_control">
<label
class="euiFormLabel"
for="select_severity"
i18n-id="xpack.ml.explorer.severityThresholdLabel"
i18n-default-message="Severity threshold"
></label>
<div class="euiFormControlLayout">
<ml-select-severity id="select_severity" />
</div>
</div>
</div>
<div class="euiFlexItem euiFlexItem--flexGrowZero" style="width:170px">
<div class="euiFormRow" id="select_interval_control">
<label
class="euiFormLabel"
for="select_interval"
i18n-id="xpack.ml.explorer.intervalLabel"
i18n-default-message="Interval"
></label>
<div class="euiFormControlLayout">
<ml-select-interval id="select_interval" />
</div>
</div>
</div>
<div class="euiFlexItem euiFlexItem--flexGrowZero" ng-show="anomalyChartRecords.length > 0">
<div class="euiFormRow" id="show_charts_checkbox_control">
<div class="euiFormControlLayout">
<ml-checkbox-show-charts id="show_charts_checkbox" />
</div>
</div>
</div>
</div>
<div class="euiSpacer euiSpacer--m"></div>
<div class="euiText explorer-charts">
<ml-explorer-charts-container />
</div>
<ml-anomalies-table
table-data="tableData"
/>
</div>
</div>
</div>
<ml-explorer-react-wrapper />
</div>

View file

@ -0,0 +1,302 @@
/*
* 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.
*/
/*
* React component for rendering Explorer dashboard swimlanes.
*/
import PropTypes from 'prop-types';
import React from 'react';
import { FormattedMessage, injectI18n } from '@kbn/i18n/react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiIconTip,
EuiSelect,
EuiSpacer,
} from '@elastic/eui';
import { AnnotationsTable } from '../components/annotations_table';
import { CheckboxShowCharts } from '../components/controls/checkbox_showcharts/checkbox_showcharts';
import {
ExplorerNoInfluencersFound,
ExplorerNoJobsFound,
ExplorerNoResultsFound,
} from './components';
import { ExplorerSwimlane } from './explorer_swimlane';
import { InfluencersList } from '../components/influencers_list';
import { LoadingIndicator } from '../components/loading_indicator/loading_indicator';
import { SelectInterval } from '../components/controls/select_interval/select_interval';
import { SelectLimit } from './select_limit/select_limit';
import { SelectSeverity } from '../components/controls/select_severity/select_severity';
// Explorer Charts
import { ExplorerChartsContainer } from './explorer_charts/explorer_charts_container';
// Anomalies Table
import { AnomaliesTable } from '../components/anomalies_table/anomalies_table';
import { timefilter } from 'ui/timefilter';
function mapSwimlaneOptionsToEuiOptions(options) {
return options.map(option => ({
value: option,
text: option,
}));
}
export const Explorer = injectI18n(
class Explorer extends React.Component {
static propTypes = {
annotationsData: PropTypes.array,
anomalyChartRecords: PropTypes.array,
hasResults: PropTypes.bool,
influencers: PropTypes.object,
jobs: PropTypes.array,
loading: PropTypes.bool,
noInfluencersConfigured: PropTypes.bool,
setSwimlaneSelectActive: PropTypes.func,
setSwimlaneViewBy: PropTypes.func,
showViewBySwimlane: PropTypes.bool,
swimlaneOverall: PropTypes.object,
swimlaneViewByFieldName: PropTypes.string,
tableData: PropTypes.object,
viewByLoadedForTimeFormatted: PropTypes.any,
viewBySwimlaneOptions: PropTypes.array,
};
viewByChangeHandler = e => this.props.setSwimlaneViewBy(e.target.value);
onSwimlaneEnterHandler = () => this.props.setSwimlaneSelectActive(true);
onSwimlaneLeaveHandler = () => this.props.setSwimlaneSelectActive(false);
render() {
const {
annotationsData,
anomalyChartRecords,
chartsData,
influencers,
intl,
hasResults,
jobs,
loading,
noInfluencersConfigured,
showViewBySwimlane,
swimlaneOverall,
swimlaneViewBy,
swimlaneViewByFieldName,
tableData,
viewByLoadedForTimeFormatted,
viewBySwimlaneDataLoading,
viewBySwimlaneOptions,
} = this.props;
if (loading === true) {
return (
<LoadingIndicator
label={intl.formatMessage({
id: 'xpack.ml.explorer.loadingLabel',
defaultMessage: 'Loading',
})}
/>
);
}
if (jobs.length === 0) {
return <ExplorerNoJobsFound />;
}
if (jobs.length > 0 && hasResults === false) {
return <ExplorerNoResultsFound />;
}
const mainColumnWidthClassName = noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10';
const mainColumnClasses = `column ${mainColumnWidthClassName}`;
return (
<div className="results-container">
{noInfluencersConfigured && (
<div className="no-influencers-warning">
<EuiIconTip
content={intl.formatMessage({
id: 'xpack.ml.explorer.noConfiguredInfluencersTooltip',
defaultMessage:
'The Top Influencers list is hidden because no influencers have been configured for the selected jobs.',
})}
position="right"
type="iInCircle"
/>
</div>
)}
{noInfluencersConfigured === false && (
<div className="column col-xs-2 euiText">
<span className="panel-title">
<FormattedMessage
id="xpack.ml.explorer.topInfuencersTitle"
defaultMessage="Top Influencers"
/>
</span>
<InfluencersList influencers={influencers} />
</div>
)}
<div className={mainColumnClasses}>
<span className="panel-title euiText">
<FormattedMessage
id="xpack.ml.explorer.anomalyTimelineTitle"
defaultMessage="Anomaly timeline"
/>
</span>
<div
className="ml-explorer-swimlane euiText"
onMouseEnter={this.onSwimlaneEnterHandler}
onMouseLeave={this.onSwimlaneLeaveHandler}
>
<ExplorerSwimlane {...swimlaneOverall} />
</div>
{viewBySwimlaneOptions.length > 0 && (
<React.Fragment>
<EuiFlexGroup direction="row" gutterSize="l" responsive={true}>
<EuiFlexItem grow={false}>
<EuiFormRow
label={intl.formatMessage({
id: 'xpack.ml.explorer.viewByLabel',
defaultMessage: 'View by',
})}
>
<EuiSelect
id="selectViewBy"
options={mapSwimlaneOptionsToEuiOptions(viewBySwimlaneOptions)}
value={swimlaneViewByFieldName}
onChange={this.viewByChangeHandler}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFormRow
label={intl.formatMessage({
id: 'xpack.ml.explorer.limitLabel',
defaultMessage: 'Limit',
})}
>
<SelectLimit />
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ alignSelf: 'center' }}>
<EuiFormRow label="&#8203;">
<div className="panel-sub-title">
{viewByLoadedForTimeFormatted && (
<FormattedMessage
id="xpack.ml.explorer.sortedByMaxAnomalyScoreForTimeFormattedLabel"
defaultMessage="(Sorted by max anomaly score for {viewByLoadedForTimeFormatted})"
values={{ viewByLoadedForTimeFormatted }}
/>
)}
{viewByLoadedForTimeFormatted === undefined && (
<FormattedMessage
id="xpack.ml.explorer.sortedByMaxAnomalyScoreLabel"
defaultMessage="(Sorted by max anomaly score)"
/>
)}
</div>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
{showViewBySwimlane && (
<div
className="ml-explorer-swimlane euiText"
onMouseEnter={this.onSwimlaneEnterHandler}
onMouseLeave={this.onSwimlaneLeaveHandler}
>
<ExplorerSwimlane {...swimlaneViewBy} />
</div>
)}
{viewBySwimlaneDataLoading && (
<LoadingIndicator/>
)}
{!showViewBySwimlane && !viewBySwimlaneDataLoading && (
<ExplorerNoInfluencersFound swimlaneViewByFieldName={swimlaneViewByFieldName} />
)}
</React.Fragment>
)}
{annotationsData.length > 0 && (
<React.Fragment>
<span className="panel-title euiText">
<FormattedMessage
id="xpack.ml.explorer.annotationsTitle"
defaultMessage="Annotations"
/>
</span>
<AnnotationsTable
annotations={annotationsData}
drillDown={true}
numberBadge={false}
/>
<br />
<br />
</React.Fragment>
)}
<span className="panel-title euiText">
<FormattedMessage id="xpack.ml.explorer.anomaliesTitle" defaultMessage="Anomalies" />
</span>
<EuiFlexGroup
direction="row"
gutterSize="l"
responsive={true}
className="ml-anomalies-controls"
>
<EuiFlexItem grow={false} style={{ width: '170px' }}>
<EuiFormRow
label={intl.formatMessage({
id: 'xpack.ml.explorer.severityThresholdLabel',
defaultMessage: 'Severity threshold',
})}
>
<SelectSeverity />
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ width: '170px' }}>
<EuiFormRow
label={intl.formatMessage({
id: 'xpack.ml.explorer.intervalLabel',
defaultMessage: 'Interval',
})}
>
<SelectInterval />
</EuiFormRow>
</EuiFlexItem>
{anomalyChartRecords.length > 0 && (
<EuiFlexItem grow={false} style={{ alignSelf: 'center' }}>
<EuiFormRow label="&#8203;">
<CheckboxShowCharts />
</EuiFormRow>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiSpacer size="m" />
<div className="euiText explorer-charts">
<ExplorerChartsContainer {...chartsData} />
</div>
<AnomaliesTable tableData={tableData} timefilter={timefilter} />
</div>
</div>
);
}
}
);

View file

@ -2,6 +2,7 @@
exports[`explorerChartsContainerService call anomalyChangeListener with actual series config 1`] = `
Object {
"chartsPerRow": 1,
"seriesToPlot": Array [],
"timeFieldName": "timestamp",
"tooManyBuckets": false,

View file

@ -33,6 +33,7 @@ import { LoadingIndicator } from '../../components/loading_indicator/loading_ind
import { mlEscape } from '../../util/string_utils';
import { mlFieldFormatService } from '../../services/field_format_service';
import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service';
import { mlSelectSeverityService, SEVERITY_OPTIONS } from '../../components/controls/select_severity/select_severity';
import { CHART_TYPE } from '../explorer_constants';
@ -50,7 +51,6 @@ const Y_AXIS_LABEL_THRESHOLD = 10;
export const ExplorerChartDistribution = injectI18n(class ExplorerChartDistribution extends React.Component {
static propTypes = {
seriesConfig: PropTypes.object,
mlSelectSeverityService: PropTypes.object.isRequired
}
componentDidMount() {
@ -64,7 +64,6 @@ export const ExplorerChartDistribution = injectI18n(class ExplorerChartDistribut
renderChart() {
const {
tooManyBuckets,
mlSelectSeverityService,
intl,
} = this.props;
@ -372,7 +371,7 @@ export const ExplorerChartDistribution = injectI18n(class ExplorerChartDistribut
.on('mouseout', () => mlChartTooltipService.hide());
// Update all dots to new positions.
const threshold = mlSelectSeverityService.state.get('threshold');
const threshold = (mlSelectSeverityService.initiliazed) ? mlSelectSeverityService.state.get('threshold') : SEVERITY_OPTIONS[0];
dots.attr('cx', d => lineChartXScale(d.date))
.attr('cy', d => lineChartYScale(d[CHART_Y_ATTRIBUTE]))
.attr('class', (d) => {

View file

@ -41,6 +41,7 @@ import { LoadingIndicator } from '../../components/loading_indicator/loading_ind
import { mlEscape } from '../../util/string_utils';
import { mlFieldFormatService } from '../../services/field_format_service';
import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service';
import { mlSelectSeverityService, SEVERITY_OPTIONS } from '../../components/controls/select_severity/select_severity';
import { injectI18n } from '@kbn/i18n/react';
@ -51,7 +52,6 @@ export const ExplorerChartSingleMetric = injectI18n(class ExplorerChartSingleMet
static propTypes = {
tooManyBuckets: PropTypes.bool,
seriesConfig: PropTypes.object,
mlSelectSeverityService: PropTypes.object.isRequired
}
componentDidMount() {
@ -65,7 +65,6 @@ export const ExplorerChartSingleMetric = injectI18n(class ExplorerChartSingleMet
renderChart() {
const {
tooManyBuckets,
mlSelectSeverityService,
intl,
} = this.props;
@ -286,7 +285,7 @@ export const ExplorerChartSingleMetric = injectI18n(class ExplorerChartSingleMet
.on('mouseout', () => mlChartTooltipService.hide());
// Update all dots to new positions.
const threshold = mlSelectSeverityService.state.get('threshold');
const threshold = (mlSelectSeverityService.initiliazed) ? mlSelectSeverityService.state.get('threshold') : SEVERITY_OPTIONS[0];
dots.attr('cx', d => lineChartXScale(d.date))
.attr('cy', d => lineChartYScale(d.value))
.attr('class', (d) => {

View file

@ -4,7 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import PropTypes from 'prop-types';
import $ from 'jquery';
import React from 'react';
import {
@ -54,7 +55,6 @@ function getChartId(series) {
function ExplorerChartContainer({
series,
tooManyBuckets,
mlSelectSeverityService,
wrapLabel
}) {
const {
@ -134,7 +134,6 @@ function ExplorerChartContainer({
<ExplorerChartDistribution
tooManyBuckets={tooManyBuckets}
seriesConfig={series}
mlSelectSeverityService={mlSelectSeverityService}
/>
);
}
@ -142,7 +141,6 @@ function ExplorerChartContainer({
<ExplorerChartSingleMetric
tooManyBuckets={tooManyBuckets}
seriesConfig={series}
mlSelectSeverityService={mlSelectSeverityService}
/>
);
})()}
@ -151,37 +149,44 @@ function ExplorerChartContainer({
}
// Flex layout wrapper for all explorer charts
export function ExplorerChartsContainer({
chartsPerRow,
seriesToPlot,
tooManyBuckets,
mlSelectSeverityService
}) {
// <EuiFlexGrid> 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;
export class ExplorerChartsContainer extends React.Component {
componentDidMount() {
// Create a div for the tooltip.
$('.ml-explorer-charts-tooltip').remove();
$('body').append('<div class="ml-explorer-tooltip ml-explorer-charts-tooltip" style="opacity:0; display: none;">');
}
const wrapLabel = seriesToPlot.some((series) => isLabelLengthAboveThreshold(series));
componentWillUnmount() {
// Remove div for the tooltip.
$('.ml-explorer-charts-tooltip').remove();
}
return (
<EuiFlexGrid columns={chartsColumns}>
{(seriesToPlot.length > 0) && seriesToPlot.map((series) => (
<EuiFlexItem key={getChartId(series)} className="ml-explorer-chart-container" style={{ minWidth: chartsWidth }}>
<ExplorerChartContainer
series={series}
tooManyBuckets={tooManyBuckets}
mlSelectSeverityService={mlSelectSeverityService}
wrapLabel={wrapLabel}
/>
</EuiFlexItem>
))}
</EuiFlexGrid>
);
render() {
const {
chartsPerRow,
seriesToPlot,
tooManyBuckets
} = this.props;
// <EuiFlexGrid> 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));
return (
<EuiFlexGrid columns={chartsColumns}>
{(seriesToPlot.length > 0) && seriesToPlot.map((series) => (
<EuiFlexItem key={getChartId(series)} className="ml-explorer-chart-container" style={{ minWidth: chartsWidth }}>
<ExplorerChartContainer
series={series}
tooManyBuckets={tooManyBuckets}
wrapLabel={wrapLabel}
/>
</EuiFlexItem>
))}
</EuiFlexGrid>
);
}
}
ExplorerChartsContainer.propTypes = {
seriesToPlot: PropTypes.array.isRequired,
tooManyBuckets: PropTypes.bool.isRequired,
mlSelectSeverityService: PropTypes.object.isRequired,
mlChartTooltipService: PropTypes.object.isRequired
};

View file

@ -22,17 +22,17 @@ jest.mock('../../services/field_format_service', () => ({
getFieldFormat: jest.fn()
}
}));
jest.mock('ui/chrome', () => ({
getBasePath: (path) => path,
getUiSettingsClient: () => ({
get: () => null
}),
jest.mock('../../services/job_service', () => ({
mlJobService: {
getJob: jest.fn()
}
}));
// The mocks for ui/chrome and ui/timefilter are copied from charts_utils.test.js
// TODO: Refactor the involved tests to avoid this duplication
jest.mock('ui/chrome',
() => ({
addBasePath: () => '/api/ml',
getBasePath: () => {
return '<basepath>';
},
@ -66,19 +66,11 @@ import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers';
import React from 'react';
import { chartLimits } from '../../util/chart_utils';
import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service';
import { getDefaultChartsData } from './explorer_charts_container_service';
import { ExplorerChartsContainer } from './explorer_charts_container';
describe('ExplorerChartsContainer', () => {
const mlSelectSeverityServiceMock = {
state: {
get: () => ({
val: ''
})
}
};
const mockedGetBBox = { x: 0, y: -11.5, width: 12.1875, height: 14.5 };
const originalGetBBox = SVGElement.prototype.getBBox;
const rareChartUniqueString = 'y-axis event distribution split by';
@ -86,29 +78,23 @@ describe('ExplorerChartsContainer', () => {
afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox));
test('Minimal Initialization', () => {
const wrapper = shallowWithIntl(<ExplorerChartsContainer
seriesToPlot={[]}
chartsPerRow={1}
tooManyBuckets={false}
mlSelectSeverityService={mlSelectSeverityServiceMock}
mlChartTooltipService={mlChartTooltipService}
/>);
const wrapper = shallowWithIntl(<ExplorerChartsContainer {...getDefaultChartsData()} />);
expect(wrapper.html()).toBe('<div class=\"euiFlexGrid euiFlexGrid--gutterLarge euiFlexGrid--wrap euiFlexGrid--responsive\"></div>');
});
test('Initialization with chart data', () => {
const wrapper = mountWithIntl(<ExplorerChartsContainer
seriesToPlot={[{
const props = {
...getDefaultChartsData(),
seriesToPlot: [{
...seriesConfig,
chartData,
chartLimits: chartLimits(chartData)
}]}
chartsPerRow={1}
tooManyBuckets={false}
mlSelectSeverityService={mlSelectSeverityServiceMock}
mlChartTooltipService={mlChartTooltipService}
/>);
}],
chartsPerRow: 1,
tooManyBuckets: false
};
const wrapper = mountWithIntl(<ExplorerChartsContainer {...props} />);
// We test child components with snapshots separately
// so we just do some high level sanity check here.
@ -119,17 +105,17 @@ describe('ExplorerChartsContainer', () => {
});
test('Initialization with rare detector', () => {
const wrapper = mountWithIntl(<ExplorerChartsContainer
seriesToPlot={[{
const props = {
...getDefaultChartsData(),
seriesToPlot: [{
...seriesConfigRare,
chartData,
chartLimits: chartLimits(chartData)
}]}
chartsPerRow={1}
tooManyBuckets={false}
mlSelectSeverityService={mlSelectSeverityServiceMock}
mlChartTooltipService={mlChartTooltipService}
/>);
}],
chartsPerRow: 1,
tooManyBuckets: false
};
const wrapper = mountWithIntl(<ExplorerChartsContainer {...props} />);
// We test child components with snapshots separately
// so we just do some high level sanity check here.

View file

@ -1,80 +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.
*/
/*
* AngularJS directive for rendering the containing div for the charts of
* anomalies in the raw data in the Machine Learning Explorer dashboard.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import $ from 'jquery';
import { ExplorerChartsContainer } from './explorer_charts_container';
import { explorerChartsContainerServiceFactory } from './explorer_charts_container_service';
import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service';
import { mlExplorerDashboardService } from '../explorer_dashboard_service';
import { I18nProvider } from '@kbn/i18n/react';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
module.directive('mlExplorerChartsContainer', function (
mlSelectSeverityService
) {
function link(scope, element) {
const anomalyDataChangeListener = explorerChartsContainerServiceFactory(
mlSelectSeverityService,
updateComponent,
$('.explorer-charts')
);
mlExplorerDashboardService.anomalyDataChange.watch(anomalyDataChangeListener);
scope.$on('$destroy', () => {
mlExplorerDashboardService.anomalyDataChange.unwatch(anomalyDataChangeListener);
});
// Create a div for the tooltip.
$('.ml-explorer-charts-tooltip').remove();
$('body').append('<div class="ml-explorer-tooltip ml-explorer-charts-tooltip" style="opacity:0; display: none;">');
element.on('$destroy', function () {
scope.$destroy();
});
function updateComponent(data) {
const props = {
chartsPerRow: data.chartsPerRow,
seriesToPlot: data.seriesToPlot,
// convert truthy/falsy value to Boolean
tooManyBuckets: !!data.tooManyBuckets,
mlSelectSeverityService,
mlChartTooltipService
};
ReactDOM.render(
<I18nProvider>
{React.createElement(ExplorerChartsContainer, props)}
</I18nProvider>,
element[0]
);
}
mlExplorerDashboardService.chartsInitDone.changed();
}
return {
restrict: 'E',
replace: false,
scope: false,
link: link
};
});

View file

@ -4,8 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
/*
* Service for the container for the anomaly charts in the
* Machine Learning Explorer dashboard.
@ -23,47 +21,52 @@ import {
import { isTimeSeriesViewDetector } from '../../../common/util/job_utils';
import { mlResultsService } from '../../services/results_service';
import { mlJobService } from '../../services/job_service';
import {
mlSelectSeverityService,
SEVERITY_OPTIONS,
} from '../../components/controls/select_severity/select_severity';
import { getChartContainerWidth } from './legacy_utils';
import { CHART_TYPE } from '../explorer_constants';
export function explorerChartsContainerServiceFactory(
mlSelectSeverityService,
callback,
$chartContainer
) {
export function getDefaultChartsData() {
return {
chartsPerRow: 1,
seriesToPlot: [],
// default values, will update on every re-render
tooManyBuckets: false,
timeFieldName: 'timestamp'
};
}
export function explorerChartsContainerServiceFactory(callback) {
const FUNCTION_DESCRIPTIONS_TO_PLOT = ['mean', 'min', 'max', 'sum', 'count', 'distinct_count', 'median', 'rare'];
const CHART_MAX_POINTS = 500;
const ANOMALIES_MAX_RESULTS = 500;
const MAX_SCHEDULED_EVENTS = 10; // Max number of scheduled events displayed per bucket.
const MAX_SCHEDULED_EVENTS = 10; // Max number of scheduled events displayed per bucket.
const ML_TIME_FIELD_NAME = 'timestamp';
const USE_OVERALL_CHART_LIMITS = false;
const MAX_CHARTS_PER_ROW = 4;
function getDefaultData() {
return {
seriesToPlot: [],
// default values, will update on every re-render
tooManyBuckets: false,
timeFieldName: 'timestamp'
};
}
callback(getDefaultData());
callback(getDefaultChartsData());
let requestCount = 0;
const anomalyDataChangeListener = function (anomalyRecords, earliestMs, latestMs) {
const anomalyDataChange = function (anomalyRecords, earliestMs, latestMs) {
const newRequestCount = ++requestCount;
requestCount = newRequestCount;
const data = getDefaultData();
const data = getDefaultChartsData();
const threshold = (mlSelectSeverityService.initalized)
? mlSelectSeverityService.state.get('threshold')
: SEVERITY_OPTIONS[0];
const threshold = mlSelectSeverityService.state.get('threshold');
const filteredRecords = anomalyRecords.filter((record) => {
return Number(record.record_score) >= threshold.val;
});
const allSeriesRecords = processRecordsForDisplay(filteredRecords);
// Calculate the number of charts per row, depending on the width available, to a max of 4.
const chartsContainerWidth = Math.floor($chartContainer.width());
const chartsContainerWidth = getChartContainerWidth();
let chartsPerRow = Math.min(Math.max(Math.floor(chartsContainerWidth / 550), 1), MAX_CHARTS_PER_ROW);
if (allSeriesRecords.length === 1) {
chartsPerRow = 1;
@ -573,6 +576,6 @@ export function explorerChartsContainerServiceFactory(
};
}
return anomalyDataChangeListener;
return anomalyDataChange;
}

View file

@ -81,6 +81,10 @@ jest.mock('../../util/string_utils', () => ({
mlEscape(d) { return d; }
}));
jest.mock('./legacy_utils', () => ({
getChartContainerWidth() { return 1140; }
}));
jest.mock('ui/chrome', () => ({
getBasePath: (path) => path,
getUiSettingsClient: () => ({
@ -88,35 +92,14 @@ jest.mock('ui/chrome', () => ({
}),
}));
const mockMlSelectSeverityService = {
state: {
get() { return { display: 'warning', val: 0 }; }
}
};
const mockChartContainer = {
width() { return 1140; }
};
function mockGetDefaultData() {
return {
seriesToPlot: [],
tooManyBuckets: false,
timeFieldName: 'timestamp'
};
}
import { explorerChartsContainerServiceFactory } from './explorer_charts_container_service';
import { explorerChartsContainerServiceFactory, getDefaultChartsData } from './explorer_charts_container_service';
describe('explorerChartsContainerService', () => {
test('Initialize factory', (done) => {
explorerChartsContainerServiceFactory(
mockMlSelectSeverityService,
callback
);
explorerChartsContainerServiceFactory(callback);
function callback(data) {
expect(data).toEqual(mockGetDefaultData());
expect(data).toEqual(getDefaultChartsData());
done();
}
});
@ -125,17 +108,13 @@ describe('explorerChartsContainerService', () => {
// callback will be called multiple times.
// the callbackData array contains the expected data values for each consecutive call.
const callbackData = [];
callbackData.push(mockGetDefaultData());
callbackData.push(getDefaultChartsData());
callbackData.push({
...mockGetDefaultData(),
...getDefaultChartsData(),
chartsPerRow: 2
});
const anomalyDataChangeListener = explorerChartsContainerServiceFactory(
mockMlSelectSeverityService,
callback,
mockChartContainer
);
const anomalyDataChangeListener = explorerChartsContainerServiceFactory(callback);
anomalyDataChangeListener(
[],
@ -159,11 +138,7 @@ describe('explorerChartsContainerService', () => {
let callbackCount = 0;
const expectedTestCount = 3;
const anomalyDataChangeListener = explorerChartsContainerServiceFactory(
mockMlSelectSeverityService,
callback,
mockChartContainer
);
const anomalyDataChangeListener = explorerChartsContainerServiceFactory(callback);
anomalyDataChangeListener(
mockAnomalyChartRecords,
@ -184,11 +159,7 @@ describe('explorerChartsContainerService', () => {
let callbackCount = 0;
const expectedTestCount = 3;
const anomalyDataChangeListener = explorerChartsContainerServiceFactory(
mockMlSelectSeverityService,
callback,
mockChartContainer
);
const anomalyDataChangeListener = explorerChartsContainerServiceFactory(callback);
const mockAnomalyChartRecordsClone = _.cloneDeep(mockAnomalyChartRecords).map((d) => {
d.job_id = 'mock-job-id-distribution';
@ -228,11 +199,7 @@ describe('explorerChartsContainerService', () => {
let callbackCount = 0;
const expectedTestCount = 3;
const anomalyDataChangeListener = explorerChartsContainerServiceFactory(
mockMlSelectSeverityService,
callback,
mockChartContainer
);
const anomalyDataChangeListener = explorerChartsContainerServiceFactory(callback);
const mockAnomalyChartRecordsClone = _.cloneDeep(mockAnomalyChartRecords);
mockAnomalyChartRecordsClone[1].partition_field_value = 'AAL.';

View file

@ -4,7 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
import './explorer_charts_container_directive.js';
import 'plugins/ml/components/chart_tooltip';

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
// This file includes utils which should eventuelly become obsolete once Anomaly Explorer
// is fully migrated to React. Their purpose is to retain functionality while we migrate step by step.
export function getChartContainerWidth() {
const chartContainer = document.querySelector('.explorer-charts');
return Math.floor(chartContainer && chartContainer.clientWidth || 0);
}

View file

@ -20,7 +20,6 @@ import moment from 'moment-timezone';
import 'plugins/ml/components/annotations_table';
import 'plugins/ml/components/anomalies_table';
import 'plugins/ml/components/controls';
import 'plugins/ml/components/influencers_list';
import 'plugins/ml/components/job_select_list';
import { FilterBarQueryFilterProvider } from 'ui/filter_bar/query_filter';
@ -43,6 +42,7 @@ import { JobSelectServiceProvider } from 'plugins/ml/components/job_select_list/
import { isTimeSeriesViewDetector } from 'plugins/ml/../common/util/job_utils';
import { timefilter } from 'ui/timefilter';
import { formatHumanReadableDateTime } from '../util/date_utils';
import { explorerChartsContainerServiceFactory, getDefaultChartsData } from './explorer_charts/explorer_charts_container_service';
import {
DRAG_SELECT_ACTION,
SWIMLANE_DEFAULT_LIMIT,
@ -53,7 +53,6 @@ import {
ANOMALIES_TABLE_DEFAULT_QUERY_SIZE
} from '../../common/constants/search';
// TODO Fully support Annotations in Anomaly Explorer
import chrome from 'ui/chrome';
const mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false);
@ -80,7 +79,6 @@ function getDefaultViewBySwimlaneData() {
};
}
module.controller('MlExplorerController', function (
$scope,
$timeout,
@ -95,6 +93,7 @@ module.controller('MlExplorerController', function (
$scope.annotationsData = [];
$scope.anomalyChartRecords = [];
$scope.chartsData = getDefaultChartsData();
$scope.timeFieldName = 'timestamp';
$scope.loading = true;
timefilter.enableTimeRangeSelector();
@ -122,6 +121,17 @@ module.controller('MlExplorerController', function (
let skipCellClicks = true;
$scope.queryFilters = [];
const anomalyDataChange = explorerChartsContainerServiceFactory((data) => {
$scope.chartsData = {
...getDefaultChartsData(),
chartsPerRow: data.chartsPerRow,
seriesToPlot: data.seriesToPlot,
// convert truthy/falsy value to Boolean
tooManyBuckets: !!data.tooManyBuckets,
};
$scope.$applyAsync();
});
const dragSelect = new DragSelect({
selectables: document.getElementsByClassName('sl-cell'),
callback(elements) {
@ -164,16 +174,7 @@ module.controller('MlExplorerController', function (
$scope.viewBySwimlaneOptions = [];
$scope.viewBySwimlaneData = getDefaultViewBySwimlaneData();
let isChartsContainerInitialized = false;
let chartsCallback = () => {};
function initializeAfterChartsContainerDone() {
if (isChartsContainerInitialized === false) {
chartsCallback();
}
isChartsContainerInitialized = true;
}
$scope.viewBySwimlaneDataLoading = false;
$scope.initializeVis = function () {
// Initialize the AppState in which to store filters.
@ -203,7 +204,6 @@ module.controller('MlExplorerController', function (
});
mlExplorerDashboardService.init();
mlExplorerDashboardService.chartsInitDone.watch(initializeAfterChartsContainerDone);
};
// create new job objects based on standard job config objects
@ -366,38 +366,16 @@ module.controller('MlExplorerController', function (
$scope.appState.save();
}
function getSwimlaneData(swimlaneType) {
switch (swimlaneType) {
case SWIMLANE_TYPE.OVERALL:
return $scope.overallSwimlaneData;
case SWIMLANE_TYPE.VIEW_BY:
return $scope.viewBySwimlaneData;
}
}
function mapScopeToSwimlaneProps(swimlaneType) {
return {
chartWidth: $scope.swimlaneWidth,
MlTimeBuckets: TimeBuckets,
swimlaneData: getSwimlaneData(swimlaneType),
swimlaneType,
selection: $scope.appState.mlExplorerSwimlane
};
}
function redrawOnResize() {
$scope.swimlaneWidth = getSwimlaneContainerWidth();
$scope.$apply();
mlExplorerDashboardService.swimlaneDataChange.changed(mapScopeToSwimlaneProps(SWIMLANE_TYPE.OVERALL));
mlExplorerDashboardService.swimlaneDataChange.changed(mapScopeToSwimlaneProps(SWIMLANE_TYPE.VIEW_BY));
if (
mlCheckboxShowChartsService.state.get('showCharts') &&
$scope.anomalyChartRecords.length > 0
) {
const timerange = getSelectionTimeRange($scope.cellData);
mlExplorerDashboardService.anomalyDataChange.changed(
anomalyDataChange(
$scope.anomalyChartRecords, timerange.earliestMs, timerange.latestMs
);
}
@ -418,6 +396,7 @@ module.controller('MlExplorerController', function (
$scope.viewBySwimlaneData.laneLabels &&
$scope.viewBySwimlaneData.laneLabels.length > 0
);
$scope.$applyAsync();
}
function getSelectionTimeRange(cellData) {
@ -465,12 +444,12 @@ module.controller('MlExplorerController', function (
// an update of the viewby swimlanes. If we'd just ignored click events
// during the loading, we could miss programmatically triggered events like
// those coming via AppState when a selection is part of the URL.
const swimlaneCellClickListenerQueue = [];
const swimlaneCellClickQueue = [];
// Listener for click events in the swimlane to load corresponding anomaly data.
const swimlaneCellClickListener = function (cellData) {
$scope.swimlaneCellClick = function (cellData) {
if (skipCellClicks === true) {
swimlaneCellClickListenerQueue.push(cellData);
swimlaneCellClickQueue.push(cellData);
return;
}
@ -492,7 +471,6 @@ module.controller('MlExplorerController', function (
updateExplorer();
}
};
mlExplorerDashboardService.swimlaneCellClick.watch(swimlaneCellClickListener);
const checkboxShowChartsListener = function () {
const showCharts = mlCheckboxShowChartsService.state.get('showCharts');
@ -500,7 +478,7 @@ module.controller('MlExplorerController', function (
updateExplorer();
} else {
const timerange = getSelectionTimeRange($scope.cellData);
mlExplorerDashboardService.anomalyDataChange.changed(
anomalyDataChange(
[], timerange.earliestMs, timerange.latestMs
);
}
@ -511,7 +489,7 @@ module.controller('MlExplorerController', function (
const showCharts = mlCheckboxShowChartsService.state.get('showCharts');
if (showCharts && $scope.cellData !== undefined) {
const timerange = getSelectionTimeRange($scope.cellData);
mlExplorerDashboardService.anomalyDataChange.changed(
anomalyDataChange(
$scope.anomalyChartRecords, timerange.earliestMs, timerange.latestMs
);
}
@ -540,13 +518,11 @@ module.controller('MlExplorerController', function (
$scope.$on('$destroy', () => {
dragSelect.stop();
mlCheckboxShowChartsService.state.unwatch(checkboxShowChartsListener);
mlExplorerDashboardService.swimlaneCellClick.unwatch(swimlaneCellClickListener);
mlExplorerDashboardService.swimlaneRenderDone.unwatch(swimlaneRenderDoneListener);
mlSelectSeverityService.state.unwatch(anomalyChartsSeverityListener);
mlSelectIntervalService.state.unwatch(tableControlsListener);
mlSelectSeverityService.state.unwatch(tableControlsListener);
mlSelectLimitService.state.unwatch(swimlaneLimitListener);
mlExplorerDashboardService.chartsInitDone.unwatch(initializeAfterChartsContainerDone);
delete $scope.cellData;
refreshWatcher.cancel();
$(window).off('resize', jqueryRedrawOnResize);
@ -582,7 +558,7 @@ module.controller('MlExplorerController', function (
console.log('Explorer anomaly charts data set:', $scope.anomalyChartRecords);
if (mlCheckboxShowChartsService.state.get('showCharts')) {
mlExplorerDashboardService.anomalyDataChange.changed(
anomalyDataChange(
$scope.anomalyChartRecords, earliestMs, latestMs
);
}
@ -807,7 +783,6 @@ module.controller('MlExplorerController', function (
// Tell the result components directives to render.
// Need to use $timeout to ensure the broadcast happens after the child scope is updated with the new data.
$timeout(() => {
mlExplorerDashboardService.swimlaneDataChange.changed(mapScopeToSwimlaneProps(SWIMLANE_TYPE.OVERALL));
loadViewBySwimlane([]);
}, 0);
});
@ -837,6 +812,7 @@ module.controller('MlExplorerController', function (
function loadViewBySwimlane(fieldValues) {
// reset the swimlane data to avoid flickering where the old dataset would briefly show up.
$scope.viewBySwimlaneData = getDefaultViewBySwimlaneData();
$scope.viewBySwimlaneDataLoading = true;
skipCellClicks = true;
// finish() function, called after each data set has been loaded and processed.
@ -860,20 +836,18 @@ module.controller('MlExplorerController', function (
}
}
$scope.viewBySwimlaneDataLoading = false;
skipCellClicks = false;
console.log('Explorer view by swimlane data set:', $scope.viewBySwimlaneData);
if (swimlaneCellClickListenerQueue.length > 0) {
const cellData = swimlaneCellClickListenerQueue.pop();
swimlaneCellClickListenerQueue.length = 0;
swimlaneCellClickListener(cellData);
if (swimlaneCellClickQueue.length > 0) {
const cellData = swimlaneCellClickQueue.pop();
swimlaneCellClickQueue.length = 0;
$scope.swimlaneCellClick(cellData);
return;
}
// Fire event to indicate swimlane data has changed.
// Need to use $timeout to ensure this happens after the child scope is updated with the new data.
setShowViewBySwimlane();
$timeout(() => {
mlExplorerDashboardService.swimlaneDataChange.changed(mapScopeToSwimlaneProps(SWIMLANE_TYPE.VIEW_BY));
}, 0);
}
if (
@ -1068,13 +1042,7 @@ module.controller('MlExplorerController', function (
await loadAnnotationsTableData();
$timeout(() => {
if ($scope.overallSwimlaneData !== undefined) {
mlExplorerDashboardService.swimlaneDataChange.changed(mapScopeToSwimlaneProps(SWIMLANE_TYPE.OVERALL));
}
if ($scope.viewBySwimlaneData !== undefined) {
mlExplorerDashboardService.swimlaneDataChange.changed(mapScopeToSwimlaneProps(SWIMLANE_TYPE.VIEW_BY));
}
mlExplorerDashboardService.anomalyDataChange.changed($scope.anomalyChartRecords || [], timerange.earliestMs, timerange.latestMs);
anomalyDataChange($scope.anomalyChartRecords || [], timerange.earliestMs, timerange.latestMs);
if (cellData !== undefined && cellData.fieldName === undefined) {
// Click is in one of the cells in the Overall swimlane - reload the 'view by' swimlane
@ -1093,11 +1061,7 @@ module.controller('MlExplorerController', function (
}, 0);
}
if (isChartsContainerInitialized) {
finish();
} else {
chartsCallback = finish;
}
finish();
}
function clearSelectedAnomalies() {

View file

@ -20,19 +20,12 @@ function mlExplorerDashboardServiceFactory() {
const listenerFactory = listenerFactoryProvider();
const dragSelect = service.dragSelect = listenerFactory();
const swimlaneCellClick = service.swimlaneCellClick = listenerFactory();
const swimlaneDataChange = service.swimlaneDataChange = listenerFactory();
const swimlaneRenderDone = service.swimlaneRenderDone = listenerFactory();
const chartsInitDone = service.chartsInitDone = listenerFactory();
service.anomalyDataChange = listenerFactory();
service.init = function () {
// Clear out any old listeners.
dragSelect.unwatchAll();
swimlaneCellClick.unwatchAll();
swimlaneDataChange.unwatchAll();
swimlaneRenderDone.unwatchAll();
chartsInitDone.unwatchAll();
};
return service;

View file

@ -0,0 +1,95 @@
/*
* 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.
*/
/*
* AngularJS directive wrapper for rendering Anomaly Explorer's React component.
*/
import { pick } from 'lodash';
import React from 'react';
import ReactDOM from 'react-dom';
import { Explorer } from './explorer';
import { IntervalHelperProvider } from 'plugins/ml/util/ml_time_buckets';
import { SWIMLANE_TYPE } from './explorer_constants';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
import { I18nProvider } from '@kbn/i18n/react';
module.directive('mlExplorerReactWrapper', function (Private) {
const TimeBuckets = Private(IntervalHelperProvider);
function link(scope, element) {
function getSwimlaneData(swimlaneType) {
switch (swimlaneType) {
case SWIMLANE_TYPE.OVERALL:
return scope.overallSwimlaneData;
case SWIMLANE_TYPE.VIEW_BY:
return scope.viewBySwimlaneData;
}
}
function mapScopeToSwimlaneProps(swimlaneType) {
return {
chartWidth: scope.swimlaneWidth,
MlTimeBuckets: TimeBuckets,
swimlaneCellClick: scope.swimlaneCellClick,
swimlaneData: getSwimlaneData(swimlaneType),
swimlaneType,
selection: scope.appState.mlExplorerSwimlane,
};
}
function render() {
const props = pick(scope, [
'annotationsData',
'anomalyChartRecords',
'chartsData',
'hasResults',
'influencers',
'jobs',
'loading',
'noInfluencersConfigured',
'setSwimlaneSelectActive',
'setSwimlaneViewBy',
'showViewBySwimlane',
'swimlaneViewByFieldName',
'tableData',
'viewByLoadedForTimeFormatted',
'viewBySwimlaneDataLoading',
'viewBySwimlaneOptions',
]);
props.swimlaneOverall = mapScopeToSwimlaneProps(SWIMLANE_TYPE.OVERALL);
props.swimlaneViewBy = mapScopeToSwimlaneProps(SWIMLANE_TYPE.VIEW_BY);
ReactDOM.render(
<I18nProvider>{React.createElement(Explorer, props)}</I18nProvider>,
element[0]
);
}
render();
scope.$watch(() => {
render();
});
element.on('$destroy', () => {
ReactDOM.unmountComponentAtNode(element[0]);
scope.$destroy();
});
}
return {
scope: false,
link,
};
});

View file

@ -32,6 +32,7 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
static propTypes = {
chartWidth: PropTypes.number.isRequired,
MlTimeBuckets: PropTypes.func.isRequired,
swimlaneCellClick: PropTypes.func.isRequired,
swimlaneData: PropTypes.shape({
laneLabels: PropTypes.array.isRequired
}).isRequired,
@ -124,6 +125,7 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
selectCell(cellsToSelect, { laneLabels, bucketScore, times }) {
const {
selection,
swimlaneCellClick,
swimlaneData,
swimlaneType
} = this.props;
@ -156,7 +158,7 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
}
if (triggerNewSelection === false) {
mlExplorerDashboardService.swimlaneCellClick.changed({});
swimlaneCellClick({});
return;
}
@ -166,7 +168,7 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
times: d3.extent(times),
type: swimlaneType
};
mlExplorerDashboardService.swimlaneCellClick.changed(cellData);
swimlaneCellClick(cellData);
}
highlightSelection(cellsToSelect, laneLabels, times) {
@ -219,6 +221,7 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
const {
chartWidth,
MlTimeBuckets,
swimlaneCellClick,
swimlaneData,
swimlaneType,
selection,
@ -307,7 +310,7 @@ export const ExplorerSwimlane = injectI18n(class ExplorerSwimlane extends React.
.html(label => mlEscape(label))
.on('click', () => {
if (typeof selection.selectedLanes !== 'undefined') {
mlExplorerDashboardService.swimlaneCellClick.changed({});
swimlaneCellClick({});
}
})
.each(function () {

View file

@ -1,61 +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.
*/
/*
* AngularJS directive wrapper for rendering Anomaly Explorer's ExplorerSwimlane React component.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import { ExplorerSwimlane } from './explorer_swimlane';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
import { mlExplorerDashboardService } from './explorer_dashboard_service';
import { I18nProvider } from '@kbn/i18n/react';
module.directive('mlExplorerSwimlane', function () {
function link(scope, element) {
function swimlaneDataChangeListener(props) {
if (
props.swimlaneType !== scope.swimlaneType ||
props.swimlaneData === undefined ||
props.swimlaneData.earliest === undefined ||
props.swimlaneData.latest === undefined
) {
return;
}
ReactDOM.render(
<I18nProvider>
{React.createElement(ExplorerSwimlane, props)}
</I18nProvider>,
element[0]
);
}
mlExplorerDashboardService.swimlaneDataChange.watch(swimlaneDataChangeListener);
element.on('$destroy', () => {
mlExplorerDashboardService.swimlaneDataChange.unwatch(swimlaneDataChangeListener);
// unmountComponentAtNode() needs to be called so dragSelectListener within
// the ExplorerSwimlane component gets unwatched properly.
ReactDOM.unmountComponentAtNode(element[0]);
scope.$destroy();
});
}
return {
scope: {
swimlaneType: '@'
},
link
};
});

View file

@ -8,7 +8,7 @@
import 'plugins/ml/explorer/explorer_controller';
import 'plugins/ml/explorer/explorer_dashboard_service';
import 'plugins/ml/explorer/explorer_swimlane_directive';
import 'plugins/ml/explorer/explorer_react_wrapper_directive';
import 'plugins/ml/explorer/explorer_charts';
import 'plugins/ml/explorer/select_limit';
import 'plugins/ml/components/job_select_list';

View file

@ -1,44 +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 ngMock from 'ng_mock';
import expect from 'expect.js';
describe('ML - <ml-select-limit>', () => {
let $scope;
let $compile;
beforeEach(ngMock.module('kibana'));
beforeEach(() => {
ngMock.inject(function ($injector) {
$compile = $injector.get('$compile');
const $rootScope = $injector.get('$rootScope');
$scope = $rootScope.$new();
});
});
afterEach(() => {
$scope.$destroy();
});
it('Initialization doesn\'t throw an error', () => {
expect(function () {
$compile('<ml-select-limit />')($scope);
}).to.not.throwError('Not initialized.');
expect($scope.setLimit).to.be.a('function');
expect($scope.limit).to.eql({ display: '10', val: 10 });
expect($scope.limitOptions).to.eql([
{ display: '5', val: 5 },
{ display: '10', val: 10 },
{ display: '25', val: 25 },
{ display: '50', val: 50 }
]);
});
});

View file

@ -6,4 +6,4 @@
import './select_limit.js';
import './select_limit_service.js';

View file

@ -1,9 +0,0 @@
<ml-controls-select
identifier="Limit"
label="{{ ::'xpack.ml.explorer.limitLabel' | i18n: {defaultMessage: 'Limit'} }}"
narrow-style="true"
options="limitOptions"
selected="limit"
show-icons="false"
update-fn="setLimit"
/>

View file

@ -7,65 +7,110 @@
/*
* AngularJS directive for rendering a select element with limit levels.
*/
* React component for rendering a select element with limit options.
*/
import PropTypes from 'prop-types';
import { get } from 'lodash';
import React, { Component } from 'react';
import _ from 'lodash';
import {
EuiSelect,
} from '@elastic/eui';
import { stateFactoryProvider } from 'plugins/ml/factories/state_factory';
const optionsMap = {
'5': 5,
'10': 10,
'25': 25,
'50': 50,
};
import template from './select_limit.html';
import 'plugins/ml/components/controls/controls_select';
const LIMIT_OPTIONS = [
{ val: 5, display: '5' },
{ val: 10, display: '10' },
{ val: 25, display: '25' },
{ val: 50, display: '50' },
];
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
function optionValueToLimit(value) {
// Get corresponding limit object with required display and val properties from the specified value.
let limit = LIMIT_OPTIONS.find(opt => (opt.val === value));
module
.service('mlSelectLimitService', function (Private) {
const stateFactory = Private(stateFactoryProvider);
this.state = stateFactory('mlSelectLimit', {
limit: { display: '10', val: 10 }
});
})
.directive('mlSelectLimit', function (mlSelectLimitService) {
return {
restrict: 'E',
template,
link: function (scope, element) {
scope.limitOptions = [
{ display: '5', val: 5 },
{ display: '10', val: 10 },
{ display: '25', val: 25 },
{ display: '50', val: 50 }
];
// Default to 10 if supplied value doesn't map to one of the options.
if (limit === undefined) {
limit = LIMIT_OPTIONS[1];
}
const limitState = mlSelectLimitService.state.get('limit');
const limitValue = _.get(limitState, 'val', 0);
let limitOption = scope.limitOptions.find(d => d.val === limitValue);
if (limitOption === undefined) {
// Attempt to set value in URL which doesn't map to one of the options.
limitOption = scope.limitOptions.find(d => d.val === 10);
}
scope.limit = limitOption;
mlSelectLimitService.state.set('limit', scope.limit);
return limit;
}
scope.setLimit = function (limit) {
if (!_.isEqual(scope.limit, limit)) {
scope.limit = limit;
mlSelectLimitService.state.set('limit', scope.limit).changed();
}
};
// This service will be populated by the corresponding angularjs based one.
export const mlSelectLimitService = {
initialized: false,
state: null
};
function setLimit() {
scope.setLimit(mlSelectLimitService.state.get('limit'));
}
class SelectLimit extends Component {
constructor(props) {
super(props);
mlSelectLimitService.state.watch(setLimit);
// Restore the limit from the state, or default to 10.
if (mlSelectLimitService.initialized) {
this.mlSelectLimitService = mlSelectLimitService;
}
element.on('$destroy', () => {
mlSelectLimitService.state.unwatch(setLimit);
scope.$destroy();
});
}
this.state = {
valueDisplay: LIMIT_OPTIONS[1].display,
};
});
}
componentDidMount() {
// set initial state from service if available
if (this.mlSelectLimitService !== undefined) {
const limitState = this.mlSelectLimitService.state.get('limit');
const limitValue = get(limitState, 'val', 10);
const limit = optionValueToLimit(limitValue);
// set initial selected option equal to limit value
const selectedOption = LIMIT_OPTIONS.find(opt => (opt.val === limit.val));
this.mlSelectLimitService.state.set('limit', limit);
this.setState({ valueDisplay: selectedOption.display, });
}
}
onChange = (e) => {
const valueDisplay = e.target.value;
this.setState({ valueDisplay });
const limit = optionValueToLimit(optionsMap[valueDisplay]);
if (this.mlSelectLimitService !== undefined) {
this.mlSelectLimitService.state.set('limit', limit).changed();
} else {
this.props.onChangeHandler(limit);
}
}
getOptions = () =>
LIMIT_OPTIONS.map(({ display, val }) => ({
value: display,
text: val,
}));
render() {
return (
<EuiSelect
options={this.getOptions()}
onChange={this.onChange}
value={this.state.valueDisplay}
/>
);
}
}
SelectLimit.propTypes = {
onChangeHandler: PropTypes.func,
};
SelectLimit.defaultProps = {
onChangeHandler: () => {},
};
export { SelectLimit };

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { SelectLimit } from './select_limit';
describe('SelectLimit', () => {
test('creates correct initial selected value', () => {
const wrapper = shallow(<SelectLimit/>);
const defaultSelectedValue = wrapper.state().valueDisplay;
expect(defaultSelectedValue).toBe('10');
});
test('state for currently selected value is updated correctly on click', () => {
const wrapper = shallow(<SelectLimit/>);
const defaultSelectedValue = wrapper.state().valueDisplay;
expect(defaultSelectedValue).toBe('10');
wrapper.simulate('change', { target: { value: '25' } });
const updatedSelectedValue = wrapper.state().valueDisplay;
expect(updatedSelectedValue).toBe('25');
});
});

View file

@ -0,0 +1,23 @@
/*
* 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.
*/
/*
* AngularJS service for storing limit values in AppState.
*/
import { stateFactoryProvider } from '../../factories/state_factory';
import { mlSelectLimitService } from './select_limit';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
module.service('mlSelectLimitService', function (Private) {
const stateFactory = Private(stateFactoryProvider);
this.state = mlSelectLimitService.state = stateFactory('mlSelectLimit', {
limit: { display: '10', val: 10 }
});
mlSelectLimitService.initialized = true;
});