From a89ce34dc31da045889f98c140bbb25f98b60c0c Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Sat, 25 Feb 2017 12:24:02 -0500 Subject: [PATCH 01/17] Start at view/edit mode 4 --- .../dashboard/__tests__/dashboard_state.js | 91 +++---- .../kibana/public/dashboard/dashboard.html | 9 + .../kibana/public/dashboard/dashboard.js | 121 +++++++-- .../public/dashboard/dashboard_constants.js | 3 +- .../public/dashboard/dashboard_state.js | 230 ++++++++++++++++-- .../public/dashboard/dashboard_strings.js | 34 +++ .../public/dashboard/dashboard_view_mode.js | 13 + .../kibana/public/dashboard/grid.js | 22 +- .../kibana/public/dashboard/panel/panel.html | 8 +- .../kibana/public/dashboard/panel/panel.js | 14 ++ .../kibana/public/dashboard/styles/index.less | 15 +- .../dashboard/top_nav/get_top_nav_config.js | 69 ++++-- .../public/dashboard/top_nav/options.html | 18 +- .../public/dashboard/top_nav/rename.html | 11 + .../kibana/public/dashboard/top_nav/save.html | 33 --- .../public/dashboard/top_nav/top_nav_ids.js | 10 + src/ui/public/kbn_top_nav/kbn_top_nav.js | 23 +- src/ui/public/state_management/app_state.js | 2 +- src/ui/public/state_management/state.js | 3 +- test/functional/apps/dashboard/_dashboard.js | 101 ++++---- .../apps/dashboard/_dashboard_time.js | 8 +- test/functional/apps/dashboard/_view_edit.js | 97 ++++++++ test/functional/apps/dashboard/index.js | 1 + .../functional/apps/visualize/_shared_item.js | 1 + test/functional/index.js | 1 + test/support/page_objects/common.js | 2 + test/support/page_objects/dashboard_page.js | 34 ++- test/support/page_objects/header_page.js | 91 +++++-- 28 files changed, 820 insertions(+), 245 deletions(-) create mode 100644 src/core_plugins/kibana/public/dashboard/dashboard_strings.js create mode 100644 src/core_plugins/kibana/public/dashboard/dashboard_view_mode.js create mode 100644 src/core_plugins/kibana/public/dashboard/top_nav/rename.html delete mode 100644 src/core_plugins/kibana/public/dashboard/top_nav/save.html create mode 100644 src/core_plugins/kibana/public/dashboard/top_nav/top_nav_ids.js create mode 100644 test/functional/apps/dashboard/_view_edit.js diff --git a/src/core_plugins/kibana/public/dashboard/__tests__/dashboard_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..be061053e81a3 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" > @@ -60,9 +62,15 @@

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..71b03d0fc56a7 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard.js +++ b/src/core_plugins/kibana/public/dashboard/dashboard.js @@ -7,6 +7,8 @@ import chrome from 'ui/chrome'; import 'plugins/kibana/dashboard/grid'; import 'plugins/kibana/dashboard/panel/panel'; +import { DashboardStrings } from './dashboard_strings'; +import { DashboardViewMode } from './dashboard_view_mode'; import dashboardTemplate from 'plugins/kibana/dashboard/dashboard.html'; import FilterBarQueryFilterProvider from 'ui/filter_bar/query_filter'; import DocTitleProvider from 'ui/doc_title'; @@ -15,7 +17,9 @@ import { DashboardConstants } from './dashboard_constants'; import { VisualizeConstants } from 'plugins/kibana/visualize/visualize_constants'; import UtilsBrushEventProvider from 'ui/utils/brush_event'; import FilterBarFilterBarClickHandlerProvider from 'ui/filter_bar/filter_bar_click_handler'; +import { getPersistedStateId } from 'plugins/kibana/dashboard/panel/panel_state'; import { DashboardState } from './dashboard_state'; +import { TopNavIds } from './top_nav/top_nav_ids'; const app = uiModules.get('app/dashboard', [ 'elasticsearch', @@ -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); @@ -67,10 +71,18 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter, docTitle.change(dash.title); } + // Brand new dashboards are defaulted to edit mode, existing ones default to view mode, except when trumped + // by a url param. + const defaultViewMode = + $route.current.params[DashboardConstants.VIEW_MODE_PARAM] || + (dash.id ? DashboardViewMode.VIEW : DashboardViewMode.EDIT); + kbnUrl.removeParam(DashboardConstants.VIEW_MODE_PARAM); + const dashboardState = new DashboardState( dash, timefilter, !getAppState.previouslyStored(), + defaultViewMode, quickRanges, AppState); @@ -92,20 +104,22 @@ 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.getBrushEvent = () => brushEvent(dashboardState.getAppState()); $scope.getFilterBarClickHandler = () => filterBarClickHandler(dashboardState.getAppState()); + $scope.expandedPanel = null; + $scope.hasExpandedPanel = () => $scope.expandedPanel !== null; - $scope.getDashTitle = () => { - return dashboardState.dashboard.lastSavedTitle || `${dashboardState.dashboard.title} (unsaved)`; - }; + $scope.getDashTitle = () => DashboardStrings.getDashboardTitle(dashboardState); $scope.newDashboard = () => { kbnUrl.change(DashboardConstants.CREATE_NEW_DASHBOARD_URL, {}); }; $scope.saveState = () => dashboardState.saveState(); @@ -138,7 +152,11 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter, }; $scope.showEditHelpText = () => { - return !dashboardState.getPanels().length; + return !dashboardState.getPanels().length && dashboardState.getViewMode() === DashboardViewMode.EDIT; + }; + + $scope.showViewHelpText = () => { + return !dashboardState.getPanels().length && dashboardState.getViewMode() === DashboardViewMode.VIEW; }; /** @@ -154,14 +172,26 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter, $scope.onPanelRemoved = (panelIndex) => dashboardState.removePanel(panelIndex); + $scope.$watch('model.query', () => dashboardState.setQuery($scope.model.query)); + $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); + $scope.save = function () { + // Make sure to save the latest query, even if 'enter' hasn't been hit. + dashboardState.updateFilters(queryFilter); return dashboardState.saveDashboard(angular.toJson).then(function (id) { $scope.kbnTopNav.close('save'); if (id) { notify.info(`Saved Dashboard as "${dash.title}"`); if (dash.id !== $routeParams.id) { kbnUrl.change( - `${DashboardConstants.EXISTING_DASHBOARD_URL}`, + `${DashboardConstants.EXISTING_DASHBOARD_URL}?${DashboardConstants.VIEW_MODE_PARAM}=${DashboardViewMode.EDIT}`, { id: dash.id }); } else { docTitle.change(dash.lastSavedTitle); @@ -170,15 +200,6 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter, }).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); @@ -195,8 +216,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() { @@ -209,6 +229,60 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter, chrome.addApplicationClass('theme-light'); } + // Defined up here, but filled in below, to avoid 'Defined before use' warning due to circular reference: + // changeViewMode uses navActions, and navActions uses changeViewMode. + const navActions = {}; + + const changeViewMode = (newMode) => { + const isPageRefresh = newMode === dashboardState.getViewMode(); + const leavingEditMode = !isPageRefresh && newMode === DashboardViewMode.VIEW; + + function doModeSwitch() { + $scope.dashboardViewMode = newMode; + $scope.topNavMenu = getTopNavConfig(newMode, navActions); + dashboardState.switchViewMode(newMode); + } + + function onCancel() { + dashboardState.reloadLastSavedFilters(); + const refreshUrl = dashboardState.getReloadDashboardUrl(); + dashboardState.resetState(); + kbnUrl.change(refreshUrl.url, refreshUrl.options, new AppState()); + doModeSwitch(); + } + + if (leavingEditMode && dashboardState.getIsDirty()) { + confirmModal( + DashboardStrings.getUnsavedChangesWarningMessage(dashboardState), + { + onConfirm: () => $scope.save().then(doModeSwitch), + onCancel, + onClose: _.noop, + confirmButtonText: 'Save dashboard', + cancelButtonText: 'Lose changes', + title: `Save dashboard ${dashboardState.getTitle()}`, + showClose: true + } + ); + } else { + // No special handling, just make the switch. + doModeSwitch(); + } + }; + + navActions[TopNavIds.SAVE] = () => { + $scope.save().then(() => changeViewMode(DashboardViewMode.VIEW)); + }; + navActions[TopNavIds.CLONE] = () => { + dashboardState.setTitle(dashboardState.getTitle() + ' (Copy)'); + dash.copyOnSave = true; + $scope.save(); + }; + navActions[TopNavIds.EXIT_EDIT_MODE] = () => changeViewMode(DashboardViewMode.VIEW); + navActions[TopNavIds.ENTER_EDIT_MODE] = () => changeViewMode(DashboardViewMode.EDIT); + + changeViewMode(dashboardState.getViewMode()); + $scope.$on('ready:vis', function () { if (pendingVisCount > 0) pendingVisCount--; if (pendingVisCount === 0) { @@ -228,10 +302,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 = { - dashboard: dash, - ui: dashboardState.getOptions(), + displayName: dash.getDisplayName(), save: $scope.save, addVis: $scope.addVis, addNewVis, diff --git a/src/core_plugins/kibana/public/dashboard/dashboard_constants.js b/src/core_plugins/kibana/public/dashboard/dashboard_constants.js index f74c7050440e6..197a9e94dd94b 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard_constants.js +++ b/src/core_plugins/kibana/public/dashboard/dashboard_constants.js @@ -4,5 +4,6 @@ export const DashboardConstants = { NEW_VISUALIZATION_ID_PARAM: 'addVisualization', LANDING_PAGE_PATH: '/dashboard', CREATE_NEW_DASHBOARD_URL: '/dashboard/create', - EXISTING_DASHBOARD_URL: '/dashboard/{{id}}' + EXISTING_DASHBOARD_URL: '/dashboard/{{id}}', + VIEW_MODE_PARAM: 'viewMode' }; diff --git a/src/core_plugins/kibana/public/dashboard/dashboard_state.js b/src/core_plugins/kibana/public/dashboard/dashboard_state.js index 4b358a4198bf1..5a87713eee457 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard_state.js +++ b/src/core_plugins/kibana/public/dashboard/dashboard_state.js @@ -1,15 +1,18 @@ import _ from 'lodash'; import { FilterUtils } from './filter_utils'; + +import { DashboardConstants } from './dashboard_constants'; +import { DashboardViewMode } from './dashboard_view_mode'; import { PanelUtils } from './panel/panel_utils'; import moment from 'moment'; import stateMonitorFactory from 'ui/state_management/state_monitor_factory'; import { createPanelState } from 'plugins/kibana/dashboard/panel/panel_state'; 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) : {}, @@ -18,31 +21,82 @@ function getStateDefaults(dashboard) { }; } +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. - */ - constructor(dashboard, timefilter, timeFilterPreviouslyStored, quickTimeRanges, AppState) { - this.stateDefaults = getStateDefaults(dashboard); + constructor(dashboard, timefilter, timeFilterPreviouslyStored, defaultViewMode, quickTimeRanges, AppState) { + this.dashboard = dashboard; + + this.stateDefaults = getStateDefaults(this.dashboard); + this.stateDefaults.viewMode = defaultViewMode; this.appState = new AppState(this.stateDefaults); this.uiState = this.appState.makeStateful('uiState'); + this.isDirty = false; this.timefilter = timefilter; - this.dashboard = dashboard; - this.stateMonitor = stateMonitorFactory.create(this.appState, this.stateDefaults); + this.lastSavedDashboardFilters = this.getFilterState(); + + // Unfortunately there is a hashkey stored with the filters on the appState, but not always + // in the dashboard searchSource. On a page refresh with a filter, this will cause the state + // monitor to count them as different even if they aren't. Hence this is a bit of a hack to get around + // that. TODO: Improve state monitor factory to take custom comparison functions for certain paths. + if (!this.getFilterBarChanged()) { + this.stateDefaults.filters = this.appState.filters; + } if (this.getShouldSyncTimefilterWithDashboard() && timeFilterPreviouslyStored) { this.syncTimefilterWithDashboard(quickTimeRanges); } PanelUtils.initPanelIndexes(this.getPanels()); + + this.createStateMonitor(); + } + + resetState() { + this.appState.reset(); + } + + getFilterState() { + return { + timeTo: this.dashboard.timeTo, + timeFrom: this.dashboard.timeFrom, + filterBars: this.getDashboardFilterBars(), + query: this.getDashboardQuery() + }; + } + + getTitle() { + return this.appState.title; + } + + setTitle(title) { + this.appState.title = title; + this.saveState(); } getAppState() { @@ -53,8 +107,22 @@ 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(); } /** @@ -65,6 +133,53 @@ export class DashboardState { return this.dashboard.timeRestore && this.dashboard.timeTo && this.dashboard.timeFrom; } + getDashboardFilterBars() { + return FilterUtils.getFilterBarsForDashboard(this.dashboard); + } + + getDashboardQuery() { + return FilterUtils.getQueryFilterForDashboard(this.dashboard); + } + + getLastSavedFilterBars() { + return this.lastSavedDashboardFilters.filterBars; + } + + getLastSavedQuery() { + return this.lastSavedDashboardFilters.query; + } + + getQueryChanged() { + return !_.isEqual(this.appState.query, this.getLastSavedQuery()); + } + + getFilterBarChanged() { + return !_.isEqual(cleanFiltersForComparison(this.appState.filters), + cleanFiltersForComparison(this.getLastSavedFilterBars())); + } + + getTimeChanged() { + return ( + !areTimesEqual(this.lastSavedDashboardFilters.timeFrom, this.timefilter.time.from) || + !areTimesEqual(this.lastSavedDashboardFilters.timeTo, this.timefilter.time.to) + ); + } + + getViewMode() { + return this.appState.viewMode; + } + + getIsDirty() { + const existingTitleChanged = + this.dashboard.lastSavedTitle && + this.dashboard.lastSavedTitle !== this.dashboard.title; + + // Not all filter changes are tracked by the state monitor. + return this.isDirty || + this.getFiltersChangedFromLastSave() || + existingTitleChanged; + } + getPanels() { return this.appState.panels; } @@ -97,6 +212,32 @@ export class DashboardState { * Used to determine whether a relative query should be classified as a "quick" time mode or * simply a "relative" time mode. */ + getFiltersChangedFromLastSave() { + // If the dashboard hasn't been saved before, then there is no last saved state + // for the current state to differ from. + if (!this.dashboard.id) return false; + + return ( + this.getQueryChanged() || + this.getFilterBarChanged() || + (this.dashboard.timeRestore && this.getTimeChanged()) + ); + } + + getChangedFiltersForDisplay() { + const changedFilters = []; + if (this.getFilterBarChanged()) { + changedFilters.push('filter'); + } + if (this.getQueryChanged()) { + changedFilters.push('query'); + } + if (this.dashboard.timeRestore && this.getTimeChanged()) { + changedFilters.push('time range'); + } + return changedFilters; + } + syncTimefilterWithDashboard(quickTimeRanges) { this.timefilter.time.to = this.dashboard.timeTo; this.timefilter.time.from = this.dashboard.timeFrom; @@ -123,6 +264,7 @@ export class DashboardState { this.appState.save(); } + /** * Saves the dashboard. * @param toJson {function} A custom toJson function. Used because the previous code used @@ -135,16 +277,23 @@ export class DashboardState { this.saveState(); const timeRestoreObj = _.pick(this.timefilter.refreshInterval, ['display', 'pause', 'section', 'value']); + this.dashboard.title = this.appState.title; + this.dashboard.timeRestore = this.appState.timeRestore; 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.timeFrom = this.dashboard.timeRestore ? convertTimeToString(this.timefilter.time.from) : undefined; + this.dashboard.timeTo = this.dashboard.timeRestore ? convertTimeToString(this.timefilter.time.to) : undefined; this.dashboard.refreshInterval = this.dashboard.timeRestore ? timeRestoreObj : undefined; this.dashboard.optionsJSON = toJson(this.appState.options); return this.dashboard.save() .then((id) => { this.stateMonitor.setInitialState(this.appState.toJSON()); + this.lastSavedDashboardFilters = this.getFilterState(); + this.stateDefaults = getStateDefaults(this.dashboard); + this.stateDefaults.viewMode = DashboardViewMode.VIEW; + // Make sure new app state defaults are using the new defaults. + this.appState.setDefaults(this.stateDefaults); return id; }); } @@ -166,10 +315,55 @@ export class DashboardState { this.saveState(); } + createStateMonitor() { + this.stateMonitor = stateMonitorFactory.create(this.appState, this.stateDefaults); + + this.stateMonitor.ignoreProps('viewMode'); + this.stateMonitor.ignoreProps('filters'); // Filters need some tweaking to compare correctly. + + this.stateMonitor.onChange(status => { + this.isDirty = status.dirty; + }); + } + + switchViewMode(newMode) { + this.appState.viewMode = newMode; + this.saveState(); + } + destroy() { if (this.stateMonitor) { this.stateMonitor.destroy(); } this.dashboard.destroy(); } + + getReloadDashboardUrl() { + const url = this.dashboard.id ? + DashboardConstants.EXISTING_DASHBOARD_URL : DashboardConstants.CREATE_NEW_DASHBOARD_URL; + const options = this.dashboard.id ? { id: this.dashboard.id } : {}; + return { url, options }; + } + + refreshStateMonitor() { + if (this.stateMonitor) { + this.stateMonitor.destroy(); + } + this.createStateMonitor(); + } + + reloadLastSavedFilters() { + // Need to make a copy to ensure nothing overwrites the originals. + this.stateDefaults.filters = this.appState.filters = Object.assign(new Array(), this.getLastSavedFilterBars()); + this.stateDefaults.query = this.appState.query = Object.assign({}, this.getLastSavedQuery()); + + if (this.dashboard.timeRestore) { + this.timefilter.time.from = this.lastSavedDashboardFilters.timeFrom; + this.timefilter.time.to = this.lastSavedDashboardFilters.timeTo; + } + + this.refreshStateMonitor(); + this.isDirty = false; + this.saveState(); + } } 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..5e0edec1dc47f --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/dashboard_strings.js @@ -0,0 +1,34 @@ +import { DashboardViewMode } from './dashboard_view_mode'; +import _ from 'lodash'; + +export class DashboardStrings { + + static 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}`; + } + + static getUnsavedChangesWarningMessage(dashboardState) { + const changedFilters = dashboardState.getChangedFiltersForDisplay(); + const changedFilterList = this.createStringList(changedFilters); + const saveOrLoseMessage = 'You can save them or exit without saving and lose your changes.'; + + return changedFilterList ? + `You have unsaved changes, including changes to your ${changedFilterList}. ${saveOrLoseMessage}` : + `You have unsaved changes. ${saveOrLoseMessage}`; + } + + static getDashboardTitle(dashboardState) { + const isEditMode = dashboardState.getViewMode() === DashboardViewMode.EDIT; + const unsavedSuffix = isEditMode && dashboardState.getIsDirty() + ? ' (unsaved)' + : ''; + + const displayTitle = `${dashboardState.getTitle()}${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..d554d0399475f --- /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: DashboardMode, VIEW: DashboardMode}} + */ +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..e090cf792616d 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel.html +++ b/src/core_plugins/kibana/public/dashboard/panel/panel.html @@ -1,4 +1,4 @@ -
+
{{::savedObj.title}} @@ -7,13 +7,13 @@ - + - + - +
diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel.js b/src/core_plugins/kibana/public/dashboard/panel/panel.js index 79151e09f03c0..ef5bb576fef65 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,14 @@ uiModules $scope.editUrl = '#management/kibana/objects/' + service.name + '/' + id + '?notFound=' + e.savedObjectType; }); + + /** + * Determines whether or not to show edit controls. + * @returns {boolean} + */ + $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..a1633c7098794 100644 --- a/src/core_plugins/kibana/public/dashboard/styles/index.less +++ b/src/core_plugins/kibana/public/dashboard/styles/index.less @@ -34,14 +34,16 @@ dashboard-grid { } .gs-w { - border: 2px dashed transparent; + + .panel { + border: 2px dashed transparent; + } .panel .panel-heading .btn-group { display: none; } &:hover { - border-color: @kibanaGray4; dashboard-panel { .visualize-show-spy { @@ -53,6 +55,15 @@ dashboard-grid { } } + .panel--edit-mode { + border-color: @kibanaGray4; + .visualize-show-spy { + visibility: visible; + } + .panel-heading .btn-group { + display: block !important; + } + } } i.remove { 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..cebd7689cfce9 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,25 @@
Options
+ +
+ +
+