From 7f941301755ba0aa910ff35392b50d8eaf8a8d69 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Fri, 12 Jul 2019 12:29:18 -0400 Subject: [PATCH] [ML] NavMenu conversion to React (#40830) * 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 --- .../public/dashboard/dashboard_state.test.ts | 4 + .../ui/public/timefilter/timefilter.d.ts | 4 + x-pack/legacy/plugins/ml/public/app.js | 2 +- .../components/navigation_menu/_index.scss | 1 + .../navigation_menu/_navigation_menu.scss | 7 ++ .../components/navigation_menu/index.ts | 7 ++ .../navigation_menu/navigation_menu.tsx | 44 +++++++ ...navigation_menu_react_wrapper_directive.js | 63 ++++++++++ .../components/navigation_menu/tabs.tsx | 112 ++++++++++++++++++ .../navigation_menu/top_nav/index.ts | 7 ++ .../navigation_menu/top_nav/top_nav.tsx | 108 +++++++++++++++++ .../data_frame/pages/job_management/route.ts | 2 +- .../datavisualizer_controller.js | 18 ++- .../ml/public/explorer/explorer_controller.js | 8 ++ x-pack/legacy/plugins/ml/public/index.scss | 4 +- .../timeseriesexplorer_controller.js | 4 + 16 files changed, 387 insertions(+), 8 deletions(-) create mode 100644 x-pack/legacy/plugins/ml/public/components/navigation_menu/_index.scss create mode 100644 x-pack/legacy/plugins/ml/public/components/navigation_menu/_navigation_menu.scss create mode 100644 x-pack/legacy/plugins/ml/public/components/navigation_menu/index.ts create mode 100644 x-pack/legacy/plugins/ml/public/components/navigation_menu/navigation_menu.tsx create mode 100644 x-pack/legacy/plugins/ml/public/components/navigation_menu/navigation_menu_react_wrapper_directive.js create mode 100644 x-pack/legacy/plugins/ml/public/components/navigation_menu/tabs.tsx create mode 100644 x-pack/legacy/plugins/ml/public/components/navigation_menu/top_nav/index.ts create mode 100644 x-pack/legacy/plugins/ml/public/components/navigation_menu/top_nav/top_nav.tsx diff --git a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_state.test.ts b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_state.test.ts index 88312629da5d92..f2acad44babf65 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_state.test.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_state.test.ts @@ -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' }; diff --git a/src/legacy/ui/public/timefilter/timefilter.d.ts b/src/legacy/ui/public/timefilter/timefilter.d.ts index b032caf684cb16..1a82c3c67eafa1 100644 --- a/src/legacy/ui/public/timefilter/timefilter.d.ts +++ b/src/legacy/ui/public/timefilter/timefilter.d.ts @@ -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; diff --git a/x-pack/legacy/plugins/ml/public/app.js b/x-pack/legacy/plugins/ml/public/app.js index e95aaa98479c08..1ac74e9bb63217 100644 --- a/x-pack/legacy/plugins/ml/public/app.js +++ b/x-pack/legacy/plugins/ml/public/app.js @@ -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'; diff --git a/x-pack/legacy/plugins/ml/public/components/navigation_menu/_index.scss b/x-pack/legacy/plugins/ml/public/components/navigation_menu/_index.scss new file mode 100644 index 00000000000000..5135bba535dd9b --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/components/navigation_menu/_index.scss @@ -0,0 +1 @@ +@import 'navigation_menu' diff --git a/x-pack/legacy/plugins/ml/public/components/navigation_menu/_navigation_menu.scss b/x-pack/legacy/plugins/ml/public/components/navigation_menu/_navigation_menu.scss new file mode 100644 index 00000000000000..08dcbfc014499b --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/components/navigation_menu/_navigation_menu.scss @@ -0,0 +1,7 @@ +.mlNavigationMenu__tab { + padding-bottom: 0; +} + +.mlNavigationMenu__topNav { + padding-top: $euiSizeS; +} diff --git a/x-pack/legacy/plugins/ml/public/components/navigation_menu/index.ts b/x-pack/legacy/plugins/ml/public/components/navigation_menu/index.ts new file mode 100644 index 00000000000000..55fe88ec908695 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/components/navigation_menu/index.ts @@ -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'; diff --git a/x-pack/legacy/plugins/ml/public/components/navigation_menu/navigation_menu.tsx b/x-pack/legacy/plugins/ml/public/components/navigation_menu/navigation_menu.tsx new file mode 100644 index 00000000000000..752b0ec4640aad --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/components/navigation_menu/navigation_menu.tsx @@ -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 = ({ + dateFormat, + disableLinks, + forceRefresh, + showTabs, + tabId, + timeHistory, + timefilter, +}) => ( + + + + + + + {showTabs && } + +); diff --git a/x-pack/legacy/plugins/ml/public/components/navigation_menu/navigation_menu_react_wrapper_directive.js b/x-pack/legacy/plugins/ml/public/components/navigation_menu/navigation_menu_react_wrapper_directive.js new file mode 100644 index 00000000000000..ea075ee504b2d2 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/components/navigation_menu/navigation_menu_react_wrapper_directive.js @@ -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(); + }); diff --git a/x-pack/legacy/plugins/ml/public/components/navigation_menu/tabs.tsx b/x-pack/legacy/plugins/ml/public/components/navigation_menu/tabs.tsx new file mode 100644 index 00000000000000..9323a4c55861ac --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/components/navigation_menu/tabs.tsx @@ -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 = ({ tabId, disableLinks }) => { + const [selectedTabId, setSelectedTabId] = useState(tabId); + function onSelectedTabChanged(id: string) { + moveToSelectedTab(id); + setSelectedTabId(id); + } + + const tabs = getTabs(disableLinks); + + return ( + + {tabs.map((tab: Tab) => ( + 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} + + ))} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/components/navigation_menu/top_nav/index.ts b/x-pack/legacy/plugins/ml/public/components/navigation_menu/top_nav/index.ts new file mode 100644 index 00000000000000..56d6651da73093 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/components/navigation_menu/top_nav/index.ts @@ -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'; diff --git a/x-pack/legacy/plugins/ml/public/components/navigation_menu/top_nav/top_nav.tsx b/x-pack/legacy/plugins/ml/public/components/navigation_menu/top_nav/top_nav.tsx new file mode 100644 index 00000000000000..6103623b90ab6a --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/components/navigation_menu/top_nav/top_nav.tsx @@ -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 = ({ 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 ( + + {(isAutoRefreshSelectorEnabled || isTimeRangeSelectorEnabled) && ( +
+ +
+ )} +
+ ); +}; diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/job_management/route.ts b/x-pack/legacy/plugins/ml/public/data_frame/pages/job_management/route.ts index 6132c74ba0ce30..fb9d325bca0f90 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/job_management/route.ts +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/job_management/route.ts @@ -15,7 +15,7 @@ import { loadIndexPatterns } from '../../../util/index_utils'; // @ts-ignore import { getDataFrameBreadcrumbs } from '../../breadcrumbs'; -const template = ``; +const template = ``; uiRoutes.when('/data_frames/?', { template, diff --git a/x-pack/legacy/plugins/ml/public/datavisualizer/datavisualizer_controller.js b/x-pack/legacy/plugins/ml/public/datavisualizer/datavisualizer_controller.js index 7e32a35692bf0c..b5d174a12019a5 100644 --- a/x-pack/legacy/plugins/ml/public/datavisualizer/datavisualizer_controller.js +++ b/x-pack/legacy/plugins/ml/public/datavisualizer/datavisualizer_controller.js @@ -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 () { diff --git a/x-pack/legacy/plugins/ml/public/explorer/explorer_controller.js b/x-pack/legacy/plugins/ml/public/explorer/explorer_controller.js index 1bdff98b0aec25..00767fd65e3ce9 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/explorer_controller.js +++ b/x-pack/legacy/plugins/ml/public/explorer/explorer_controller.js @@ -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. diff --git a/x-pack/legacy/plugins/ml/public/index.scss b/x-pack/legacy/plugins/ml/public/index.scss index 0332eb5756e453..a8b16b5de56d69 100644 --- a/x-pack/legacy/plugins/ml/public/index.scss +++ b/x-pack/legacy/plugins/ml/public/index.scss @@ -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 diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_controller.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_controller.js index 780899be08617c..53b34a9158d7c9 100644 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_controller.js +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_controller.js @@ -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) {