From 6e4e1a3befde67fe7670ab3a1649f1952ed9fab8 Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Fri, 24 Mar 2017 13:07:03 -0400 Subject: [PATCH] View edit 5 (#10585) (#10875) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Start at view/edit mode 4 * Revert back to save panel - Get rid of `clone` - Get rid of rename * Move order of top nav component around so no chance of hitting Edit -> Cancel * Update "lose changes" confirmation dialog - Change the string and button text - Set default focus on the cancel button. * Remove DashboardViewMode It was previously used when viewMode wasn’t stored in appState, in order to keep an unsaved dashboard, that was just saved, in Edit mode. This is unnecessary in the latest iteration. * Pull time filter out of dashboard_state * Fix edit controls bug, add more tests, make other tests more stable. * Remove unnecessary logic, unapplied query bug fix, code clean up * Fix bug on filter reloading, add tests & clean up * Fix issue with move and remove icons showing up in expanded panel mode and add tests to catch it. * Fix issue with lose changes not resetting panel state and add tests. * General Clean Up - Merge function only used in one spot inline. Add comments * address code review comments * Stop using static + class to namespace functions Instead, export each function individually. * Show dashed border on maximized panels in edit mode. * Be more methodical about ensuring the modal dialog hides before continuing. * abide by new no unused vars rule --- .../dashboard/__tests__/dashboard_panels.js | 3 +- .../dashboard/__tests__/dashboard_state.js | 91 ++--- .../kibana/public/dashboard/dashboard.html | 11 +- .../kibana/public/dashboard/dashboard.js | 132 +++++-- .../public/dashboard/dashboard_state.js | 340 +++++++++++++++--- .../public/dashboard/dashboard_strings.js | 45 +++ .../public/dashboard/dashboard_view_mode.js | 13 + .../kibana/public/dashboard/grid.js | 22 +- .../kibana/public/dashboard/panel/panel.html | 25 +- .../kibana/public/dashboard/panel/panel.js | 13 + .../kibana/public/dashboard/styles/index.less | 26 +- .../dashboard/top_nav/get_top_nav_config.js | 69 +++- .../public/dashboard/top_nav/options.html | 5 +- .../kibana/public/dashboard/top_nav/save.html | 12 +- .../public/dashboard/top_nav/top_nav_ids.js | 8 + src/ui/public/kbn_top_nav/kbn_top_nav.js | 35 +- src/ui/public/modals/confirm_modal.js | 18 +- src/ui/public/modals/modal_overlay.html | 2 +- src/ui/public/state_management/state.js | 3 +- test/functional/apps/dashboard/_dashboard.js | 60 ++-- .../apps/dashboard/_dashboard_save.js | 3 + .../apps/dashboard/_dashboard_time.js | 19 + test/functional/apps/dashboard/_view_edit.js | 283 +++++++++++++++ test/functional/apps/dashboard/index.js | 1 + .../functional/apps/visualize/_shared_item.js | 1 + test/functional/index.js | 4 +- test/support/page_objects/common.js | 14 + test/support/page_objects/dashboard_page.js | 64 +++- test/support/page_objects/header_page.js | 96 +++-- 29 files changed, 1171 insertions(+), 247 deletions(-) create mode 100644 src/core_plugins/kibana/public/dashboard/dashboard_strings.js create mode 100644 src/core_plugins/kibana/public/dashboard/dashboard_view_mode.js create mode 100644 src/core_plugins/kibana/public/dashboard/top_nav/top_nav_ids.js create mode 100644 test/functional/apps/dashboard/_view_edit.js diff --git a/src/core_plugins/kibana/public/dashboard/__tests__/dashboard_panels.js b/src/core_plugins/kibana/public/dashboard/__tests__/dashboard_panels.js index 2ea064577856f..87903c241bdd5 100644 --- a/src/core_plugins/kibana/public/dashboard/__tests__/dashboard_panels.js +++ b/src/core_plugins/kibana/public/dashboard/__tests__/dashboard_panels.js @@ -14,7 +14,8 @@ describe('dashboard panels', function () { $route.current = { locals: { dash: dashboard - } + }, + params: {} }; $el = angular.element(` diff --git a/src/core_plugins/kibana/public/dashboard/__tests__/dashboard_state.js b/src/core_plugins/kibana/public/dashboard/__tests__/dashboard_state.js index 31f6915ec6582..36dba12db7bc9 100644 --- a/src/core_plugins/kibana/public/dashboard/__tests__/dashboard_state.js +++ b/src/core_plugins/kibana/public/dashboard/__tests__/dashboard_state.js @@ -5,13 +5,14 @@ import { DashboardState } from '../dashboard_state'; describe('DashboardState', function () { let AppState; + let dashboardState; let savedDashboard; let SavedDashboard; let timefilter; let quickTimeRanges; function initDashboardState() { - new DashboardState(savedDashboard, timefilter, true, quickTimeRanges, AppState); + dashboardState = new DashboardState(savedDashboard, AppState); } beforeEach(ngMock.module('kibana')); @@ -23,72 +24,56 @@ describe('DashboardState', function () { savedDashboard = new SavedDashboard(); })); - describe('timefilter', function () { - - describe('when timeRestore is true', function () { - it('syncs quick time', function () { - savedDashboard.timeRestore = true; - savedDashboard.timeFrom = 'now/w'; - savedDashboard.timeTo = 'now/w'; - - timefilter.time.from = '2015-09-19 06:31:44.000'; - timefilter.time.to = '2015-09-29 06:31:44.000'; - timefilter.time.mode = 'absolute'; - - initDashboardState(); - - expect(timefilter.time.mode).to.equal('quick'); - expect(timefilter.time.to).to.equal('now/w'); - expect(timefilter.time.from).to.equal('now/w'); - }); - - it('syncs relative time', function () { - savedDashboard.timeRestore = true; - savedDashboard.timeFrom = 'now-13d'; - savedDashboard.timeTo = 'now'; + describe('syncTimefilterWithDashboard', function () { + it('syncs quick time', function () { + savedDashboard.timeRestore = true; + savedDashboard.timeFrom = 'now/w'; + savedDashboard.timeTo = 'now/w'; - timefilter.time.from = '2015-09-19 06:31:44.000'; - timefilter.time.to = '2015-09-29 06:31:44.000'; - timefilter.time.mode = 'absolute'; + timefilter.time.from = '2015-09-19 06:31:44.000'; + timefilter.time.to = '2015-09-29 06:31:44.000'; + timefilter.time.mode = 'absolute'; - initDashboardState(); + initDashboardState(); + dashboardState.syncTimefilterWithDashboard(timefilter, quickTimeRanges); - expect(timefilter.time.mode).to.equal('relative'); - expect(timefilter.time.to).to.equal('now'); - expect(timefilter.time.from).to.equal('now-13d'); - }); + expect(timefilter.time.mode).to.equal('quick'); + expect(timefilter.time.to).to.equal('now/w'); + expect(timefilter.time.from).to.equal('now/w'); + }); - it('syncs absolute time', function () { - savedDashboard.timeRestore = true; - savedDashboard.timeFrom = '2015-09-19 06:31:44.000'; - savedDashboard.timeTo = '2015-09-29 06:31:44.000'; + it('syncs relative time', function () { + savedDashboard.timeRestore = true; + savedDashboard.timeFrom = 'now-13d'; + savedDashboard.timeTo = 'now'; - timefilter.time.from = 'now/w'; - timefilter.time.to = 'now/w'; - timefilter.time.mode = 'quick'; + timefilter.time.from = '2015-09-19 06:31:44.000'; + timefilter.time.to = '2015-09-29 06:31:44.000'; + timefilter.time.mode = 'absolute'; - initDashboardState(); + initDashboardState(); + dashboardState.syncTimefilterWithDashboard(timefilter, quickTimeRanges); - expect(timefilter.time.mode).to.equal('absolute'); - expect(timefilter.time.to).to.equal(savedDashboard.timeTo); - expect(timefilter.time.from).to.equal(savedDashboard.timeFrom); - }); + expect(timefilter.time.mode).to.equal('relative'); + expect(timefilter.time.to).to.equal('now'); + expect(timefilter.time.from).to.equal('now-13d'); }); - it('is not synced when timeRestore is false', function () { - savedDashboard.timeRestore = false; - savedDashboard.timeFrom = 'now/w'; - savedDashboard.timeTo = 'now/w'; + it('syncs absolute time', function () { + savedDashboard.timeRestore = true; + savedDashboard.timeFrom = '2015-09-19 06:31:44.000'; + savedDashboard.timeTo = '2015-09-29 06:31:44.000'; - timefilter.time.timeFrom = '2015-09-19 06:31:44.000'; - timefilter.time.timeTo = '2015-09-29 06:31:44.000'; - timefilter.time.mode = 'absolute'; + timefilter.time.from = 'now/w'; + timefilter.time.to = 'now/w'; + timefilter.time.mode = 'quick'; initDashboardState(); + dashboardState.syncTimefilterWithDashboard(timefilter, quickTimeRanges); expect(timefilter.time.mode).to.equal('absolute'); - expect(timefilter.time.timeFrom).to.equal('2015-09-19 06:31:44.000'); - expect(timefilter.time.timeTo).to.equal('2015-09-29 06:31:44.000'); + expect(timefilter.time.to).to.equal(savedDashboard.timeTo); + expect(timefilter.time.from).to.equal(savedDashboard.timeFrom); }); }); }); diff --git a/src/core_plugins/kibana/public/dashboard/dashboard.html b/src/core_plugins/kibana/public/dashboard/dashboard.html index 7f3357c6ded70..93036579a4d8c 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard.html +++ b/src/core_plugins/kibana/public/dashboard/dashboard.html @@ -33,6 +33,7 @@ ng-model="model.query" placeholder="Filter..." aria-label="Filter input" + data-test-subj="dashboardQuery" type="text" class="kuiLocalSearchInput" ng-class="{'kuiLocalSearchInput-isInvalid': queryInput.$invalid}" @@ -41,6 +42,7 @@ type="submit" aria-label="Filter Dashboards" class="kuiLocalSearchButton" + data-test-subj="dashboardQueryFilterButton" ng-disabled="queryInput.$invalid" > @@ -55,14 +57,20 @@ -
+

This dashboard is empty. Let's fill it up!

Click the Add button in the menu bar above to add a visualization to the dashboard.
If you haven't setup a visualization yet visit "Visualize" to create your first visualization.

+
+

This dashboard is empty. Let's fill it up!

+

Click the Edit button in the menu bar above to start working on your new dashboard. +

+ This dashboard is empty. Let's fill it up! panel="expandedPanel" is-full-screen-mode="!chrome.getVisible()" is-expanded="true" + dashboard-view-mode="dashboardViewMode" get-vis-click-handler="getFilterBarClickHandler" get-vis-brush-handler="getBrushEvent" save-state="saveState" diff --git a/src/core_plugins/kibana/public/dashboard/dashboard.js b/src/core_plugins/kibana/public/dashboard/dashboard.js index 5042845d407c0..909014a27c37a 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard.js +++ b/src/core_plugins/kibana/public/dashboard/dashboard.js @@ -7,6 +7,10 @@ import chrome from 'ui/chrome'; import 'plugins/kibana/dashboard/grid'; import 'plugins/kibana/dashboard/panel/panel'; +import { getDashboardTitle, getUnsavedChangesWarningMessage } from './dashboard_strings'; +import { DashboardViewMode } from './dashboard_view_mode'; +import { TopNavIds } from './top_nav/top_nav_ids'; +import { ConfirmationButtonTypes } from 'ui/modals/confirm_modal'; import dashboardTemplate from 'plugins/kibana/dashboard/dashboard.html'; import FilterBarQueryFilterProvider from 'ui/filter_bar/query_filter'; import DocTitleProvider from 'ui/doc_title'; @@ -50,7 +54,7 @@ uiRoutes } }); -app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter, quickRanges, kbnUrl, Private) { +app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter, quickRanges, kbnUrl, confirmModal, Private) { const brushEvent = Private(UtilsBrushEventProvider); const filterBarClickHandler = Private(FilterBarFilterBarClickHandlerProvider); @@ -58,7 +62,7 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter, restrict: 'E', controllerAs: 'dashboardApp', controller: function ($scope, $rootScope, $route, $routeParams, $location, Private, getAppState) { - const queryFilter = Private(FilterBarQueryFilterProvider); + const filterBar = Private(FilterBarQueryFilterProvider); const docTitle = Private(DocTitleProvider); const notify = new Notifier({ location: 'Dashboard' }); @@ -67,12 +71,13 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter, docTitle.change(dash.title); } - const dashboardState = new DashboardState( - dash, - timefilter, - !getAppState.previouslyStored(), - quickRanges, - AppState); + const dashboardState = new DashboardState(dash, AppState); + + // The 'previouslyStored' check is so we only update the time filter on dashboard open, not during + // normal cross app navigation. + if (dashboardState.getIsTimeSavedWithDashboard() && !getAppState.previouslyStored()) { + dashboardState.syncTimefilterWithDashboard(timefilter, quickRanges); + } // Part of the exposed plugin API - do not remove without careful consideration. this.appStatus = { @@ -82,7 +87,7 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter, this.appStatus.dirty = status.dirty || !dash.id; }); - dashboardState.updateFilters(queryFilter); + dashboardState.applyFilters(dashboardState.getQuery(), filterBar.getFilters()); let pendingVisCount = _.size(dashboardState.getPanels()); timefilter.enabled = true; @@ -92,22 +97,30 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter, // Following the "best practice" of always have a '.' in your ng-models – // https://github.com/angular/angular.js/wiki/Understanding-Scopes - $scope.model = { query: dashboardState.getQuery() }; + $scope.model = { + query: dashboardState.getQuery(), + darkTheme: dashboardState.getDarkTheme(), + timeRestore: dashboardState.getTimeRestore(), + title: dashboardState.getTitle() + }; $scope.panels = dashboardState.getPanels(); - $scope.topNavMenu = getTopNavConfig(kbnUrl); $scope.refresh = _.bindKey(courier, 'fetch'); $scope.timefilter = timefilter; $scope.expandedPanel = null; + $scope.dashboardViewMode = dashboardState.getViewMode(); $scope.getBrushEvent = () => brushEvent(dashboardState.getAppState()); $scope.getFilterBarClickHandler = () => filterBarClickHandler(dashboardState.getAppState()); $scope.hasExpandedPanel = () => $scope.expandedPanel !== null; - $scope.getDashTitle = () => { - return dashboardState.dashboard.lastSavedTitle || `${dashboardState.dashboard.title} (unsaved)`; - }; + $scope.getDashTitle = () => getDashboardTitle( + dashboardState.getTitle(), + dashboardState.getViewMode(), + dashboardState.getIsDirty(timefilter)); $scope.newDashboard = () => { kbnUrl.change(DashboardConstants.CREATE_NEW_DASHBOARD_URL, {}); }; $scope.saveState = () => dashboardState.saveState(); + $scope.getShouldShowEditHelp = () => !dashboardState.getPanels().length && dashboardState.getIsEditMode(); + $scope.getShouldShowViewHelp = () => !dashboardState.getPanels().length && dashboardState.getIsViewMode(); $scope.toggleExpandPanel = (panelIndex) => { if ($scope.expandedPanel && $scope.expandedPanel.panelIndex === panelIndex) { @@ -119,8 +132,7 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter, }; $scope.filterResults = function () { - dashboardState.setQuery($scope.model.query); - dashboardState.updateFilters(queryFilter); + dashboardState.applyFilters($scope.model.query, filterBar.getFilters()); $scope.refresh(); }; @@ -137,10 +149,6 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter, notify.info(`Search successfully added to your dashboard`); }; - $scope.showEditHelpText = () => { - return !dashboardState.getPanels().length; - }; - /** * Creates a child ui state for the panel. It's passed the ui state to use, but needs to * be generated from the parent (why, I don't know yet). @@ -154,8 +162,68 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter, $scope.onPanelRemoved = (panelIndex) => dashboardState.removePanel(panelIndex); + $scope.$watch('model.darkTheme', () => { + dashboardState.setDarkTheme($scope.model.darkTheme); + updateTheme(); + }); + $scope.$watch('model.title', () => dashboardState.setTitle($scope.model.title)); + $scope.$watch('model.timeRestore', () => dashboardState.setTimeRestore($scope.model.timeRestore)); + + $scope.$listen(timefilter, 'fetch', $scope.refresh); + + function updateViewMode(newMode) { + $scope.topNavMenu = getTopNavConfig(newMode, navActions); // eslint-disable-line no-use-before-define + dashboardState.switchViewMode(newMode); + $scope.dashboardViewMode = newMode; + } + + const onChangeViewMode = (newMode) => { + const isPageRefresh = newMode === dashboardState.getViewMode(); + const isLeavingEditMode = !isPageRefresh && newMode === DashboardViewMode.VIEW; + const willLoseChanges = isLeavingEditMode && dashboardState.getIsDirty(timefilter); + + if (!willLoseChanges) { + updateViewMode(newMode); + return; + } + + function revertChangesAndExitEditMode() { + dashboardState.resetState(); + const refreshUrl = dash.id ? + DashboardConstants.EXISTING_DASHBOARD_URL : DashboardConstants.CREATE_NEW_DASHBOARD_URL; + const refreshUrlOptions = dash.id ? { id: dash.id } : {}; + kbnUrl.change(refreshUrl, refreshUrlOptions); + // This is only necessary for new dashboards, which will default to Edit mode. + updateViewMode(DashboardViewMode.VIEW); + + // We need to do a hard reset of the timepicker. appState will not reload like + // it does on 'open' because it's been saved to the url and the getAppState.previouslyStored() check on + // reload will cause it not to sync. + if (dashboardState.getIsTimeSavedWithDashboard()) { + dashboardState.syncTimefilterWithDashboard(timefilter, quickRanges); + } + } + + confirmModal( + getUnsavedChangesWarningMessage(dashboardState.getChangedFilterTypes(timefilter)), + { + onConfirm: revertChangesAndExitEditMode, + onCancel: _.noop, + confirmButtonText: 'Yes, lose changes', + cancelButtonText: 'No, keep working', + defaultFocusedButton: ConfirmationButtonTypes.CANCEL + } + ); + }; + + const navActions = {}; + navActions[TopNavIds.EXIT_EDIT_MODE] = () => onChangeViewMode(DashboardViewMode.VIEW); + navActions[TopNavIds.ENTER_EDIT_MODE] = () => onChangeViewMode(DashboardViewMode.EDIT); + + updateViewMode(dashboardState.getViewMode()); + $scope.save = function () { - return dashboardState.saveDashboard(angular.toJson).then(function (id) { + return dashboardState.saveDashboard(angular.toJson, timefilter).then(function (id) { $scope.kbnTopNav.close('save'); if (id) { notify.info(`Saved Dashboard as "${dash.title}"`); @@ -165,27 +233,19 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter, { id: dash.id }); } else { docTitle.change(dash.lastSavedTitle); + updateViewMode(DashboardViewMode.VIEW); } } }).catch(notify.fatal); }; - $scope.$watchCollection(() => dashboardState.getOptions(), () => dashboardState.saveState()); - $scope.$watch(() => dashboardState.getOptions().darkTheme, updateTheme); - - $scope.$watch('model.query', function () { - dashboardState.setQuery($scope.model.query); - }); - - $scope.$listen(timefilter, 'fetch', $scope.refresh); - // update root source when filters update - $scope.$listen(queryFilter, 'update', function () { - dashboardState.updateFilters(queryFilter); + $scope.$listen(filterBar, 'update', function () { + dashboardState.applyFilters($scope.model.query, filterBar.getFilters()); }); // update data when filters fire fetch event - $scope.$listen(queryFilter, 'fetch', $scope.refresh); + $scope.$listen(filterBar, 'fetch', $scope.refresh); $scope.$on('$destroy', () => { dashboardState.destroy(); @@ -195,8 +255,7 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter, }); function updateTheme() { - const useDarkTheme = dashboardState.getOptions().darkTheme; - useDarkTheme ? setDarkTheme() : setLightTheme(); + dashboardState.getDarkTheme() ? setDarkTheme() : setLightTheme(); } function setDarkTheme() { @@ -228,10 +287,9 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter, `${VisualizeConstants.WIZARD_STEP_1_PAGE_PATH}?${DashboardConstants.ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM}`); }; - // Setup configurable values for config directive, after objects are initialized $scope.opts = { + displayName: dash.getDisplayName(), dashboard: dash, - ui: dashboardState.getOptions(), save: $scope.save, addVis: $scope.addVis, addNewVis, diff --git a/src/core_plugins/kibana/public/dashboard/dashboard_state.js b/src/core_plugins/kibana/public/dashboard/dashboard_state.js index 4b358a4198bf1..418b1f98cf49c 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard_state.js +++ b/src/core_plugins/kibana/public/dashboard/dashboard_state.js @@ -1,5 +1,7 @@ import _ from 'lodash'; import { FilterUtils } from './filter_utils'; + +import { DashboardViewMode } from './dashboard_view_mode'; import { PanelUtils } from './panel/panel_utils'; import moment from 'moment'; @@ -10,39 +12,114 @@ import { getPersistedStateId } from 'plugins/kibana/dashboard/panel/panel_state' function getStateDefaults(dashboard) { return { title: dashboard.title, + timeRestore: dashboard.timeRestore, panels: dashboard.panelsJSON ? JSON.parse(dashboard.panelsJSON) : [], options: dashboard.optionsJSON ? JSON.parse(dashboard.optionsJSON) : {}, uiState: dashboard.uiStateJSON ? JSON.parse(dashboard.uiStateJSON) : {}, query: FilterUtils.getQueryFilterForDashboard(dashboard), - filters: FilterUtils.getFilterBarsForDashboard(dashboard) + filters: FilterUtils.getFilterBarsForDashboard(dashboard), + viewMode: dashboard.id ? DashboardViewMode.VIEW : DashboardViewMode.EDIT, }; } +/** + * Depending on how a dashboard is loaded, the filter object may contain a $$hashKey and $state that will throw + * off a filter comparison. This removes those variables. + * @param filters {Array.} + * @returns {Array.} + */ +function cleanFiltersForComparison(filters) { + return _.map(filters, (filter) => _.omit(filter, ['$$hashKey', '$state'])); +} + +/** + * Converts the time to a string, if it isn't already. + * @param time {string|Moment} + * @returns {string} + */ +function convertTimeToString(time) { + return typeof time === 'string' ? time : moment(time).toString(); +} + +/** + * Compares the two times, making sure they are in both compared in string format. Absolute times + * are sometimes stored as moment objects, but converted to strings when reloaded. Relative times are + * strings that are not convertible to moment objects. + * @param timeA {string|Moment} + * @param timeB {string|Moment} + * @returns {boolean} + */ +function areTimesEqual(timeA, timeB) { + return convertTimeToString(timeA) === convertTimeToString(timeB); +} + export class DashboardState { /** - * @param dashboard {SavedDashboard} - * @param timefilter {Object} - * @param timeFilterPreviouslyStored {boolean} - I'm honestly not sure what this value - * means but preserving original logic after a refactor. - * @param quickTimeRanges {Array} An array of default time ranges that should be - * classified as 'quick' mode. - * @param AppState {Object} A class that will be used to instantiate the appState. + * + * @param savedDashboard {SavedDashboard} + * @param AppState {AppState} */ - constructor(dashboard, timefilter, timeFilterPreviouslyStored, quickTimeRanges, AppState) { - this.stateDefaults = getStateDefaults(dashboard); + constructor(savedDashboard, AppState) { + this.savedDashboard = savedDashboard; + + this.stateDefaults = getStateDefaults(this.savedDashboard); this.appState = new AppState(this.stateDefaults); this.uiState = this.appState.makeStateful('uiState'); - this.timefilter = timefilter; - this.dashboard = dashboard; - - this.stateMonitor = stateMonitorFactory.create(this.appState, this.stateDefaults); + this.isDirty = false; - if (this.getShouldSyncTimefilterWithDashboard() && timeFilterPreviouslyStored) { - this.syncTimefilterWithDashboard(quickTimeRanges); - } + // We can't compare the filters stored on this.appState to this.savedDashboard because in order to apply + // the filters to the visualizations, we need to save it on the dashboard. We keep track of the original + // filter state in order to let the user know if their filters changed and provide this specific information + //in the 'lose changes' warning message. + this.lastSavedDashboardFilters = this.getFilterState(); PanelUtils.initPanelIndexes(this.getPanels()); + this.createStateMonitor(); + } + + /** + * Resets the state back to the last saved version of the dashboard. + */ + resetState() { + // appState.reset uses the internal defaults to reset the state, but some of the default settings (e.g. the panels + // array) point to the same object that is stored on appState and is getting modified. + // The right way to fix this might be to ensure the defaults object stored on state is a deep + // clone, but given how much code uses the state object, I determined that to be too risky of a change for + // now. TODO: revisit this! + this.stateDefaults = getStateDefaults(this.savedDashboard); + // The original query won't be restored by the above because the query on this.savedDashboard is applied + // in place in order for it to affect the visualizations. + this.stateDefaults.query = this.lastSavedDashboardFilters.query; + // Need to make a copy to ensure they are not overwritten. + this.stateDefaults.filters = Object.assign(new Array(), this.getLastSavedFilterBars()); + + this.isDirty = false; + this.appState.setDefaults(this.stateDefaults); + this.appState.reset(); + this.stateMonitor.setInitialState(this.appState.toJSON()); + } + + /** + * Returns an object which contains the current filter state of this.savedDashboard. + * @returns {{timeTo: String, timeFrom: String, filterBars: Array, query: Object}} + */ + getFilterState() { + return { + timeTo: this.savedDashboard.timeTo, + timeFrom: this.savedDashboard.timeFrom, + filterBars: this.getDashboardFilterBars(), + query: this.getDashboardQuery() + }; + } + + getTitle() { + return this.appState.title; + } + + setTitle(title) { + this.appState.title = title; + this.saveState(); } getAppState() { @@ -53,16 +130,106 @@ export class DashboardState { return this.appState.query; } - getOptions() { - return this.appState.options; + getDarkTheme() { + return this.appState.options.darkTheme; + } + + setDarkTheme(darkTheme) { + this.appState.options.darkTheme = darkTheme; + this.saveState(); + } + + getTimeRestore() { + return this.appState.timeRestore; + } + + setTimeRestore(timeRestore) { + this.appState.timeRestore = timeRestore; + this.saveState(); } /** - * Returns true if the timefilter should match the time stored with the dashboard. * @returns {boolean} */ - getShouldSyncTimefilterWithDashboard() { - return this.dashboard.timeRestore && this.dashboard.timeTo && this.dashboard.timeFrom; + getIsTimeSavedWithDashboard() { + return this.savedDashboard.timeRestore; + } + + getDashboardFilterBars() { + return FilterUtils.getFilterBarsForDashboard(this.savedDashboard); + } + + getDashboardQuery() { + return FilterUtils.getQueryFilterForDashboard(this.savedDashboard); + } + + getLastSavedFilterBars() { + return this.lastSavedDashboardFilters.filterBars; + } + + getLastSavedQuery() { + return this.lastSavedDashboardFilters.query; + } + + /** + * @returns {boolean} True if the query changed since the last time the dashboard was saved, or if it's a + * new dashboard, if the query differs from the default. + */ + getQueryChanged() { + return !_.isEqual(this.appState.query, this.getLastSavedQuery()); + } + + /** + * @returns {boolean} True if the filter bar state has changed since the last time the dashboard was saved, + * or if it's a new dashboard, if the query differs from the default. + */ + getFilterBarChanged() { + return !_.isEqual(cleanFiltersForComparison(this.appState.filters), + cleanFiltersForComparison(this.getLastSavedFilterBars())); + } + + /** + * @param timeFilter + * @returns {boolean} True if the time state has changed since the time saved with the dashboard. + */ + getTimeChanged(timeFilter) { + return ( + !areTimesEqual(this.lastSavedDashboardFilters.timeFrom, timeFilter.time.from) || + !areTimesEqual(this.lastSavedDashboardFilters.timeTo, timeFilter.time.to) + ); + } + + /** + * + * @returns {DashboardViewMode} + */ + getViewMode() { + return this.appState.viewMode; + } + + /** + * @returns {boolean} + */ + getIsViewMode() { + return this.getViewMode() === DashboardViewMode.VIEW; + } + + /** + * @returns {boolean} + */ + getIsEditMode() { + return this.getViewMode() === DashboardViewMode.EDIT; + } + + /** + * + * @returns {boolean} True if the dashboard has changed since the last save (or, is new). + */ + getIsDirty(timeFilter) { + return this.isDirty || + // Filter bar comparison is done manually (see cleanFiltersForComparison for the reason) and time picker + // changes are not tracked by the state monitor. + this.getFiltersChanged(timeFilter); } getPanels() { @@ -92,33 +259,62 @@ export class DashboardState { } /** - * Updates the time filter to match the values stored in the dashboard. - * @param {Array} quickTimeRanges - An array of often used default relative time ranges. - * Used to determine whether a relative query should be classified as a "quick" time mode or - * simply a "relative" time mode. + * @param timeFilter + * @returns {Array.} An array of user friendly strings indicating the filter types that have changed. + */ + getChangedFilterTypes(timeFilter) { + const changedFilters = []; + if (this.getFilterBarChanged()) { + changedFilters.push('filter'); + } + if (this.getQueryChanged()) { + changedFilters.push('query'); + } + if (this.savedDashboard.timeRestore && this.getTimeChanged(timeFilter)) { + changedFilters.push('time range'); + } + return changedFilters; + } + + /** + * @return {boolean} True if filters (query, filter bar filters, and time picker if time is stored + * with the dashboard) have changed since the last saved state (or if the dashboard hasn't been saved, + * the default state). */ - syncTimefilterWithDashboard(quickTimeRanges) { - this.timefilter.time.to = this.dashboard.timeTo; - this.timefilter.time.from = this.dashboard.timeFrom; - const isMoment = moment(this.dashboard.timeTo).isValid(); + getFiltersChanged(timeFilter) { + return this.getChangedFilterTypes(timeFilter).length > 0; + } + + /** + * Updates timeFilter to match the time saved with the dashboard. + * @param timeFilter + * @param quickTimeRanges + */ + syncTimefilterWithDashboard(timeFilter, quickTimeRanges) { + if (!this.getIsTimeSavedWithDashboard()) { + throw new Error('The time is not saved with this dashboard so should not be synced.'); + } + + timeFilter.time.to = this.savedDashboard.timeTo; + timeFilter.time.from = this.savedDashboard.timeFrom; + const isMoment = moment(this.savedDashboard.timeTo).isValid(); if (isMoment) { - this.timefilter.time.mode = 'absolute'; + timeFilter.time.mode = 'absolute'; } else { const quickTime = _.find( quickTimeRanges, - (timeRange) => timeRange.from === this.dashboard.timeFrom && timeRange.to === this.dashboard.timeTo); + (timeRange) => timeRange.from === this.savedDashboard.timeFrom && timeRange.to === this.savedDashboard.timeTo); - this.timefilter.time.mode = quickTime ? 'quick' : 'relative'; + timeFilter.time.mode = quickTime ? 'quick' : 'relative'; } - if (this.dashboard.refreshInterval) { - this.timefilter.refreshInterval = this.dashboard.refreshInterval; + if (this.savedDashboard.refreshInterval) { + timeFilter.refreshInterval = this.savedDashboard.refreshInterval; } } - setQuery(newQuery) { - this.appState.query = newQuery; - } - + /** + * Saves the current application state to the URL. + */ saveState() { this.appState.save(); } @@ -128,48 +324,82 @@ export class DashboardState { * @param toJson {function} A custom toJson function. Used because the previous code used * the angularized toJson version, and it was unclear whether there was a reason not to use * JSON.stringify + * @param timefilter * @returns {Promise} A promise that if resolved, will contain the id of the newly saved * dashboard. */ - saveDashboard(toJson) { + saveDashboard(toJson, timeFilter) { this.saveState(); - const timeRestoreObj = _.pick(this.timefilter.refreshInterval, ['display', 'pause', 'section', 'value']); - this.dashboard.panelsJSON = toJson(this.appState.panels); - this.dashboard.uiStateJSON = toJson(this.uiState.getChanges()); - this.dashboard.timeFrom = this.dashboard.timeRestore ? this.timefilter.time.from : undefined; - this.dashboard.timeTo = this.dashboard.timeRestore ? this.timefilter.time.to : undefined; - this.dashboard.refreshInterval = this.dashboard.timeRestore ? timeRestoreObj : undefined; - this.dashboard.optionsJSON = toJson(this.appState.options); + const timeRestoreObj = _.pick(timeFilter.refreshInterval, ['display', 'pause', 'section', 'value']); + this.savedDashboard.title = this.appState.title; + this.savedDashboard.timeRestore = this.appState.timeRestore; + this.savedDashboard.panelsJSON = toJson(this.appState.panels); + this.savedDashboard.uiStateJSON = toJson(this.uiState.getChanges()); + this.savedDashboard.timeFrom = this.savedDashboard.timeRestore ? convertTimeToString(timeFilter.time.from) : undefined; + this.savedDashboard.timeTo = this.savedDashboard.timeRestore ? convertTimeToString(timeFilter.time.to) : undefined; + this.savedDashboard.refreshInterval = this.savedDashboard.timeRestore ? timeRestoreObj : undefined; + this.savedDashboard.optionsJSON = toJson(this.appState.options); - return this.dashboard.save() + return this.savedDashboard.save() .then((id) => { + this.lastSavedDashboardFilters = this.getFilterState(); + this.stateDefaults = getStateDefaults(this.savedDashboard); + this.stateDefaults.viewMode = DashboardViewMode.VIEW; + // Make sure new app state defaults are using the new defaults. + this.appState.setDefaults(this.stateDefaults); this.stateMonitor.setInitialState(this.appState.toJSON()); return id; }); } /** - * Stores the given filter with the dashboard and to the state. - * @param filter + * Applies the current filter state to the dashboard. + * @param filter {Array.} An array of filter bar filters. */ - updateFilters(filter) { - const filters = filter.getFilters(); + applyFilters(query, filters) { + this.appState.query = query; if (this.appState.query) { - this.dashboard.searchSource.set('filter', _.union(filters, [{ + this.savedDashboard.searchSource.set('filter', _.union(filters, [{ query: this.appState.query }])); } else { - this.dashboard.searchSource.set('filter', filters); + this.savedDashboard.searchSource.set('filter', filters); } this.saveState(); } + /** + * Creates a state monitor and saves it to this.stateMonitor. Used to track unsaved changes made to appState. + */ + createStateMonitor() { + this.stateMonitor = stateMonitorFactory.create(this.appState, this.stateDefaults); + + this.stateMonitor.ignoreProps('viewMode'); + // Filters need to be compared manually because they sometimes have a $$hashkey stored on the object. + this.stateMonitor.ignoreProps('filters'); + + this.stateMonitor.onChange(status => { + this.isDirty = status.dirty; + }); + } + + /** + * @param newMode {DashboardViewMode} + */ + switchViewMode(newMode) { + this.appState.viewMode = newMode; + this.saveState(); + } + + /** + * Destroys and cleans up this object when it's no longer used. + */ destroy() { if (this.stateMonitor) { this.stateMonitor.destroy(); } - this.dashboard.destroy(); + this.savedDashboard.destroy(); } } diff --git a/src/core_plugins/kibana/public/dashboard/dashboard_strings.js b/src/core_plugins/kibana/public/dashboard/dashboard_strings.js new file mode 100644 index 0000000000000..5243ccdf49605 --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/dashboard_strings.js @@ -0,0 +1,45 @@ +import { DashboardViewMode } from './dashboard_view_mode'; +import _ from 'lodash'; + +/** + * @param list {Array.} + * @returns {string} The list of strings concatenated with commas so it can be used in a message. + * E.g. ['a', 'b', 'c'] returns 'a, b, and c'. + */ +export function createStringList(list) { + const listClone = _.clone(list); + const isPlural = list.length > 1; + const lastEntry = isPlural ? `, and ${list[list.length - 1]}` : ''; + if (isPlural) listClone.splice(-1, 1); + + return `${listClone.join(', ')}${lastEntry}`; +} + +/** + * @param changedFilters {Array.} An optional list of filter types that have changed. + * @returns {string} A warning message to display to the user that they are going to lose changes. + */ +export function getUnsavedChangesWarningMessage(changedFilters) { + const changedFilterList = createStringList(changedFilters); + + return changedFilterList ? + `Are you sure you want to cancel and lose changes, including changes made to your ${changedFilterList}?` : + `Are you sure you want to cancel and lose changes?`; +} + +/** + * @param title {string} the current title of the dashboard + * @param viewMode {DashboardViewMode} the current mode. If in editing state, prepends 'Editing ' to the title. + * @param isDirty {boolean} if the dashboard is in a dirty state. If in dirty state, adds (unsaved) to the + * end of the title. + * @returns {string} A title to display to the user based on the above parameters. + */ +export function getDashboardTitle(title, viewMode, isDirty) { + const isEditMode = viewMode === DashboardViewMode.EDIT; + const unsavedSuffix = isEditMode && isDirty + ? ' (unsaved)' + : ''; + + const displayTitle = `${title}${unsavedSuffix}`; + return isEditMode ? 'Editing ' + displayTitle : displayTitle; +} diff --git a/src/core_plugins/kibana/public/dashboard/dashboard_view_mode.js b/src/core_plugins/kibana/public/dashboard/dashboard_view_mode.js new file mode 100644 index 0000000000000..12a532575c54d --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/dashboard_view_mode.js @@ -0,0 +1,13 @@ +/** + * A dashboard mode. + * @typedef {string} DashboardMode + */ + +/** + * Dashboard view modes. + * @type {{EDIT: DashboardViewMode, VIEW: DashboardViewMode}} + */ +export const DashboardViewMode = { + EDIT: 'edit', + VIEW: 'view' +}; diff --git a/src/core_plugins/kibana/public/dashboard/grid.js b/src/core_plugins/kibana/public/dashboard/grid.js index f026f478a4a96..485d38f8f118f 100644 --- a/src/core_plugins/kibana/public/dashboard/grid.js +++ b/src/core_plugins/kibana/public/dashboard/grid.js @@ -4,6 +4,7 @@ import Binder from 'ui/binder'; import chrome from 'ui/chrome'; import 'gridster'; import uiModules from 'ui/modules'; +import { DashboardViewMode } from 'plugins/kibana/dashboard/dashboard_view_mode'; import { PanelUtils } from 'plugins/kibana/dashboard/panel/panel_utils'; const app = uiModules.get('app/dashboard'); @@ -12,6 +13,11 @@ app.directive('dashboardGrid', function ($compile, Notifier) { return { restrict: 'E', scope: { + /** + * What view mode the dashboard is currently in - edit or view only. + * @type {DashboardViewMode} + */ + dashboardViewMode: '=', /** * Used to create a child persisted state for the panel from parent state. * @type {function} - Returns a {PersistedState} child uiState for this scope. @@ -105,13 +111,26 @@ app.directive('dashboardGrid', function ($compile, Notifier) { } }).data('gridster'); + function setResizeCapability() { + if ($scope.dashboardViewMode === DashboardViewMode.VIEW) { + gridster.disable_resize(); + } else { + gridster.enable_resize(); + } + } + // This is necessary to enable text selection within gridster elements // http://stackoverflow.com/questions/21561027/text-not-selectable-from-editable-div-which-is-draggable binder.jqOn($el, 'mousedown', function () { gridster.disable().disable_resize(); }); binder.jqOn($el, 'mouseup', function enableResize() { - gridster.enable().enable_resize(); + gridster.enable(); + setResizeCapability(); + }); + + $scope.$watch('dashboardViewMode', () => { + setResizeCapability(); }); $scope.$watchCollection('panels', function (panels) { @@ -172,6 +191,7 @@ app.directive('dashboardGrid', function ($compile, Notifier) { panel="findPanelByPanelIndex(${panel.panelIndex}, panels)" is-full-screen-mode="isFullScreenMode" is-expanded="false" + dashboard-view-mode="dashboardViewMode" get-vis-click-handler="getVisClickHandler" get-vis-brush-handler="getVisBrushHandler" save-state="saveState" diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel.html b/src/core_plugins/kibana/public/dashboard/panel/panel.html index 920b5959533ee..9f7368cb75a39 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel.html +++ b/src/core_plugins/kibana/public/dashboard/panel/panel.html @@ -1,19 +1,34 @@ -
+
{{::savedObj.title}} diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel.js b/src/core_plugins/kibana/public/dashboard/panel/panel.js index 79151e09f03c0..6f3875d8d6ffb 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel.js +++ b/src/core_plugins/kibana/public/dashboard/panel/panel.js @@ -7,6 +7,7 @@ import uiModules from 'ui/modules'; import panelTemplate from 'plugins/kibana/dashboard/panel/panel.html'; import { getPersistedStateId } from 'plugins/kibana/dashboard/panel/panel_state'; import { loadSavedObject } from 'plugins/kibana/dashboard/panel/load_saved_object'; +import { DashboardViewMode } from '../dashboard_view_mode'; uiModules .get('app/dashboard') @@ -25,6 +26,11 @@ uiModules restrict: 'E', template: panelTemplate, scope: { + /** + * What view mode the dashboard is currently in - edit or view only. + * @type {DashboardViewMode} + */ + dashboardViewMode: '=', /** * Whether or not the dashboard this panel is contained on is in 'full screen mode'. * @type {boolean} @@ -138,6 +144,13 @@ uiModules $scope.editUrl = '#management/kibana/objects/' + service.name + '/' + id + '?notFound=' + e.savedObjectType; }); + + /** + * @returns {boolean} True if the user can only view, not edit. + */ + $scope.isViewOnlyMode = () => { + return $scope.dashboardViewMode === DashboardViewMode.VIEW || $scope.isFullScreenMode; + }; } }; }); diff --git a/src/core_plugins/kibana/public/dashboard/styles/index.less b/src/core_plugins/kibana/public/dashboard/styles/index.less index f7a480a657457..f351295d8dc3f 100644 --- a/src/core_plugins/kibana/public/dashboard/styles/index.less +++ b/src/core_plugins/kibana/public/dashboard/styles/index.less @@ -34,14 +34,12 @@ dashboard-grid { } .gs-w { - border: 2px dashed transparent; .panel .panel-heading .btn-group { display: none; } &:hover { - border-color: @kibanaGray4; dashboard-panel { .visualize-show-spy { @@ -53,6 +51,15 @@ dashboard-grid { } } + .panel--edit-mode { + border-color: @kibanaGray4; + .visualize-show-spy { + visibility: visible; + } + .panel-heading .btn-group { + display: block !important; + } + } } i.remove { @@ -60,6 +67,20 @@ dashboard-grid { } } +.panel { + border: 2px dashed transparent; +} + +.panel--edit-mode { + border-color: @kibanaGray4; + .visualize-show-spy { + visibility: visible; + } + .panel-heading .btn-group { + display: block !important; + } +} + .dashboard-container { flex: 1; display: flex; @@ -88,7 +109,6 @@ dashboard-panel { display: flex; flex-direction: column; justify-content: flex-start; - border: 0 solid transparent; .panel-heading { padding: 0px 0px 0px 5px; diff --git a/src/core_plugins/kibana/public/dashboard/top_nav/get_top_nav_config.js b/src/core_plugins/kibana/public/dashboard/top_nav/get_top_nav_config.js index 8a20d1d091364..a5d2060ad5a72 100644 --- a/src/core_plugins/kibana/public/dashboard/top_nav/get_top_nav_config.js +++ b/src/core_plugins/kibana/public/dashboard/top_nav/get_top_nav_config.js @@ -1,26 +1,37 @@ +import { DashboardViewMode } from '../dashboard_view_mode'; +import { TopNavIds } from './top_nav_ids'; /** - * @param kbnUrl - used to change the url. - * @return {Array} - Returns an array of objects for a top nav configuration. - * Note that order matters and the top nav will be displayed in the same order. + * @param {DashboardMode} dashboardMode. + * @param actions {Object} - A mapping of TopNavIds to an action function that should run when the + * corresponding top nav is clicked. + * @return {Array} - Returns an array of objects for a top nav configuration, based on the + * mode. */ -export function getTopNavConfig() { - return [ - getAddConfig(), - getSaveConfig(), - getShareConfig(), - getOptionsConfig()]; +export function getTopNavConfig(dashboardMode, actions) { + switch (dashboardMode) { + case DashboardViewMode.VIEW: + return [getShareConfig(), getEditConfig(actions[TopNavIds.ENTER_EDIT_MODE])]; + case DashboardViewMode.EDIT: + return [ + getSaveConfig(), + getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE]), + getAddConfig(), + getOptionsConfig()]; + default: + return []; + } } /** * @returns {kbnTopNavConfig} */ -function getAddConfig() { +function getEditConfig(action) { return { - key: 'add', - description: 'Add a panel to the dashboard', - testId: 'dashboardAddPanelButton', - template: require('plugins/kibana/dashboard/top_nav/add_panel.html') + key: 'edit', + description: 'Switch to edit mode', + testId: 'dashboardEditMode', + run: action }; } @@ -30,18 +41,42 @@ function getAddConfig() { function getSaveConfig() { return { key: 'save', - description: 'Save Dashboard', + description: 'Save your dashboard', testId: 'dashboardSaveButton', template: require('plugins/kibana/dashboard/top_nav/save.html') }; } +/** + * @returns {kbnTopNavConfig} + */ +function getViewConfig(action) { + return { + key: 'cancel', + description: 'Cancel editing and switch to view-only mode', + testId: 'dashboardViewOnlyMode', + run: action + }; +} + +/** + * @returns {kbnTopNavConfig} + */ +function getAddConfig() { + return { + key: TopNavIds.ADD, + description: 'Add a panel to the dashboard', + testId: 'dashboardAddPanelButton', + template: require('plugins/kibana/dashboard/top_nav/add_panel.html') + }; +} + /** * @returns {kbnTopNavConfig} */ function getShareConfig() { return { - key: 'share', + key: TopNavIds.SHARE, description: 'Share Dashboard', testId: 'dashboardShareButton', template: require('plugins/kibana/dashboard/top_nav/share.html') @@ -53,7 +88,7 @@ function getShareConfig() { */ function getOptionsConfig() { return { - key: 'options', + key: TopNavIds.OPTIONS, description: 'Options', testId: 'dashboardOptionsButton', template: require('plugins/kibana/dashboard/top_nav/options.html') diff --git a/src/core_plugins/kibana/public/dashboard/top_nav/options.html b/src/core_plugins/kibana/public/dashboard/top_nav/options.html index 2adbeaad2e337..a549d182b6488 100644 --- a/src/core_plugins/kibana/public/dashboard/top_nav/options.html +++ b/src/core_plugins/kibana/public/dashboard/top_nav/options.html @@ -1,11 +1,12 @@
Options
+
-
diff --git a/src/core_plugins/kibana/public/dashboard/top_nav/top_nav_ids.js b/src/core_plugins/kibana/public/dashboard/top_nav/top_nav_ids.js new file mode 100644 index 0000000000000..ed6a3cf474e2d --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/top_nav/top_nav_ids.js @@ -0,0 +1,8 @@ +export const TopNavIds = { + ADD: 'add', + SHARE: 'share', + OPTIONS: 'options', + SAVE: 'save', + EXIT_EDIT_MODE: 'exitEditMode', + ENTER_EDIT_MODE: 'enterEditMode' +}; diff --git a/src/ui/public/kbn_top_nav/kbn_top_nav.js b/src/ui/public/kbn_top_nav/kbn_top_nav.js index e05a8156241a6..04ce2641b23b0 100644 --- a/src/ui/public/kbn_top_nav/kbn_top_nav.js +++ b/src/ui/public/kbn_top_nav/kbn_top_nav.js @@ -98,18 +98,37 @@ module.directive('kbnTopNav', function (Private) { $scope.transcludes[transclusionSlot] = transcludedItem; }); }); - const extensions = getNavbarExtensions($attrs.name); - let controls = _.get($scope, $attrs.config, []); - if (controls instanceof KbnTopNavController) { - controls.addItems(extensions); - } else { - controls = controls.concat(extensions); + function initTopNav(newConfig, oldConfig) { + if (_.isEqual(oldConfig, newConfig)) return; + + if (newConfig instanceof KbnTopNavController) { + newConfig.addItems(extensions); + $scope.kbnTopNav = new KbnTopNavController(newConfig); + } else { + newConfig = newConfig.concat(extensions); + $scope.kbnTopNav = new KbnTopNavController(newConfig); + } + $scope.kbnTopNav._link($scope, $element); + } + + const getTopNavConfig = () => { + return _.get($scope, $attrs.config, []); + }; + + const topNavConfig = getTopNavConfig(); + + // Because we store $scope and $element on the kbnTopNavController, if this was passed an instance + // instead of a configuration, it will enter an infinite digest loop. Only watch for updates if a config + // was passed instead. This is ugly, but without diving into a larger refactor, the smallest temporary solution + // to get dynamic nav updates working for dashboard. Console is currently the only place that passes a + // KbnTopNavController (and a slew of tests). + if (!(topNavConfig instanceof KbnTopNavController)) { + $scope.$watch(getTopNavConfig, initTopNav, true); } - $scope.kbnTopNav = new KbnTopNavController(controls); - $scope.kbnTopNav._link($scope, $element); + initTopNav(topNavConfig, null); return $scope.kbnTopNav; }, diff --git a/src/ui/public/modals/confirm_modal.js b/src/ui/public/modals/confirm_modal.js index 64a5dd7b0cecf..a3d811343fce0 100644 --- a/src/ui/public/modals/confirm_modal.js +++ b/src/ui/public/modals/confirm_modal.js @@ -6,6 +6,11 @@ import { ModalOverlay } from './modal_overlay'; const module = uiModules.get('kibana'); +export const ConfirmationButtonTypes = { + CONFIRM: 'Confirm', + CANCEL: 'Cancel' +}; + /** * @typedef {Object} ConfirmModalOptions * @property {String} confirmButtonText @@ -31,7 +36,8 @@ module.factory('confirmModal', function ($rootScope, $compile) { const defaultOptions = { onCancel: noop, cancelButtonText: 'Cancel', - showClose: false + showClose: false, + defaultFocusedButton: ConfirmationButtonTypes.CONFIRM }; if (customOptions.showClose === true && !customOptions.title) { @@ -77,7 +83,15 @@ module.factory('confirmModal', function ($rootScope, $compile) { } }); - modalInstance.find('[data-test-subj=confirmModalConfirmButton]').focus(); + switch (options.defaultFocusedButton) { + case ConfirmationButtonTypes.CONFIRM: + modalInstance.find('[data-test-subj=confirmModalConfirmButton]').focus(); + break; + case ConfirmationButtonTypes.CANCEL: + modalInstance.find('[data-test-subj=confirmModalCancelButton]').focus(); + break; + default: + } } if (modalPopover) { diff --git a/src/ui/public/modals/modal_overlay.html b/src/ui/public/modals/modal_overlay.html index b9de7e5ce45e1..7693a04aef868 100644 --- a/src/ui/public/modals/modal_overlay.html +++ b/src/ui/public/modals/modal_overlay.html @@ -1 +1 @@ -
+
diff --git a/src/ui/public/state_management/state.js b/src/ui/public/state_management/state.js index 4326d667f21b8..65729c04c228c 100644 --- a/src/ui/public/state_management/state.js +++ b/src/ui/public/state_management/state.js @@ -19,7 +19,7 @@ import { isStateHash, } from './state_storage'; -export default function StateProvider(Private, $rootScope, $location, config) { +export default function StateProvider(Private, $rootScope, $location, config, kbnUrl) { const Events = Private(EventsProvider); _.class(State).inherits(Events); @@ -173,6 +173,7 @@ export default function StateProvider(Private, $rootScope, $location, config) { * @returns {void} */ State.prototype.reset = function () { + kbnUrl.removeParam(this.getQueryParamName()); // apply diff to _attributes from defaults, this is side effecting so // it will change the state in place. const diffResults = applyDiff(this, this._defaults); diff --git a/test/functional/apps/dashboard/_dashboard.js b/test/functional/apps/dashboard/_dashboard.js index c584a8f66f0b4..353a1b90e3ce6 100644 --- a/test/functional/apps/dashboard/_dashboard.js +++ b/test/functional/apps/dashboard/_dashboard.js @@ -5,7 +5,6 @@ import { } from '../../../../src/core_plugins/kibana/public/dashboard/panel/panel_state'; import { bdd } from '../../../support'; - import PageObjects from '../../../support/page_objects'; bdd.describe('dashboard tab', function describeIndexTests() { @@ -85,42 +84,47 @@ bdd.describe('dashboard tab', function describeIndexTests() { PageObjects.common.saveScreenshot('Dashboard-has-visualizations'); }); }); +}); - bdd.it('filters when a pie chart slice is clicked', async function () { - let descriptions = await PageObjects.dashboard.getFilterDescriptions(1000); +bdd.describe('filters', async function () { + bdd.it('are not selected by default', async function () { + const descriptions = await PageObjects.dashboard.getFilterDescriptions(1000); expect(descriptions.length).to.equal(0); + }); + bdd.it('are added when a pie chart slice is clicked', async function () { await PageObjects.dashboard.filterOnPieSlice(); - descriptions = await PageObjects.dashboard.getFilterDescriptions(); + const descriptions = await PageObjects.dashboard.getFilterDescriptions(); expect(descriptions.length).to.equal(1); }); +}); - bdd.it('retains dark theme in state', async function () { - await PageObjects.dashboard.useDarkTheme(true); - await PageObjects.header.clickVisualize(); - await PageObjects.header.clickDashboard(); - const isDarkThemeOn = await PageObjects.dashboard.isDarkThemeOn(); - expect(isDarkThemeOn).to.equal(true); - }); - - bdd.it('should have shared-items-count set to the number of visualizations', function checkSavedItemsCount() { - const visualizations = PageObjects.dashboard.getTestVisualizations(); - return PageObjects.common.tryForTime(10000, () => PageObjects.dashboard.getSharedItemsCount()) - .then(function (count) { - PageObjects.common.log('shared-items-count = ' + count); - expect(count).to.eql(visualizations.length); - }); - }); +bdd.it('retains dark theme in state', async function () { + await PageObjects.dashboard.clickEdit(); + await PageObjects.dashboard.useDarkTheme(true); + await PageObjects.header.clickVisualize(); + await PageObjects.header.clickDashboard(); + const isDarkThemeOn = await PageObjects.dashboard.isDarkThemeOn(); + expect(isDarkThemeOn).to.equal(true); +}); - bdd.it('should have panels with expected shared-item title and description', function checkTitles() { - const visualizations = PageObjects.dashboard.getTestVisualizations(); - return PageObjects.common.tryForTime(10000, function () { - return PageObjects.dashboard.getPanelSharedItemData() - .then(function (data) { - expect(data.map(item => item.title)).to.eql(visualizations.map(v => v.name)); - expect(data.map(item => item.description)).to.eql(visualizations.map(v => v.description)); - }); +bdd.it('should have shared-items-count set to the number of visualizations', function checkSavedItemsCount() { + const visualizations = PageObjects.dashboard.getTestVisualizations(); + return PageObjects.common.tryForTime(10000, () => PageObjects.dashboard.getSharedItemsCount()) + .then(function (count) { + PageObjects.common.log('shared-items-count = ' + count); + expect(count).to.eql(visualizations.length); }); +}); + +bdd.it('should have panels with expected shared-item title and description', function checkTitles() { + const visualizations = PageObjects.dashboard.getTestVisualizations(); + return PageObjects.common.tryForTime(10000, function () { + return PageObjects.dashboard.getPanelSharedItemData() + .then(function (data) { + expect(data.map(item => item.title)).to.eql(visualizations.map(v => v.name)); + expect(data.map(item => item.description)).to.eql(visualizations.map(v => v.description)); + }); }); bdd.it('add new visualization link', async function checkTitles() { diff --git a/test/functional/apps/dashboard/_dashboard_save.js b/test/functional/apps/dashboard/_dashboard_save.js index 0b01501498e2e..764f502d6ff79 100644 --- a/test/functional/apps/dashboard/_dashboard_save.js +++ b/test/functional/apps/dashboard/_dashboard_save.js @@ -52,6 +52,7 @@ bdd.describe('dashboard save', function describeIndexTests() { async function() { await PageObjects.dashboard.clickDashboardByLinkText(dashboardName); await PageObjects.header.isGlobalLoadingIndicatorHidden(); + await PageObjects.dashboard.clickEdit(); await PageObjects.dashboard.saveDashboard(dashboardName); const isConfirmOpen = await PageObjects.common.isConfirmModalOpen(); @@ -60,6 +61,7 @@ bdd.describe('dashboard save', function describeIndexTests() { ); bdd.it('Warns you when you Save as New Dashboard, and the title is a duplicate', async function() { + await PageObjects.dashboard.clickEdit(); await PageObjects.dashboard.enterDashboardTitleAndClickSave(dashboardName, { saveAsNew: true }); const isConfirmOpen = await PageObjects.common.isConfirmModalOpen(); @@ -76,6 +78,7 @@ bdd.describe('dashboard save', function describeIndexTests() { }); bdd.it('Warns when case is different', async function() { + await PageObjects.dashboard.clickEdit(); await PageObjects.dashboard.enterDashboardTitleAndClickSave(dashboardName.toUpperCase()); const isConfirmOpen = await PageObjects.common.isConfirmModalOpen(); diff --git a/test/functional/apps/dashboard/_dashboard_time.js b/test/functional/apps/dashboard/_dashboard_time.js index 24307d4971209..b4d8966072712 100644 --- a/test/functional/apps/dashboard/_dashboard_time.js +++ b/test/functional/apps/dashboard/_dashboard_time.js @@ -11,6 +11,10 @@ const toTime = '2015-09-23 18:31:44.000'; bdd.describe('dashboard time', function dashboardSaveWithTime() { bdd.before(async function () { await PageObjects.dashboard.initTests(); + + // This flip between apps fixes the url so state is preserved when switching apps in test mode. + await PageObjects.header.clickVisualize(); + await PageObjects.header.clickDashboard(); }); bdd.describe('dashboard without stored timed', async function () { @@ -34,6 +38,7 @@ bdd.describe('dashboard time', function dashboardSaveWithTime() { bdd.describe('dashboard with stored timed', async function () { bdd.it('is saved with quick time', async function () { + await PageObjects.dashboard.clickEdit(); await PageObjects.header.setQuickTime('Today'); await PageObjects.dashboard.saveDashboard(dashboardName, { storeTimeWithDashboard: true }); }); @@ -48,6 +53,7 @@ bdd.describe('dashboard time', function dashboardSaveWithTime() { }); bdd.it('is saved with absolute time', async function () { + await PageObjects.dashboard.clickEdit(); await PageObjects.header.setAbsoluteRange(fromTime, toTime); await PageObjects.dashboard.saveDashboard(dashboardName, { storeTimeWithDashboard: true }); }); @@ -62,6 +68,19 @@ bdd.describe('dashboard time', function dashboardSaveWithTime() { expect(fromTimeNext).to.equal(fromTime); expect(toTimeNext).to.equal(toTime); }); + + // If the user has time stored with a dashboard, it's supposed to override the current time settings + // when it's opened. However, if the user then changes the time, navigates to visualize, then navigates + // back to dashboard, the overridden time should be preserved. The time is *only* reset on open, not + // during navigation or page refreshes. + bdd.it('preserves time changes during navigation', async function () { + await PageObjects.header.setQuickTime('Today'); + await PageObjects.header.clickVisualize(); + await PageObjects.header.clickDashboard(); + + const prettyPrint = await PageObjects.header.getPrettyDuration(); + expect(prettyPrint).to.equal('Today'); + }); }); }); diff --git a/test/functional/apps/dashboard/_view_edit.js b/test/functional/apps/dashboard/_view_edit.js new file mode 100644 index 0000000000000..7123fd146ee6d --- /dev/null +++ b/test/functional/apps/dashboard/_view_edit.js @@ -0,0 +1,283 @@ +import expect from 'expect.js'; + +import { bdd } from '../../../support'; +import PageObjects from '../../../support/page_objects'; + +const dashboardName = 'Dashboard View Edit Test'; + +bdd.describe('dashboard view edit mode', function viewEditModeTests() { + bdd.before(async function () { + return PageObjects.dashboard.initTests(); + }); + + bdd.it('create new dashboard opens in edit mode', async function () { + // This flip between apps fixes the url so state is preserved when switching apps in test mode. + // Without this flip the url in test mode looks something like + // "http://localhost:5620/app/kibana?_t=1486069030837#/dashboard?_g=...." + // after the initial flip, the url will look like this: "http://localhost:5620/app/kibana#/dashboard?_g=...." + await PageObjects.header.clickVisualize(); + await PageObjects.header.clickDashboard(); + + await PageObjects.dashboard.clickNewDashboard(); + await PageObjects.dashboard.clickCancelOutOfEditMode(); + }); + + bdd.it('create test dashboard', async function () { + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.dashboard.clickNewDashboard(); + await PageObjects.dashboard.addVisualizations(PageObjects.dashboard.getTestVisualizationNames()); + await PageObjects.dashboard.saveDashboard(dashboardName); + }); + + bdd.it('existing dashboard opens in view mode', async function () { + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.dashboard.clickDashboardByLinkText(dashboardName); + const inViewMode = await PageObjects.dashboard.getIsInViewMode(); + + expect(inViewMode).to.equal(true); + }); + + bdd.describe('panel edit controls', function () { + bdd.it('are hidden in view mode', async function () { + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.dashboard.clickDashboardByLinkText(dashboardName); + + const editLinkExists = await PageObjects.common.doesTestSubjectExist('dashboardPanelEditLink'); + const moveExists = await PageObjects.common.doesTestSubjectExist('dashboardPanelMoveIcon'); + const removeExists = await PageObjects.common.doesTestSubjectExist('dashboardPanelRemoveIcon'); + + expect(editLinkExists).to.equal(false); + expect(moveExists).to.equal(false); + expect(removeExists).to.equal(false); + }); + + bdd.it('are shown in edit mode', async function () { + await PageObjects.dashboard.clickEdit(); + + const editLinkExists = await PageObjects.common.doesTestSubjectExist('dashboardPanelEditLink'); + const moveExists = await PageObjects.common.doesTestSubjectExist('dashboardPanelMoveIcon'); + const removeExists = await PageObjects.common.doesTestSubjectExist('dashboardPanelRemoveIcon'); + + expect(editLinkExists).to.equal(true); + expect(moveExists).to.equal(true); + expect(removeExists).to.equal(true); + }); + + bdd.describe('on an expanded panel', function () { + bdd.it('are hidden in view mode', async function () { + await PageObjects.dashboard.saveDashboard(dashboardName); + await PageObjects.dashboard.toggleExpandPanel(); + + const editLinkExists = await PageObjects.common.doesTestSubjectExist('dashboardPanelEditLink'); + const moveExists = await PageObjects.common.doesTestSubjectExist('dashboardPanelMoveIcon'); + const removeExists = await PageObjects.common.doesTestSubjectExist('dashboardPanelRemoveIcon'); + + expect(editLinkExists).to.equal(false); + expect(moveExists).to.equal(false); + expect(removeExists).to.equal(false); + }); + + bdd.it('in edit mode hides move and remove icons ', async function () { + await PageObjects.dashboard.clickEdit(); + + const editLinkExists = await PageObjects.common.doesTestSubjectExist('dashboardPanelEditLink'); + const moveExists = await PageObjects.common.doesTestSubjectExist('dashboardPanelMoveIcon'); + const removeExists = await PageObjects.common.doesTestSubjectExist('dashboardPanelRemoveIcon'); + + expect(editLinkExists).to.equal(true); + expect(moveExists).to.equal(false); + expect(removeExists).to.equal(false); + + await PageObjects.dashboard.toggleExpandPanel(); + }); + }); + }); + + // Panel expand should also be shown in view mode, but only on mouse hover. + bdd.describe('panel expand control shown in edit mode', async function () { + const expandExists = await PageObjects.common.doesTestSubjectExist('dashboardPanelExpandIcon'); + expect(expandExists).to.equal(true); + }); + + bdd.it('save auto exits out of edit mode', async function () { + await PageObjects.dashboard.saveDashboard(dashboardName); + const isViewMode = await PageObjects.dashboard.getIsInViewMode(); + + expect(isViewMode).to.equal(true); + }); + + bdd.describe('shows lose changes warning', async function () { + bdd.describe('and loses changes on confirmation', function () { + bdd.it('when time changed is stored with dashboard', async function () { + await PageObjects.dashboard.clickEdit(); + const originalFromTime = '2015-09-19 06:31:44.000'; + const originalToTime = '2015-09-19 06:31:44.000'; + await PageObjects.header.setAbsoluteRange(originalFromTime, originalToTime); + await PageObjects.dashboard.saveDashboard(dashboardName, { storeTimeWithDashboard: true }); + + await PageObjects.dashboard.clickEdit(); + await PageObjects.header.setAbsoluteRange('2013-09-19 06:31:44.000', '2013-09-19 06:31:44.000'); + await PageObjects.dashboard.clickCancelOutOfEditMode(); + + // confirm lose changes + await PageObjects.common.clickConfirmOnModal(); + + const newFromTime = await PageObjects.header.getFromTime(); + const newToTime = await PageObjects.header.getToTime(); + + expect(newFromTime).to.equal(originalFromTime); + expect(newToTime).to.equal(originalToTime); + }); + + bdd.it('when the query is edited and applied', async function () { + await PageObjects.dashboard.clickEdit(); + + const originalQuery = await PageObjects.dashboard.getQuery(); + await PageObjects.dashboard.appendQuery('extra stuff'); + await PageObjects.dashboard.clickFilterButton(); + + await PageObjects.dashboard.clickCancelOutOfEditMode(); + + // confirm lose changes + await PageObjects.common.clickConfirmOnModal(); + + const query = await PageObjects.dashboard.getQuery(); + expect(query).to.equal(originalQuery); + }); + + bdd.it('when a filter is deleted', async function () { + await PageObjects.dashboard.clickEdit(); + await PageObjects.dashboard.setTimepickerInDataRange(); + await PageObjects.dashboard.filterOnPieSlice(); + await PageObjects.dashboard.saveDashboard(dashboardName); + + // This may seem like a pointless line but there was a bug that only arose when the dashboard + // was loaded initially + await PageObjects.dashboard.loadSavedDashboard(dashboardName); + await PageObjects.dashboard.clickEdit(); + + const originalFilters = await PageObjects.dashboard.getFilters(); + + // Click to cause hover menu to show up, but it will also actually click the filter, which will turn + // it off, so we need to click twice to turn it back on. + await originalFilters[0].click(); + await originalFilters[0].click(); + + const removeFilterButton = await PageObjects.common.findTestSubject('removeFilter-memory'); + await removeFilterButton.click(); + + const noFilters = await PageObjects.dashboard.getFilters(1000); + expect(noFilters.length).to.equal(0); + + await PageObjects.dashboard.clickCancelOutOfEditMode(); + + // confirm lose changes + await PageObjects.common.clickConfirmOnModal(); + + const reloadedFilters = await PageObjects.dashboard.getFilters(); + expect(reloadedFilters.length).to.equal(1); + }); + + bdd.it('when a new vis is added', async function () { + await PageObjects.dashboard.loadSavedDashboard(dashboardName); + await PageObjects.dashboard.clickEdit(); + + await PageObjects.dashboard.clickAddVisualization(); + await PageObjects.dashboard.clickAddNewVisualizationLink(); + await PageObjects.visualize.clickAreaChart(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.visualize.saveVisualization('new viz panel'); + + await PageObjects.dashboard.clickCancelOutOfEditMode(); + + // confirm lose changes + await PageObjects.common.clickConfirmOnModal(); + + const visualizations = PageObjects.dashboard.getTestVisualizations(); + const panelTitles = await PageObjects.dashboard.getPanelSizeData(); + expect(panelTitles.length).to.eql(visualizations.length); + }); + + bdd.it('when an existing vis is added', async function () { + await PageObjects.dashboard.loadSavedDashboard(dashboardName); + await PageObjects.dashboard.clickEdit(); + + await PageObjects.dashboard.addVisualization('new viz panel'); + await PageObjects.dashboard.clickCancelOutOfEditMode(); + + // confirm lose changes + await PageObjects.common.clickConfirmOnModal(); + + const visualizations = PageObjects.dashboard.getTestVisualizations(); + const panelTitles = await PageObjects.dashboard.getPanelSizeData(); + expect(panelTitles.length).to.eql(visualizations.length); + }); + }); + + bdd.describe('and preserves edits on cancel', function () { + bdd.it('when time changed is stored with dashboard', async function () { + await PageObjects.dashboard.clickEdit(); + const newFromTime = '2015-09-19 06:31:44.000'; + const newToTime = '2015-09-19 06:31:44.000'; + await PageObjects.header.setAbsoluteRange('2013-09-19 06:31:44.000', '2013-09-19 06:31:44.000'); + await PageObjects.dashboard.saveDashboard(dashboardName, true); + await PageObjects.dashboard.clickEdit(); + await PageObjects.header.setAbsoluteRange(newToTime, newToTime); + await PageObjects.dashboard.clickCancelOutOfEditMode(); + + await PageObjects.common.clickCancelOnModal(); + await PageObjects.dashboard.saveDashboard(dashboardName, { storeTimeWithDashboard: true }); + + await PageObjects.dashboard.loadSavedDashboard(dashboardName); + + const fromTime = await PageObjects.header.getFromTime(); + const toTime = await PageObjects.header.getToTime(); + + expect(fromTime).to.equal(newFromTime); + expect(toTime).to.equal(newToTime); + }); + }); + }); + + bdd.describe('Does not show lose changes warning', async function () { + bdd.it('when time changed is not stored with dashboard', async function () { + await PageObjects.dashboard.clickEdit(); + await PageObjects.dashboard.saveDashboard(dashboardName, { storeTimeWithDashboard: false }); + await PageObjects.dashboard.clickEdit(); + await PageObjects.header.setAbsoluteRange('2013-10-19 06:31:44.000', '2013-12-19 06:31:44.000'); + await PageObjects.dashboard.clickCancelOutOfEditMode(); + + const isOpen = await PageObjects.common.isConfirmModalOpen(); + expect(isOpen).to.be(false); + }); + + bdd.it('when a dashboard has a filter and remains unchanged', async function () { + await PageObjects.dashboard.clickEdit(); + await PageObjects.dashboard.setTimepickerInDataRange(); + await PageObjects.dashboard.filterOnPieSlice(); + await PageObjects.dashboard.saveDashboard(dashboardName); + await PageObjects.dashboard.clickEdit(); + await PageObjects.dashboard.clickCancelOutOfEditMode(); + + const isOpen = await PageObjects.common.isConfirmModalOpen(); + expect(isOpen).to.be(false); + }); + + // See https://github.com/elastic/kibana/issues/10110 - this is intentional. + bdd.it('when the query is edited but not applied', async function () { + await PageObjects.dashboard.clickEdit(); + + const originalQuery = await PageObjects.dashboard.getQuery(); + await PageObjects.dashboard.appendQuery('extra stuff'); + + await PageObjects.dashboard.clickCancelOutOfEditMode(); + + const isOpen = await PageObjects.common.isConfirmModalOpen(); + expect(isOpen).to.be(false); + + await PageObjects.dashboard.loadSavedDashboard(dashboardName); + const query = await PageObjects.dashboard.getQuery(); + expect(query).to.equal(originalQuery); + }); + }); +}); diff --git a/test/functional/apps/dashboard/index.js b/test/functional/apps/dashboard/index.js index 21d22edc5f8bd..8b5261b7a423a 100644 --- a/test/functional/apps/dashboard/index.js +++ b/test/functional/apps/dashboard/index.js @@ -8,6 +8,7 @@ bdd.describe('dashboard app', function () { return remote.setWindowSize(1200,800); }); + require('./_view_edit'); require('./_dashboard'); require('./_dashboard_save'); require('./_dashboard_time'); diff --git a/test/functional/apps/visualize/_shared_item.js b/test/functional/apps/visualize/_shared_item.js index 67eaf88c27495..61480ca9eb6f0 100644 --- a/test/functional/apps/visualize/_shared_item.js +++ b/test/functional/apps/visualize/_shared_item.js @@ -20,6 +20,7 @@ bdd.describe('visualize app', function describeIndexTests() { title: 'Shared-Item Visualization AreaChart', description: 'AreaChart' }; + return PageObjects.visualize.clickVisualizationByName('Shared-Item Visualization AreaChart') .then (() => PageObjects.common.try(function () { return PageObjects.common.getSharedItemTitleAndDescription() diff --git a/test/functional/index.js b/test/functional/index.js index 605bb1bee9d5d..4b067580a087f 100644 --- a/test/functional/index.js +++ b/test/functional/index.js @@ -35,8 +35,8 @@ define(function (require) { 'intern/dojo/node!./apps/visualize', 'intern/dojo/node!./apps/console', 'intern/dojo/node!./apps/dashboard', - 'intern/dojo/node!./apps/context', - 'intern/dojo/node!./status_page' + 'intern/dojo/node!./status_page', + 'intern/dojo/node!./apps/context' ].filter((suite) => { if (!requestedApps) return true; return requestedApps.reduce((previous, app) => { diff --git a/test/support/page_objects/common.js b/test/support/page_objects/common.js index 89a5737a86220..c4b9221be6c4f 100644 --- a/test/support/page_objects/common.js +++ b/test/support/page_objects/common.js @@ -341,14 +341,28 @@ export default class Common { }; } + /** + * Makes sure the modal overlay is not showing, tries a few times in case it is in the process of hiding. + */ + async ensureModalOverlayHidden() { + return PageObjects.common.try(async () => { + const shown = await this.doesTestSubjectExist('modalOverlay'); + if (shown) { + throw new Error('Modal overlay is showing'); + } + }); + } + async clickConfirmOnModal() { this.debug('Clicking modal confirm'); await this.findTestSubject('confirmModalConfirmButton').click(); + await this.ensureModalOverlayHidden(); } async clickCancelOnModal() { this.debug('Clicking modal cancel'); await this.findTestSubject('confirmModalCancelButton').click(); + await this.ensureModalOverlayHidden(); } async isConfirmModalOpen() { diff --git a/test/support/page_objects/dashboard_page.js b/test/support/page_objects/dashboard_page.js index cfba7d3b1efdb..ce1288a6244d7 100644 --- a/test/support/page_objects/dashboard_page.js +++ b/test/support/page_objects/dashboard_page.js @@ -1,11 +1,13 @@ import _ from 'lodash'; import { defaultFindTimeout } from '../'; + import { scenarioManager, esClient, elasticDump } from '../'; + import PageObjects from './'; export default class DashboardPage { @@ -50,6 +52,36 @@ export default class DashboardPage { } } + async getQuery() { + const queryObject = await PageObjects.common.findTestSubject('dashboardQuery'); + return queryObject.getProperty('value'); + } + + appendQuery(query) { + return PageObjects.common.findTestSubject('dashboardQuery').type(query); + } + + clickFilterButton() { + return PageObjects.common.findTestSubject('dashboardQueryFilterButton') + .click(); + } + + clickEdit() { + PageObjects.common.debug('Clicking edit'); + return PageObjects.common.findTestSubject('dashboardEditMode') + .click(); + } + + getIsInViewMode() { + PageObjects.common.debug('getIsInViewMode'); + return PageObjects.common.doesTestSubjectExist('dashboardEditMode'); + } + + clickCancelOutOfEditMode() { + PageObjects.common.debug('Clicking cancel'); + return PageObjects.common.findTestSubject('dashboardViewOnlyMode').click(); + } + clickNewDashboard() { return PageObjects.common.clickTestSubject('newDashboardLink'); } @@ -135,6 +167,12 @@ export default class DashboardPage { }); } + async renameDashboard(dashName) { + PageObjects.common.debug(`Naming dashboard ` + dashName); + await PageObjects.common.findTestSubject('dashboardRenameButton').click(); + await this.findTimeout.findById('dashboardTitle').type(dashName); + } + /** * * @param dashName {String} @@ -340,6 +378,10 @@ export default class DashboardPage { } } + async getFilters(timeout = defaultFindTimeout) { + return PageObjects.common.findAllByCssSelector('.filter-bar > .filter', timeout); + } + async getFilterDescriptions(timeout = defaultFindTimeout) { const filters = await PageObjects.common.findAllByCssSelector( '.filter-bar > .filter > .filter-description', @@ -349,9 +391,24 @@ export default class DashboardPage { async filterOnPieSlice() { PageObjects.common.debug('Filtering on a pie slice'); - const slices = await PageObjects.common.findAllByCssSelector('svg > g > path.slice'); - PageObjects.common.debug('Slices found:' + slices.length); - return slices[0].click(); + await PageObjects.common.try(async () => { + const slices = await PageObjects.common.findAllByCssSelector('svg > g > path.slice'); + PageObjects.common.debug('Slices found:' + slices.length); + return slices[0].click(); + }); + } + + async toggleExpandPanel() { + PageObjects.common.debug('toggleExpandPanel'); + const expandShown = await PageObjects.common.doesTestSubjectExist('dashboardPanelExpandIcon'); + if (!expandShown) { + const panelElements = await this.findTimeout.findAllByCssSelector('span.panel-title'); + PageObjects.common.debug('click title'); + await panelElements[0].click(); // Click to simulate hover. + } + const expandButton = await PageObjects.common.findTestSubject('dashboardPanelExpandIcon'); + PageObjects.common.debug('click expand icon'); + expandButton.click(); } getSharedItemsCount() { @@ -382,5 +439,4 @@ export default class DashboardPage { })); }); } - } diff --git a/test/support/page_objects/header_page.js b/test/support/page_objects/header_page.js index 2a334707e3cd6..a7e788afdc286 100644 --- a/test/support/page_objects/header_page.js +++ b/test/support/page_objects/header_page.js @@ -60,9 +60,31 @@ export default class HeaderPage { .catch(() => false); } - clickAbsoluteButton() { + async clickAbsoluteButton() { + await PageObjects.common.try(async () => { + await this.remote.setFindTimeout(defaultFindTimeout); + const absoluteButton = await this.remote.findByLinkText('Absolute'); + await absoluteButton.click(); + }); + } + + clickQuickButton() { return this.remote.setFindTimeout(defaultFindTimeout) - .findByLinkText('Absolute').click(); + .findByLinkText('Quick').click(); + } + + async getFromTime() { + await this.ensureTimePickerIsOpen(); + return this.remote.setFindTimeout(defaultFindTimeout) + .findByCssSelector('input[ng-model=\'absolute.from\']') + .getProperty('value'); + } + + async getToTime() { + await this.ensureTimePickerIsOpen(); + return this.remote.setFindTimeout(defaultFindTimeout) + .findByCssSelector('input[ng-model=\'absolute.to\']') + .getProperty('value'); } async getFromTime() { @@ -106,24 +128,58 @@ export default class HeaderPage { setAbsoluteRange(fromTime, toTime) { PageObjects.common.debug('clickTimepicker'); return this.clickTimepicker() - .then(() => { - PageObjects.common.debug('--Clicking Absolute button'); - return this.clickAbsoluteButton(); - }) - .then(() => { - PageObjects.common.debug('--Setting From Time : ' + fromTime); - return this.setFromTime(fromTime); - }) - .then(() => { - PageObjects.common.debug('--Setting To Time : ' + toTime); - return this.setToTime(toTime); - }) - .then(() => { - return this.clickGoButton(); - }) - .then(() => { - return this.waitUntilLoadingHasFinished(); - }); + .then(() => { + PageObjects.common.debug('--Clicking Absolute button'); + return this.clickAbsoluteButton(); + }) + .then(() => { + PageObjects.common.debug('--Setting From Time : ' + fromTime); + return this.setFromTime(fromTime); + }) + .then(() => { + PageObjects.common.debug('--Setting To Time : ' + toTime); + return this.setToTime(toTime); + }) + .then(() => { + return this.clickGoButton(); + }) + .then(() => { + return this.waitUntilLoadingHasFinished(); + }); + } + + async ensureTimePickerIsOpen() { + const isOpen = await PageObjects.header.isTimepickerOpen(); + PageObjects.common.debug(`time picker open: ${isOpen}`); + if (!isOpen) { + PageObjects.common.debug('--Opening time picker'); + await PageObjects.header.clickTimepicker(); + } + } + + async setAbsoluteRange(fromTime, toTime) { + PageObjects.common.debug(`Setting absolute range to ${fromTime} to ${toTime}`); + await this.ensureTimePickerIsOpen(); + PageObjects.common.debug('--Clicking Absolute button'); + await this.clickAbsoluteButton(); + PageObjects.common.debug('--Setting From Time : ' + fromTime); + await this.setFromTime(fromTime); + PageObjects.common.debug('--Setting To Time : ' + toTime); + await this.setToTime(toTime); + await this.clickGoButton(); + await this.isGlobalLoadingIndicatorHidden(); + } + + async setQuickTime(quickTime) { + await this.ensureTimePickerIsOpen(); + PageObjects.common.debug('--Clicking Quick button'); + await this.clickQuickButton(); + await this.remote.setFindTimeout(defaultFindTimeout) + .findByLinkText(quickTime).click(); + } + + async getPrettyDuration() { + return await PageObjects.common.findTestSubject('globalTimepickerRange').getVisibleText(); } getToastMessage() {