[ML] NavMenu conversion to React (#40830) (#41018)

* create NavigationMenu and TopNav components

* create timefilter wrapper class

* update timefilter type file

* use new navMenu

* use legacy timefilter for initial conversion

* remove comments

* Move navMenu tabs into separate component

* ensure dataFrame tab selected style works

* update test

* remove top padding when topNav not visible

* add refresh button functionality
This commit is contained in:
Melissa Alvarez 2019-07-12 16:05:55 -04:00 committed by GitHub
parent 215931326b
commit 9ba01c9ab2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 387 additions and 8 deletions

View file

@ -47,6 +47,10 @@ describe('DashboardState', function() {
enableAutoRefreshSelector: jest.fn(),
off: jest.fn(),
on: jest.fn(),
getActiveBounds: () => {},
enableTimeRangeSelector: () => {},
isAutoRefreshSelectorEnabled: true,
isTimeRangeSelectorEnabled: true,
};
const mockIndexPattern: IndexPattern = { id: 'index1', fields: [], title: 'hi' };

View file

@ -33,11 +33,15 @@ export interface Timefilter {
setTime: (timeRange: TimeRange) => void;
setRefreshInterval: (refreshInterval: RefreshInterval) => void;
getRefreshInterval: () => RefreshInterval;
getActiveBounds: () => void;
disableAutoRefreshSelector: () => void;
disableTimeRangeSelector: () => void;
enableAutoRefreshSelector: () => void;
enableTimeRangeSelector: () => void;
off: (event: string, reload: () => void) => void;
on: (event: string, reload: () => void) => void;
isAutoRefreshSelectorEnabled: boolean;
isTimeRangeSelectorEnabled: boolean;
}
export const timefilter: Timefilter;

View file

@ -29,7 +29,7 @@ import 'plugins/ml/components/form_label';
import 'plugins/ml/components/json_tooltip';
import 'plugins/ml/components/tooltip';
import 'plugins/ml/components/confirm_modal';
import 'plugins/ml/components/nav_menu';
import 'plugins/ml/components/navigation_menu';
import 'plugins/ml/components/loading_indicator';
import 'plugins/ml/settings';
import 'plugins/ml/file_datavisualizer';

View file

@ -0,0 +1 @@
@import 'navigation_menu'

View file

@ -0,0 +1,7 @@
.mlNavigationMenu__tab {
padding-bottom: 0;
}
.mlNavigationMenu__topNav {
padding-top: $euiSizeS;
}

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.
*/
import './navigation_menu_react_wrapper_directive';

View file

@ -0,0 +1,44 @@
/*
* 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, { Fragment, FC } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { TopNav } from './top_nav';
import { Tabs } from './tabs';
interface Props {
dateFormat: string;
disableLinks: boolean;
forceRefresh: () => void;
showTabs: boolean;
tabId: string;
timeHistory: any;
timefilter: any;
}
export const NavigationMenu: FC<Props> = ({
dateFormat,
disableLinks,
forceRefresh,
showTabs,
tabId,
timeHistory,
timefilter,
}) => (
<Fragment>
<EuiFlexGroup justifyContent="flexEnd" gutterSize="xs">
<EuiFlexItem grow={false}>
<TopNav
dateFormat={dateFormat}
timeHistory={timeHistory}
timefilter={timefilter}
forceRefresh={forceRefresh}
/>
</EuiFlexItem>
</EuiFlexGroup>
{showTabs && <Tabs tabId={tabId} disableLinks={disableLinks} />}
</Fragment>
);

View file

@ -0,0 +1,63 @@
/*
* 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 ReactDOM from 'react-dom';
import { NavigationMenu } from './navigation_menu';
import { isFullLicense } from '../../license/check_license';
import { timeHistory } from 'ui/timefilter/time_history';
import { uiModules } from 'ui/modules';
import { timefilter } from 'ui/timefilter';
import { Subject } from 'rxjs';
const module = uiModules.get('apps/ml');
import 'ui/directives/kbn_href';
module.directive('mlNavMenu', function (config, mlTimefilterRefreshService) {
return {
restrict: 'E',
transclude: true,
link: function (scope, element, attrs) {
const { name } = attrs;
let showTabs = false;
if (name === 'jobs' ||
name === 'settings' ||
name === 'data_frames' ||
name === 'datavisualizer' ||
name === 'filedatavisualizer' ||
name === 'timeseriesexplorer' ||
name === 'access-denied' ||
name === 'explorer') {
showTabs = true;
}
const props = {
dateFormat: config.get('dateFormat'),
disableLinks: (isFullLicense() === false),
showTabs,
tabId: name,
timeHistory,
timefilter,
forceRefresh: () => mlTimefilterRefreshService.next()
};
ReactDOM.render(React.createElement(NavigationMenu, props),
element[0]
);
element.on('$destroy', () => {
ReactDOM.unmountComponentAtNode(element[0]);
scope.$destroy();
});
}
};
})
.service('mlTimefilterRefreshService', function () {
return new Subject();
});

View file

@ -0,0 +1,112 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, useState } from 'react';
import { EuiTabs, EuiTab } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import chrome from 'ui/chrome';
interface Tab {
id: string;
name: any;
disabled: boolean;
}
interface TestSubjMap {
[key: string]: string;
}
interface Props {
disableLinks: boolean;
tabId: string;
}
function getTabs(disableLinks: boolean): Tab[] {
return [
{
id: 'jobs',
name: i18n.translate('xpack.ml.navMenu.jobManagementTabLinkText', {
defaultMessage: 'Job Management',
}),
disabled: disableLinks,
},
{
id: 'explorer',
name: i18n.translate('xpack.ml.navMenu.anomalyExplorerTabLinkText', {
defaultMessage: 'Anomaly Explorer',
}),
disabled: disableLinks,
},
{
id: 'timeseriesexplorer',
name: i18n.translate('xpack.ml.navMenu.singleMetricViewerTabLinkText', {
defaultMessage: 'Single Metric Viewer',
}),
disabled: disableLinks,
},
{
id: 'data_frames',
name: i18n.translate('xpack.ml.navMenu.dataFrameTabLinkText', {
defaultMessage: 'Data Frames',
}),
disabled: false,
},
{
id: 'datavisualizer',
name: i18n.translate('xpack.ml.navMenu.dataVisualizerTabLinkText', {
defaultMessage: 'Data Visualizer',
}),
disabled: false,
},
{
id: 'settings',
name: i18n.translate('xpack.ml.navMenu.settingsTabLinkText', {
defaultMessage: 'Settings',
}),
disabled: disableLinks,
},
];
}
const TAB_TEST_SUBJ_MAP: TestSubjMap = {
jobs: 'mlTabJobManagement',
explorer: 'mlTabAnomalyExplorer',
timeseriesexplorer: 'mlTabSingleMetricViewer',
data_frames: 'mlTabDataFrames',
datavisualizer: 'mlTabDataVisualizer',
settings: 'mlTabSettings',
};
function moveToSelectedTab(selectedTabId: string) {
window.location.href = `${chrome.getBasePath()}/app/ml#/${selectedTabId}`;
}
export const Tabs: FC<Props> = ({ tabId, disableLinks }) => {
const [selectedTabId, setSelectedTabId] = useState(tabId);
function onSelectedTabChanged(id: string) {
moveToSelectedTab(id);
setSelectedTabId(id);
}
const tabs = getTabs(disableLinks);
return (
<EuiTabs>
{tabs.map((tab: Tab) => (
<EuiTab
className="mlNavigationMenu__tab"
onClick={() => onSelectedTabChanged(tab.id)}
isSelected={tab.id === selectedTabId}
disabled={tab.disabled}
key={`${tab.id}-key`}
data-test-subj={TAB_TEST_SUBJ_MAP[tab.id]}
>
{tab.name}
</EuiTab>
))}
</EuiTabs>
);
};

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 { TopNav } from './top_nav';

View file

@ -0,0 +1,108 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, Fragment, useState, useEffect } from 'react';
import { EuiSuperDatePicker } from '@elastic/eui';
import { TimeHistory, TimeRange } from 'src/legacy/ui/public/timefilter/time_history';
import { Timefilter } from 'ui/timefilter';
interface Props {
dateFormat: string;
forceRefresh: () => void;
timeHistory: TimeHistory;
timefilter: Timefilter;
}
function getRecentlyUsedRanges(timeHistory: TimeHistory): Array<{ start: string; end: string }> {
return timeHistory.get().map(({ from, to }: TimeRange) => {
return {
start: from,
end: to,
};
});
}
export const TopNav: FC<Props> = ({ dateFormat, forceRefresh, timeHistory, timefilter }) => {
const [refreshInterval, setRefreshInterval] = useState(timefilter.getRefreshInterval());
const [time, setTime] = useState(timefilter.getTime());
const [recentlyUsedRanges, setRecentlyUsedRanges] = useState(getRecentlyUsedRanges(timeHistory));
const [isAutoRefreshSelectorEnabled, setIsAutoRefreshSelectorEnabled] = useState(
timefilter.isAutoRefreshSelectorEnabled
);
const [isTimeRangeSelectorEnabled, setIsTimeRangeSelectorEnabled] = useState(
timefilter.isTimeRangeSelectorEnabled
);
useEffect(() => {
timefilter.on('refreshIntervalUpdate', timefilterUpdateListener);
timefilter.on('timeUpdate', timefilterUpdateListener);
timefilter.on('enabledUpdated', timefilterUpdateListener);
return function cleanup() {
timefilter.off('refreshIntervalUpdate', timefilterUpdateListener);
timefilter.off('timeUpdate', timefilterUpdateListener);
timefilter.off('enabledUpdated', timefilterUpdateListener);
};
}, []);
useEffect(() => {
// Force re-render with up-to-date values when isTimeRangeSelectorEnabled/isAutoRefreshSelectorEnabled are changed.
timefilterUpdateListener();
}, [isTimeRangeSelectorEnabled, isAutoRefreshSelectorEnabled]);
function timefilterUpdateListener() {
setTime(timefilter.getTime());
setRefreshInterval(timefilter.getRefreshInterval());
setIsAutoRefreshSelectorEnabled(timefilter.isAutoRefreshSelectorEnabled);
setIsTimeRangeSelectorEnabled(timefilter.isTimeRangeSelectorEnabled);
}
function updateFilter({ start, end }: { start: string; end: string }) {
const newTime = { from: start, to: end };
// Update timefilter for controllers listening for changes
timefilter.setTime(newTime);
setTime(newTime);
setRecentlyUsedRanges(getRecentlyUsedRanges(timeHistory));
}
function updateInterval({
isPaused,
refreshInterval: interval,
}: {
isPaused: boolean;
refreshInterval: number;
}) {
const newInterval = {
pause: isPaused,
value: interval,
};
// Update timefilter for controllers listening for changes
timefilter.setRefreshInterval(newInterval);
// Update state
setRefreshInterval(newInterval);
}
return (
<Fragment>
{(isAutoRefreshSelectorEnabled || isTimeRangeSelectorEnabled) && (
<div className="mlNavigationMenu__topNav">
<EuiSuperDatePicker
start={time.from}
end={time.to}
isPaused={refreshInterval.pause}
isAutoRefreshOnly={!isTimeRangeSelectorEnabled}
refreshInterval={refreshInterval.value}
onTimeChange={updateFilter}
onRefresh={forceRefresh}
onRefreshChange={updateInterval}
recentlyUsedRanges={recentlyUsedRanges}
dateFormat={dateFormat}
/>
</div>
)}
</Fragment>
);
};

View file

@ -15,7 +15,7 @@ import { loadIndexPatterns } from '../../../util/index_utils';
// @ts-ignore
import { getDataFrameBreadcrumbs } from '../../breadcrumbs';
const template = `<ml-nav-menu name="data_frame" /><ml-data-frame-page />`;
const template = `<ml-nav-menu name="data_frames" /><ml-data-frame-page />`;
uiRoutes.when('/data_frames/?', {
template,

View file

@ -52,10 +52,11 @@ import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
module
.controller('MlDataVisualizerViewFields', function ($scope, $timeout, $window, Private, AppState, config) {
.controller('MlDataVisualizerViewFields', function ($injector, $scope, $timeout, $window, Private, AppState, config) {
timefilter.enableTimeRangeSelector();
timefilter.enableAutoRefreshSelector();
const mlTimefilterRefreshService = $injector.get('mlTimefilterRefreshService');
const createSearchItems = Private(SearchItemsProvider);
const {
@ -144,12 +145,21 @@ module
.value();
$scope.indexedFieldTypes = indexedFieldTypes.sort();
// Refresh the data when the time range is altered.
$scope.$listenAndDigestAsync(timefilter, 'fetch', function () {
function refresh() {
$scope.earliest = timefilter.getActiveBounds().min.valueOf();
$scope.latest = timefilter.getActiveBounds().max.valueOf();
loadOverallStats();
}
// Refresh the data when the time range is altered.
$scope.$listenAndDigestAsync(timefilter, 'fetch', function () {
refresh();
});
const timefilterRefreshServiceSub = mlTimefilterRefreshService.subscribe(refresh);
$scope.$on('$destroy', () => {
timefilterRefreshServiceSub.unsubscribe();
});
$scope.submitSearchQuery = function () {

View file

@ -71,6 +71,7 @@ module.controller('MlExplorerController', function (
$injector.get('mlSelectSeverityService');
const mlJobSelectService = $injector.get('mlJobSelectService');
const mlTimefilterRefreshService = $injector.get('mlTimefilterRefreshService');
// $scope should only contain what's actually still necessary for the angular part.
// For the moment that's the job selector and the (hidden) filter bar.
@ -203,6 +204,12 @@ module.controller('MlExplorerController', function (
}
});
const timefilterRefreshServiceSub = mlTimefilterRefreshService.subscribe(() => {
if ($scope.jobSelectionUpdateInProgress === false) {
explorer$.next({ action: EXPLORER_ACTION.RELOAD });
}
});
// Refresh all the data when the time range is altered.
$scope.$listenAndDigestAsync(timefilter, 'fetch', () => {
if ($scope.jobSelectionUpdateInProgress === false) {
@ -287,6 +294,7 @@ module.controller('MlExplorerController', function (
$scope.$on('$destroy', () => {
explorerSubscriber.unsubscribe();
jobSelectServiceSub.unsubscribe();
timefilterRefreshServiceSub.unsubscribe();
refreshWatcher.cancel();
$(window).off('resize', jqueryRedrawOnResize);
// Cancel listening for updates to the global nav state.

View file

@ -40,11 +40,11 @@
@import 'components/form_label/index';
@import 'components/influencers_list/index';
@import 'components/items_grid/index';
@import 'components/job_selector/index'; // TODO: remove above two once react conversion of job selector is done
@import 'components/job_selector/index';
@import 'components/json_tooltip/index'; // SASSTODO: This file overwrites EUI directly
@import 'components/loading_indicator/index'; // SASSTODO: This component should be replaced with EuiLoadingSpinner
@import 'components/messagebar/index';
@import 'components/nav_menu/index';
@import 'components/navigation_menu/index';
@import 'components/rule_editor/index'; // SASSTODO: This file overwrites EUI directly
// Hacks are last so they can overwrite anything above if needed

View file

@ -93,6 +93,7 @@ module.controller('MlTimeSeriesExplorerController', function (
$injector.get('mlSelectIntervalService');
$injector.get('mlSelectSeverityService');
const mlJobSelectService = $injector.get('mlJobSelectService');
const mlTimefilterRefreshService = $injector.get('mlTimefilterRefreshService');
$scope.timeFieldName = 'timestamp';
timefilter.enableTimeRangeSelector();
@ -711,12 +712,15 @@ module.controller('MlTimeSeriesExplorerController', function (
}
});
const timefilterRefreshServiceSub = mlTimefilterRefreshService.subscribe($scope.refresh);
$scope.$on('$destroy', () => {
refreshWatcher.cancel();
intervalSub.unsubscribe();
severitySub.unsubscribe();
annotationsRefreshSub.unsubscribe();
jobSelectServiceSub.unsubscribe();
timefilterRefreshServiceSub.unsubscribe();
});
$scope.$on('contextChartSelected', function (event, selection) { // eslint-disable-line no-unused-vars