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 @@
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() {