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 7308316ea881c..ed84012deffd3 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() { @@ -81,42 +80,47 @@ bdd.describe('dashboard tab', function describeIndexTests() { }); }); }); +}); - 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() {