diff --git a/src/core_plugins/kibana/public/visualize/editor/editor.html b/src/core_plugins/kibana/public/visualize/editor/editor.html
index 58b0b08f5e73f..f2f613ccc6c74 100644
--- a/src/core_plugins/kibana/public/visualize/editor/editor.html
+++ b/src/core_plugins/kibana/public/visualize/editor/editor.html
@@ -78,7 +78,11 @@
diff --git a/src/core_plugins/kibana/public/visualize/editor/editor.js b/src/core_plugins/kibana/public/visualize/editor/editor.js
index 8eca1caf8882c..0d9394d098706 100644
--- a/src/core_plugins/kibana/public/visualize/editor/editor.js
+++ b/src/core_plugins/kibana/public/visualize/editor/editor.js
@@ -55,7 +55,19 @@ uiModules
'kibana/notify',
'kibana/courier'
])
-.controller('VisEditor', function ($scope, $route, timefilter, AppState, $location, kbnUrl, $timeout, courier, Private, Promise) {
+.controller('VisEditor', function (
+ $location,
+ $rootScope,
+ $route,
+ $scope,
+ $timeout,
+ AppState,
+ courier,
+ kbnUrl,
+ Private,
+ Promise,
+ timefilter,
+) {
const docTitle = Private(DocTitleProvider);
const brushEvent = Private(UtilsBrushEventProvider);
@@ -70,6 +82,40 @@ uiModules
const vis = savedVis.vis;
const editableVis = vis.createEditableVis();
+
+ // Store history of visualization state.
+ const pastStateHistory = [];
+ let futureStateHistory = [];
+
+ function resetFutureStateHistory() {
+ futureStateHistory = [];
+ }
+
+ $scope.undoStateHistory = () => {
+ // Move present state into future.
+ futureStateHistory.push(vis.getState());
+
+ // Set past state to present.
+ const lastState = pastStateHistory.pop();
+ vis.setState(lastState);
+ editableVis.setState(lastState);
+ $scope.fetch();
+ };
+
+ $scope.redoStateHistory = () => {
+ // Move present state into the past.
+ pastStateHistory.push(vis.getState());
+
+ // Set future state to present.
+ const nextState = futureStateHistory.pop();
+ vis.setState(nextState);
+ editableVis.setState(nextState);
+ $scope.fetch();
+ };
+
+ // We intend to keep editableVis and vis in sync with one another, so calling `requesting` on
+ // vis should call it on both. However, it's not clear what is the benefit of calling
+ // `editableVis.requesting()`.
vis.requesting = function () {
const requesting = editableVis.requesting;
requesting.call(vis);
@@ -146,7 +192,12 @@ uiModules
editableVis.listeners.brush = vis.listeners.brush = brushEvent;
// track state of editable vis vs. "actual" vis
- $scope.stageEditableVis = transferVisState(editableVis, vis, true);
+ $scope.stageEditableVis = () => {
+ resetFutureStateHistory();
+ pastStateHistory.push(vis.getState());
+ transferVisState(editableVis, vis, true)();
+ };
+
$scope.resetEditableVis = transferVisState(vis, editableVis);
$scope.$watch(function () {
return editableVis.getEnabledState();
diff --git a/src/core_plugins/kibana/public/visualize/editor/sidebar.html b/src/core_plugins/kibana/public/visualize/editor/sidebar.html
index 02ed5e96edae4..84a9dd054f685 100644
--- a/src/core_plugins/kibana/public/visualize/editor/sidebar.html
+++ b/src/core_plugins/kibana/public/visualize/editor/sidebar.html
@@ -35,6 +35,12 @@
+
+
+ Undo
+ Redo
+
+
- {
+ // When there's a change to the state, build a new shared URL using the new query param.
+ let search = $location.search();
+ search[param] = value;
+ const url = `${$location.absUrl().split('?')[0]}?${$httpParamSerializer(search)}`;
+ updateUrl(url);
+ });
+
+ $rootScope.$broadcast('state:triggerQueryParamChange');
- $scope.$watch('getUrl()', updateUrl);
+ // TODO: What other use cases does this address beyond query param changes?
+ // We need to address them too.
+ // $scope.$watch('getUrl()', updateUrl);
}
};
});
diff --git a/src/ui/public/state_management/state.js b/src/ui/public/state_management/state.js
index 7b934567e01ff..4326ac63f8586 100644
--- a/src/ui/public/state_management/state.js
+++ b/src/ui/public/state_management/state.js
@@ -1,4 +1,5 @@
import _ from 'lodash';
+import angular from 'angular';
import rison from 'rison-node';
import applyDiff from 'ui/utils/diff_object';
import qs from 'ui/utils/query_string';
@@ -42,6 +43,9 @@ export default function StateProvider(Private, $rootScope, $location) {
// Initialize the State with fetch
self.fetch();
+
+ // External actors in the system can request the current url params to be re-broadcast.
+ $rootScope.$on('state:triggerQueryParamChange', this.broadcastQueryParams.bind(this));
}
State.prototype._readFromURL = function () {
@@ -73,6 +77,22 @@ export default function StateProvider(Private, $rootScope, $location) {
}
_.defaults(stash, this._defaults);
+
+ // Internalize vis state on `this`. Once the vis state is initially read from the URL, it will
+ // never be read from it again.
+ // TODO: Allow properties such as vis to be dynamically specified via a public method,
+ // e.g. `internalizeParam()`.
+ if (this.toObject().vis) {
+ stash.vis = this.toObject().vis;
+ }
+
+ // TODO: Because we are removing the vis prop from the URL, it's also removed from the history.
+ // This means that navigating to a different part of the app and then hitting the browser back
+ // button will take you to an empty visualization. One possible solution is to store the current
+ // vis in the history with replaceState and pass it in as the state arg. Then, when the user
+ // navigates away and then back to this route, we need to listen for onpopstate and use the state
+ // to build the visualization.
+
// apply diff to state from stash, will change state in place via side effect
let diffResults = applyDiff(this, stash);
@@ -103,14 +123,25 @@ export default function StateProvider(Private, $rootScope, $location) {
this.emit('save_with_changes', diffResults.keys);
}
+ // Prevent vis state from being persisted to the URL. It's now represented internally on `this`.
+ delete state.vis;
+
// persist the state in the URL
let search = $location.search();
- search[this._urlParam] = this.toRISON();
+ // RISON-encode state instead of `this`, because we only want to persist certain properties to
+ // the URL, e.g. *not* vis state.
+ search[this._urlParam] = rison.encode(JSON.parse(angular.toJson(state)));
if (replace) {
$location.search(search).replace();
} else {
$location.search(search);
}
+
+ this.broadcastQueryParams();
+ };
+
+ State.prototype.broadcastQueryParams = function () {
+ $rootScope.$broadcast('state:queryParamChange', this._urlParam, this.toRISON());
};
/**