diff --git a/src/core_plugins/kibana/public/discover/controllers/discover.js b/src/core_plugins/kibana/public/discover/controllers/discover.js
index 053e13766eaf8..111aa678b127b 100644
--- a/src/core_plugins/kibana/public/discover/controllers/discover.js
+++ b/src/core_plugins/kibana/public/discover/controllers/discover.js
@@ -2,7 +2,6 @@ import _ from 'lodash';
import angular from 'angular';
import moment from 'moment';
import getSort from 'ui/doc_table/lib/get_sort';
-import rison from 'rison-node';
import dateMath from '@elastic/datemath';
import 'ui/doc_table';
import 'ui/visualize';
@@ -26,8 +25,7 @@ import AggTypesBucketsIntervalOptionsProvider from 'ui/agg_types/buckets/_interv
import uiRoutes from 'ui/routes';
import uiModules from 'ui/modules';
import indexTemplate from 'plugins/kibana/discover/index.html';
-
-
+import StateProvider from 'ui/state_management/state';
const app = uiModules.get('apps/discover', [
'kibana/notify',
@@ -43,18 +41,25 @@ uiRoutes
template: indexTemplate,
reloadOnSearch: false,
resolve: {
- ip: function (Promise, courier, config, $location) {
+ ip: function (Promise, courier, config, $location, Private) {
+ const State = Private(StateProvider);
return courier.indexPatterns.getIds()
.then(function (list) {
- const stateRison = $location.search()._a;
-
- let state;
- try { state = rison.decode(stateRison); }
- catch (e) { state = {}; }
+ /**
+ * In making the indexPattern modifiable it was placed in appState. Unfortunately,
+ * the load order of AppState conflicts with the load order of many other things
+ * so in order to get the name of the index we should use, and to switch to the
+ * default if necessary, we parse the appState with a temporary State object and
+ * then destroy it immediatly after we're done
+ *
+ * @type {State}
+ */
+ const state = new State('_a', {});
const specified = !!state.index;
const exists = _.contains(list, state.index);
const id = exists ? state.index : config.get('defaultIndex');
+ state.destroy();
return Promise.props({
list: list,
diff --git a/src/test_utils/__tests__/stub_browser_storage.js b/src/test_utils/__tests__/stub_browser_storage.js
index 83e4a7440ddd3..215e564d3723a 100644
--- a/src/test_utils/__tests__/stub_browser_storage.js
+++ b/src/test_utils/__tests__/stub_browser_storage.js
@@ -49,10 +49,10 @@ describe('StubBrowserStorage', () => {
});
});
- describe('size limiting', () => {
+ describe('#setStubbedSizeLimit', () => {
it('allows limiting the storage size', () => {
const store = new StubBrowserStorage();
- store._setSizeLimit(10);
+ store.setStubbedSizeLimit(10);
store.setItem('abc', 'def'); // store size is 6, key.length + val.length
expect(() => {
store.setItem('ghi', 'jkl');
@@ -61,25 +61,41 @@ describe('StubBrowserStorage', () => {
it('allows defining the limit as infinity', () => {
const store = new StubBrowserStorage();
- store._setSizeLimit(Infinity);
+ store.setStubbedSizeLimit(Infinity);
store.setItem('abc', 'def');
store.setItem('ghi', 'jkl'); // unlike the previous test, this doesn't throw
});
- it('requires setting the limit before keys', () => {
+ it('throws an error if the limit is below the current size', () => {
const store = new StubBrowserStorage();
store.setItem('key', 'val');
expect(() => {
- store._setSizeLimit(10);
- }).throwError(/before setting/);
+ store.setStubbedSizeLimit(5);
+ }).throwError(Error);
});
it('respects removed items', () => {
const store = new StubBrowserStorage();
- store._setSizeLimit(10);
+ store.setStubbedSizeLimit(10);
store.setItem('abc', 'def');
store.removeItem('abc');
store.setItem('ghi', 'jkl'); // unlike the previous test, this doesn't throw
});
});
+
+ describe('#getStubbedSizeLimit', () => {
+ it('returns the size limit', () => {
+ const store = new StubBrowserStorage();
+ store.setStubbedSizeLimit(10);
+ expect(store.getStubbedSizeLimit()).to.equal(10);
+ });
+ });
+
+ describe('#getStubbedSize', () => {
+ it('returns the size', () => {
+ const store = new StubBrowserStorage();
+ store.setItem(1, 1);
+ expect(store.getStubbedSize()).to.equal(2);
+ });
+ });
});
diff --git a/src/test_utils/stub_browser_storage.js b/src/test_utils/stub_browser_storage.js
index cf628e0eca515..e601e78bfe813 100644
--- a/src/test_utils/stub_browser_storage.js
+++ b/src/test_utils/stub_browser_storage.js
@@ -1,92 +1,109 @@
-const keys = Symbol('keys');
-const values = Symbol('values');
-const remainingSize = Symbol('remainingSize');
-
export default class StubBrowserStorage {
constructor() {
- this[keys] = [];
- this[values] = [];
- this[remainingSize] = 5000000; // 5mb, minimum browser storage size
+ this._keys = [];
+ this._values = [];
+ this._size = 0;
+ this._sizeLimit = 5000000; // 5mb, minimum browser storage size
}
+ // -----------------------------------------------------------------------------------------------
+ // Browser-specific methods.
+ // -----------------------------------------------------------------------------------------------
+
get length() {
- return this[keys].length;
+ return this._keys.length;
}
key(i) {
- return this[keys][i];
+ return this._keys[i];
}
getItem(key) {
key = String(key);
- const i = this[keys].indexOf(key);
+ const i = this._keys.indexOf(key);
if (i === -1) return null;
- return this[values][i];
+ return this._values[i];
}
setItem(key, value) {
key = String(key);
value = String(value);
- this._takeUpSpace(this._calcSizeOfAdd(key, value));
+ const sizeOfAddition = this._getSizeOfAddition(key, value);
+ this._updateSize(sizeOfAddition);
- const i = this[keys].indexOf(key);
+ const i = this._keys.indexOf(key);
if (i === -1) {
- this[keys].push(key);
- this[values].push(value);
+ this._keys.push(key);
+ this._values.push(value);
} else {
- this[values][i] = value;
+ this._values[i] = value;
}
}
removeItem(key) {
key = String(key);
- this._takeUpSpace(this._calcSizeOfRemove(key));
+ const sizeOfRemoval = this._getSizeOfRemoval(key);
+ this._updateSize(sizeOfRemoval);
- const i = this[keys].indexOf(key);
+ const i = this._keys.indexOf(key);
if (i === -1) return;
- this[keys].splice(i, 1);
- this[values].splice(i, 1);
+ this._keys.splice(i, 1);
+ this._values.splice(i, 1);
}
- // non-standard api methods
- _getKeys() {
- return this[keys].slice();
+ // -----------------------------------------------------------------------------------------------
+ // Test-specific methods.
+ // -----------------------------------------------------------------------------------------------
+
+ getStubbedKeys() {
+ return this._keys.slice();
}
- _getValues() {
- return this[values].slice();
+ getStubbedValues() {
+ return this._values.slice();
}
- _setSizeLimit(limit) {
- if (this[keys].length) {
- throw new Error('You must call _setSizeLimit() before setting any values');
+ setStubbedSizeLimit(sizeLimit) {
+ // We can't reconcile a size limit with the "stored" items, if the stored items size exceeds it.
+ if (sizeLimit < this._size) {
+ throw new Error(`You can't set a size limit smaller than the current size.`);
}
- this[remainingSize] = limit;
+ this._sizeLimit = sizeLimit;
+ }
+
+ getStubbedSizeLimit() {
+ return this._sizeLimit;
+ }
+
+ getStubbedSize() {
+ return this._size;
}
- _calcSizeOfAdd(key, value) {
- const i = this[keys].indexOf(key);
+ _getSizeOfAddition(key, value) {
+ const i = this._keys.indexOf(key);
if (i === -1) {
return key.length + value.length;
}
- return value.length - this[values][i].length;
+ // Return difference of what's been stored, and what *will* be stored.
+ return value.length - this._values[i].length;
}
- _calcSizeOfRemove(key) {
- const i = this[keys].indexOf(key);
+ _getSizeOfRemoval(key) {
+ const i = this._keys.indexOf(key);
if (i === -1) {
return 0;
}
- return 0 - (key.length + this[values][i].length);
+ // Return negative value.
+ return -(key.length + this._values[i].length);
}
- _takeUpSpace(delta) {
- if (this[remainingSize] - delta < 0) {
+ _updateSize(delta) {
+ if (this._size + delta > this._sizeLimit) {
throw new Error('something about quota exceeded, browsers are not consistent here');
}
- this[remainingSize] -= delta;
+ this._size += delta;
}
}
diff --git a/src/ui/public/chrome/api/__tests__/apps.js b/src/ui/public/chrome/api/__tests__/apps.js
index 7e6040b1aa8ce..613920e0dcb86 100644
--- a/src/ui/public/chrome/api/__tests__/apps.js
+++ b/src/ui/public/chrome/api/__tests__/apps.js
@@ -152,8 +152,8 @@ describe('Chrome API :: apps', function () {
expect(chrome.getLastUrlFor('app')).to.equal(null);
chrome.setLastUrlFor('app', 'url');
expect(chrome.getLastUrlFor('app')).to.equal('url');
- expect(store._getKeys().length).to.equal(1);
- expect(store._getValues().shift()).to.equal('url');
+ expect(store.getStubbedKeys().length).to.equal(1);
+ expect(store.getStubbedValues().shift()).to.equal('url');
});
});
});
diff --git a/src/ui/public/chrome/api/angular.js b/src/ui/public/chrome/api/angular.js
index e8f9b30115e6b..198ddfef4cdfd 100644
--- a/src/ui/public/chrome/api/angular.js
+++ b/src/ui/public/chrome/api/angular.js
@@ -5,7 +5,7 @@ import modules from 'ui/modules';
import Notifier from 'ui/notify/notifier';
import { UrlOverflowServiceProvider } from '../../error_url_overflow';
-const URL_LIMIT_WARN_WITHIN = 150;
+const URL_LIMIT_WARN_WITHIN = 1000;
module.exports = function (chrome, internals) {
@@ -57,10 +57,20 @@ module.exports = function (chrome, internals) {
try {
if (urlOverflow.check($location.absUrl()) <= URL_LIMIT_WARN_WITHIN) {
- notify.warning(`
- The URL has gotten big and may cause Kibana
- to stop working. Please simplify the data on screen.
- `);
+ notify.directive({
+ template: `
+
+ The URL has gotten big and may cause Kibana
+ to stop working. Please either enable the
+ state:storeInSessionStorage
+ option in the advanced
+ settings or simplify the onscreen visuals.
+
+ `
+ }, {
+ type: 'error',
+ actions: [{ text: 'close' }]
+ });
}
} catch (e) {
const { host, path, search, protocol } = parseUrl(window.location.href);
diff --git a/src/ui/public/chrome/directives/kbn_chrome.js b/src/ui/public/chrome/directives/kbn_chrome.js
index 1ff7435e94d9a..1b191dda6809a 100644
--- a/src/ui/public/chrome/directives/kbn_chrome.js
+++ b/src/ui/public/chrome/directives/kbn_chrome.js
@@ -4,6 +4,10 @@ import { remove } from 'lodash';
import './kbn_chrome.less';
import UiModules from 'ui/modules';
import { isSystemApiRequest } from 'ui/system_api';
+import {
+ getUnhashableStatesProvider,
+ unhashUrl,
+} from 'ui/state_management/state_hashing';
export default function (chrome, internals) {
@@ -28,15 +32,17 @@ export default function (chrome, internals) {
},
controllerAs: 'chrome',
- controller($scope, $rootScope, $location, $http) {
+ controller($scope, $rootScope, $location, $http, Private) {
+ const getUnhashableStates = Private(getUnhashableStatesProvider);
// are we showing the embedded version of the chrome?
internals.setVisibleDefault(!$location.search().embed);
// listen for route changes, propogate to tabs
const onRouteChange = function () {
- let { href } = window.location;
- internals.trackPossibleSubUrl(href);
+ const urlWithHashes = window.location.href;
+ const urlWithStates = unhashUrl(urlWithHashes, getUnhashableStates());
+ internals.trackPossibleSubUrl(urlWithStates);
};
$rootScope.$on('$routeChangeSuccess', onRouteChange);
diff --git a/src/ui/public/crypto/index.js b/src/ui/public/crypto/index.js
new file mode 100644
index 0000000000000..9951cf805cb85
--- /dev/null
+++ b/src/ui/public/crypto/index.js
@@ -0,0 +1 @@
+export { Sha256 } from './sha256';
diff --git a/src/ui/public/crypto/sha256.js b/src/ui/public/crypto/sha256.js
new file mode 100644
index 0000000000000..1c6de18b1dfab
--- /dev/null
+++ b/src/ui/public/crypto/sha256.js
@@ -0,0 +1,216 @@
+// ported from https://github.com/spalger/sha.js/blob/6557630d508873e262e94bff70c50bdd797c1df7/sha256.js
+// and https://github.com/spalger/sha.js/blob/6557630d508873e262e94bff70c50bdd797c1df7/hash.js
+
+/**
+ * A JavaScript implementation of the Secure Hash Algorithm, SHA-256, as defined
+ * in FIPS 180-2
+ * Version 2.2-beta Copyright Angel Marin, Paul Johnston 2000 - 2009.
+ * Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet
+ *
+ * Copyright (c) 2013-2014 sha.js contributors
+ *
+ * Permission is hereby granted, free of charge,
+ * to any person obtaining a copy of this software and
+ * associated documentation files (the "Software"), to
+ * deal in the Software without restriction, including
+ * without limitation the rights to use, copy, modify,
+ * merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom
+ * the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice
+ * shall be included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR
+ * ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+ * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+const K = [
+ 0x428A2F98, 0x71374491, 0xB5C0FBCF, 0xE9B5DBA5,
+ 0x3956C25B, 0x59F111F1, 0x923F82A4, 0xAB1C5ED5,
+ 0xD807AA98, 0x12835B01, 0x243185BE, 0x550C7DC3,
+ 0x72BE5D74, 0x80DEB1FE, 0x9BDC06A7, 0xC19BF174,
+ 0xE49B69C1, 0xEFBE4786, 0x0FC19DC6, 0x240CA1CC,
+ 0x2DE92C6F, 0x4A7484AA, 0x5CB0A9DC, 0x76F988DA,
+ 0x983E5152, 0xA831C66D, 0xB00327C8, 0xBF597FC7,
+ 0xC6E00BF3, 0xD5A79147, 0x06CA6351, 0x14292967,
+ 0x27B70A85, 0x2E1B2138, 0x4D2C6DFC, 0x53380D13,
+ 0x650A7354, 0x766A0ABB, 0x81C2C92E, 0x92722C85,
+ 0xA2BFE8A1, 0xA81A664B, 0xC24B8B70, 0xC76C51A3,
+ 0xD192E819, 0xD6990624, 0xF40E3585, 0x106AA070,
+ 0x19A4C116, 0x1E376C08, 0x2748774C, 0x34B0BCB5,
+ 0x391C0CB3, 0x4ED8AA4A, 0x5B9CCA4F, 0x682E6FF3,
+ 0x748F82EE, 0x78A5636F, 0x84C87814, 0x8CC70208,
+ 0x90BEFFFA, 0xA4506CEB, 0xBEF9A3F7, 0xC67178F2,
+];
+
+const W = new Array(64);
+
+export class Sha256 {
+ constructor() {
+ this.init();
+
+ this._w = W; // new Array(64)
+
+ const blockSize = 64;
+ const finalSize = 56;
+ this._block = new Buffer(blockSize);
+ this._finalSize = finalSize;
+ this._blockSize = blockSize;
+ this._len = 0;
+ this._s = 0;
+ }
+
+ init() {
+ this._a = 0x6a09e667;
+ this._b = 0xbb67ae85;
+ this._c = 0x3c6ef372;
+ this._d = 0xa54ff53a;
+ this._e = 0x510e527f;
+ this._f = 0x9b05688c;
+ this._g = 0x1f83d9ab;
+ this._h = 0x5be0cd19;
+
+ return this;
+ }
+
+ update(data, enc) {
+ if (typeof data === 'string') {
+ enc = enc || 'utf8';
+ data = new Buffer(data, enc);
+ }
+
+ const l = this._len += data.length;
+ let s = this._s || 0;
+ let f = 0;
+ const buffer = this._block;
+
+ while (s < l) {
+ const t = Math.min(data.length, f + this._blockSize - (s % this._blockSize));
+ const ch = (t - f);
+
+ for (let i = 0; i < ch; i++) {
+ buffer[(s % this._blockSize) + i] = data[i + f];
+ }
+
+ s += ch;
+ f += ch;
+
+ if ((s % this._blockSize) === 0) {
+ this._update(buffer);
+ }
+ }
+ this._s = s;
+
+ return this;
+ }
+
+ digest(enc) {
+ // Suppose the length of the message M, in bits, is l
+ const l = this._len * 8;
+
+ // Append the bit 1 to the end of the message
+ this._block[this._len % this._blockSize] = 0x80;
+
+ // and then k zero bits, where k is the smallest non-negative solution to the equation (l + 1 + k) === finalSize mod blockSize
+ this._block.fill(0, this._len % this._blockSize + 1);
+
+ if (l % (this._blockSize * 8) >= this._finalSize * 8) {
+ this._update(this._block);
+ this._block.fill(0);
+ }
+
+ // to this append the block which is equal to the number l written in binary
+ // TODO: handle case where l is > Math.pow(2, 29)
+ this._block.writeInt32BE(l, this._blockSize - 4);
+
+ const hash = this._update(this._block) || this._hash();
+
+ return enc ? hash.toString(enc) : hash;
+ }
+
+ _update(M) {
+ const W = this._w;
+
+ let a = this._a | 0;
+ let b = this._b | 0;
+ let c = this._c | 0;
+ let d = this._d | 0;
+ let e = this._e | 0;
+ let f = this._f | 0;
+ let g = this._g | 0;
+ let h = this._h | 0;
+
+ let i;
+ for (i = 0; i < 16; ++i) W[i] = M.readInt32BE(i * 4);
+ for (; i < 64; ++i) W[i] = (gamma1(W[i - 2]) + W[i - 7] + gamma0(W[i - 15]) + W[i - 16]) | 0;
+
+ for (let j = 0; j < 64; ++j) {
+ const T1 = (h + sigma1(e) + ch(e, f, g) + K[j] + W[j]) | 0;
+ const T2 = (sigma0(a) + maj(a, b, c)) | 0;
+
+ h = g;
+ g = f;
+ f = e;
+ e = (d + T1) | 0;
+ d = c;
+ c = b;
+ b = a;
+ a = (T1 + T2) | 0;
+ }
+
+ this._a = (a + this._a) | 0;
+ this._b = (b + this._b) | 0;
+ this._c = (c + this._c) | 0;
+ this._d = (d + this._d) | 0;
+ this._e = (e + this._e) | 0;
+ this._f = (f + this._f) | 0;
+ this._g = (g + this._g) | 0;
+ this._h = (h + this._h) | 0;
+ }
+
+ _hash() {
+ const H = new Buffer(32);
+
+ H.writeInt32BE(this._a, 0);
+ H.writeInt32BE(this._b, 4);
+ H.writeInt32BE(this._c, 8);
+ H.writeInt32BE(this._d, 12);
+ H.writeInt32BE(this._e, 16);
+ H.writeInt32BE(this._f, 20);
+ H.writeInt32BE(this._g, 24);
+ H.writeInt32BE(this._h, 28);
+
+ return H;
+ }
+}
+
+function ch(x, y, z) {
+ return z ^ (x & (y ^ z));
+}
+
+function maj(x, y, z) {
+ return (x & y) | (z & (x | y));
+}
+
+function sigma0(x) {
+ return (x >>> 2 | x << 30) ^ (x >>> 13 | x << 19) ^ (x >>> 22 | x << 10);
+}
+
+function sigma1(x) {
+ return (x >>> 6 | x << 26) ^ (x >>> 11 | x << 21) ^ (x >>> 25 | x << 7);
+}
+
+function gamma0(x) {
+ return (x >>> 7 | x << 25) ^ (x >>> 18 | x << 14) ^ (x >>> 3);
+}
+
+function gamma1(x) {
+ return (x >>> 17 | x << 15) ^ (x >>> 19 | x << 13) ^ (x >>> 10);
+}
diff --git a/src/ui/public/error_url_overflow/error_url_overflow.html b/src/ui/public/error_url_overflow/error_url_overflow.html
index a69ce9975352e..143ccb25e2db6 100644
--- a/src/ui/public/error_url_overflow/error_url_overflow.html
+++ b/src/ui/public/error_url_overflow/error_url_overflow.html
@@ -9,6 +9,7 @@
Ok, how do I fix this?
This usually only happens with big, complex dashboards, so you have some options:
+ - Enable the
state:storeInSessionStorage
option in the advanced settings. This will prevent the URLs from getting long, but makes them a bit less portable.
- Remove some stuff from your dashboard. This will reduce the length of the URL and keep IE in a good place.
- Don't use IE. Every other supported browser we know of doesn't have this limit.
diff --git a/src/ui/public/share/directives/share_object_url.js b/src/ui/public/share/directives/share_object_url.js
index d20a1c4c00305..74682dd3de33c 100644
--- a/src/ui/public/share/directives/share_object_url.js
+++ b/src/ui/public/share/directives/share_object_url.js
@@ -4,10 +4,15 @@ import '../styles/index.less';
import LibUrlShortenerProvider from '../lib/url_shortener';
import uiModules from 'ui/modules';
import shareObjectUrlTemplate from 'ui/share/views/share_object_url.html';
-
+import {
+ getUnhashableStatesProvider,
+ unhashUrl,
+} from 'ui/state_management/state_hashing';
+import { memoize } from 'lodash';
app.directive('shareObjectUrl', function (Private, Notifier) {
const urlShortener = Private(LibUrlShortenerProvider);
+ const getUnhashableStates = Private(getUnhashableStatesProvider);
return {
restrict: 'E',
@@ -70,11 +75,14 @@ app.directive('shareObjectUrl', function (Private, Notifier) {
};
$scope.getUrl = function () {
- let url = $location.absUrl();
+ const urlWithHashes = $location.absUrl();
+ const urlWithStates = unhashUrl(urlWithHashes, getUnhashableStates());
+
if ($scope.shareAsEmbed) {
- url = url.replace('?', '?embed=true&');
+ return urlWithStates.replace('?', '?embed=true&');
}
- return url;
+
+ return urlWithStates;
};
$scope.$watch('getUrl()', updateUrl);
diff --git a/src/ui/public/state_management/__tests__/state.js b/src/ui/public/state_management/__tests__/state.js
index d0233594e5b44..28b04c9dcc27a 100644
--- a/src/ui/public/state_management/__tests__/state.js
+++ b/src/ui/public/state_management/__tests__/state.js
@@ -1,48 +1,76 @@
-
-import _ from 'lodash';
import sinon from 'sinon';
import expect from 'expect.js';
import ngMock from 'ng_mock';
+import { encode as encodeRison } from 'rison-node';
import 'ui/private';
+import Notifier from 'ui/notify/notifier';
import StateManagementStateProvider from 'ui/state_management/state';
+import {
+ unhashQueryString,
+} from 'ui/state_management/state_hashing';
+import {
+ createStateHash,
+ isStateHash,
+} from 'ui/state_management/state_storage';
+import HashedItemStore from 'ui/state_management/state_storage/hashed_item_store';
+import StubBrowserStorage from 'test_utils/stub_browser_storage';
import EventsProvider from 'ui/events';
describe('State Management', function () {
+ const notifier = new Notifier();
let $rootScope;
let $location;
let State;
let Events;
+ let setup;
beforeEach(ngMock.module('kibana'));
- beforeEach(ngMock.inject(function (_$rootScope_, _$location_, Private) {
+ beforeEach(ngMock.inject(function (_$rootScope_, _$location_, Private, config) {
$location = _$location_;
$rootScope = _$rootScope_;
State = Private(StateManagementStateProvider);
Events = Private(EventsProvider);
+ Notifier.prototype._notifs.splice(0);
+
+ setup = opts => {
+ const { param, initial, storeInHash } = (opts || {});
+ sinon.stub(config, 'get').withArgs('state:storeInSessionStorage').returns(!!storeInHash);
+ const store = new StubBrowserStorage();
+ const hashedItemStore = new HashedItemStore(store);
+ const state = new State(param, initial, hashedItemStore, notifier);
+
+ const getUnhashedSearch = state => {
+ return unhashQueryString($location.search(), [ state ]);
+ };
+
+ return { notifier, store, hashedItemStore, state, getUnhashedSearch };
+ };
}));
+ afterEach(() => Notifier.prototype._notifs.splice(0));
+
describe('Provider', function () {
it('should reset the state to the defaults', function () {
- let state = new State('_s', { message: ['test'] });
+ const { state, getUnhashedSearch } = setup({ initial: { message: ['test'] } });
state.reset();
- let search = $location.search();
+ let search = getUnhashedSearch(state);
expect(search).to.have.property('_s');
expect(search._s).to.equal('(message:!(test))');
expect(state.message).to.eql(['test']);
});
it('should apply the defaults upon initialization', function () {
- let state = new State('_s', { message: 'test' });
+ const { state } = setup({ initial: { message: 'test' } });
expect(state).to.have.property('message', 'test');
});
it('should inherit from Events', function () {
- let state = new State();
+ const { state } = setup();
expect(state).to.be.an(Events);
});
it('should emit an event if reset with changes', function (done) {
- let state = new State('_s', { message: 'test' });
+ const { state } = setup({ initial: { message: ['test'] } });
state.on('reset_with_changes', function (keys) {
expect(keys).to.eql(['message']);
done();
@@ -54,7 +82,7 @@ describe('State Management', function () {
});
it('should not emit an event if reset without changes', function () {
- let state = new State('_s', { message: 'test' });
+ const { state } = setup({ initial: { message: 'test' } });
state.on('reset_with_changes', function () {
expect().fail();
});
@@ -67,29 +95,29 @@ describe('State Management', function () {
describe('Search', function () {
it('should save to $location.search()', function () {
- let state = new State('_s', { test: 'foo' });
+ const { state, getUnhashedSearch } = setup({ initial: { test: 'foo' } });
state.save();
- let search = $location.search();
+ let search = getUnhashedSearch(state);
expect(search).to.have.property('_s');
expect(search._s).to.equal('(test:foo)');
});
it('should emit an event if changes are saved', function (done) {
- let state = new State();
+ const { state, getUnhashedSearch } = setup();
state.on('save_with_changes', function (keys) {
expect(keys).to.eql(['test']);
done();
});
state.test = 'foo';
state.save();
- let search = $location.search();
+ getUnhashedSearch(state);
$rootScope.$apply();
});
});
describe('Fetch', function () {
it('should emit an event if changes are fetched', function (done) {
- let state = new State();
+ const { state } = setup();
state.on('fetch_with_changes', function (keys) {
expect(keys).to.eql(['foo']);
done();
@@ -101,7 +129,7 @@ describe('State Management', function () {
});
it('should have events that attach to scope', function (done) {
- let state = new State();
+ const { state } = setup();
state.on('test', function (message) {
expect(message).to.equal('foo');
done();
@@ -111,7 +139,7 @@ describe('State Management', function () {
});
it('should fire listeners for #onUpdate() on #fetch()', function (done) {
- let state = new State();
+ const { state } = setup();
state.on('fetch_with_changes', function (keys) {
expect(keys).to.eql(['foo']);
done();
@@ -123,7 +151,7 @@ describe('State Management', function () {
});
it('should apply defaults to fetches', function () {
- let state = new State('_s', { message: 'test' });
+ const { state } = setup({ initial: { message: 'test' } });
$location.search({ _s: '(foo:bar)' });
state.fetch();
expect(state).to.have.property('foo', 'bar');
@@ -131,7 +159,7 @@ describe('State Management', function () {
});
it('should call fetch when $routeUpdate is fired on $rootScope', function () {
- let state = new State();
+ const { state } = setup();
let spy = sinon.spy(state, 'fetch');
$rootScope.$emit('$routeUpdate', 'test');
sinon.assert.calledOnce(spy);
@@ -139,9 +167,9 @@ describe('State Management', function () {
it('should clear state when missing form URL', function () {
let stateObj;
- let state = new State();
+ const { state } = setup();
- // set satte via URL
+ // set state via URL
$location.search({ _s: '(foo:(bar:baz))' });
state.fetch();
stateObj = state.toObject();
@@ -160,4 +188,60 @@ describe('State Management', function () {
expect(stateObj).to.eql({});
});
});
+
+ describe('Hashing', () => {
+ it('stores state values in a hashedItemStore, writing the hash to the url', () => {
+ const { state, hashedItemStore } = setup({ storeInHash: true });
+ state.foo = 'bar';
+ state.save();
+ const urlVal = $location.search()[state.getQueryParamName()];
+
+ expect(isStateHash(urlVal)).to.be(true);
+ expect(hashedItemStore.getItem(urlVal)).to.eql(JSON.stringify({ foo: 'bar' }));
+ });
+
+ it('should replace rison in the URL with a hash', () => {
+ const { state, hashedItemStore } = setup({ storeInHash: true });
+ const obj = { foo: { bar: 'baz' } };
+ const rison = encodeRison(obj);
+
+ $location.search({ _s: rison });
+ state.fetch();
+
+ const urlVal = $location.search()._s;
+ expect(urlVal).to.not.be(rison);
+ expect(isStateHash(urlVal)).to.be(true);
+ expect(hashedItemStore.getItem(urlVal)).to.eql(JSON.stringify(obj));
+ });
+
+ context('error handling', () => {
+ it('notifies the user when a hash value does not map to a stored value', () => {
+ const { state, notifier } = setup({ storeInHash: true });
+ const search = $location.search();
+ const badHash = createStateHash('{"a": "b"}', () => null);
+
+ search[state.getQueryParamName()] = badHash;
+ $location.search(search);
+
+ expect(notifier._notifs).to.have.length(0);
+ state.fetch();
+ expect(notifier._notifs).to.have.length(1);
+ expect(notifier._notifs[0].content).to.match(/use the share functionality/i);
+ });
+
+ it('presents fatal error linking to github when setting item fails', () => {
+ const { state, hashedItemStore, notifier } = setup({ storeInHash: true });
+ const fatalStub = sinon.stub(notifier, 'fatal').throws();
+ sinon.stub(hashedItemStore, 'setItem').returns(false);
+
+ expect(() => {
+ state.toQueryParam();
+ }).to.throwError();
+
+ sinon.assert.calledOnce(fatalStub);
+ expect(fatalStub.firstCall.args[0]).to.be.an(Error);
+ expect(fatalStub.firstCall.args[0].message).to.match(/github\.com/);
+ });
+ });
+ });
});
diff --git a/src/ui/public/state_management/state.js b/src/ui/public/state_management/state.js
index 7b934567e01ff..835f0e198cbcf 100644
--- a/src/ui/public/state_management/state.js
+++ b/src/ui/public/state_management/state.js
@@ -1,59 +1,92 @@
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';
import EventsProvider from 'ui/events';
import Notifier from 'ui/notify/notifier';
-import KbnUrlProvider from 'ui/url';
-const notify = new Notifier();
-export default function StateProvider(Private, $rootScope, $location) {
+import {
+ createStateHash,
+ hashedItemStoreSingleton,
+ isStateHash,
+} from './state_storage';
+
+export default function StateProvider(Private, $rootScope, $location, config) {
const Events = Private(EventsProvider);
_.class(State).inherits(Events);
- function State(urlParam, defaults) {
+ function State(
+ urlParam,
+ defaults,
+ hashedItemStore = hashedItemStoreSingleton,
+ notifier = new Notifier()
+ ) {
State.Super.call(this);
- let self = this;
- self.setDefaults(defaults);
- self._urlParam = urlParam || '_s';
+ this.setDefaults(defaults);
+ this._urlParam = urlParam || '_s';
+ this._notifier = notifier;
+ this._hashedItemStore = hashedItemStore;
// When the URL updates we need to fetch the values from the URL
- self._cleanUpListeners = _.partial(_.callEach, [
+ this._cleanUpListeners = _.partial(_.callEach, [
// partial route update, no app reload
- $rootScope.$on('$routeUpdate', function () {
- self.fetch();
+ $rootScope.$on('$routeUpdate', () => {
+ this.fetch();
}),
// beginning of full route update, new app will be initialized before
// $routeChangeSuccess or $routeChangeError
- $rootScope.$on('$routeChangeStart', function () {
- if (!self._persistAcrossApps) {
- self.destroy();
+ $rootScope.$on('$routeChangeStart', () => {
+ if (!this._persistAcrossApps) {
+ this.destroy();
}
}),
- $rootScope.$on('$routeChangeSuccess', function () {
- if (self._persistAcrossApps) {
- self.fetch();
+ $rootScope.$on('$routeChangeSuccess', () => {
+ if (this._persistAcrossApps) {
+ this.fetch();
}
})
]);
// Initialize the State with fetch
- self.fetch();
+ this.fetch();
}
State.prototype._readFromURL = function () {
- let search = $location.search();
+ const search = $location.search();
+ const urlVal = search[this._urlParam];
+
+ if (!urlVal) {
+ return null;
+ }
+
+ if (isStateHash(urlVal)) {
+ return this._parseQueryParamValue(urlVal);
+ }
+
+ let risonEncoded;
+ let unableToParse;
try {
- return search[this._urlParam] ? rison.decode(search[this._urlParam]) : null;
+ risonEncoded = rison.decode(urlVal);
} catch (e) {
- notify.error('Unable to parse URL');
- search[this._urlParam] = rison.encode(this._defaults);
+ unableToParse = true;
+ }
+
+ if (unableToParse) {
+ this._notifier.error('Unable to parse URL');
+ search[this._urlParam] = this.toQueryParam(this._defaults);
$location.search(search).replace();
- return null;
}
+
+ if (risonEncoded) {
+ search[this._urlParam] = this.toQueryParam(risonEncoded);
+ $location.search(search).replace();
+ return risonEncoded;
+ }
+
+ return null;
};
/**
@@ -95,9 +128,8 @@ export default function StateProvider(Private, $rootScope, $location) {
stash = {};
}
- _.defaults(state, this._defaults);
// apply diff to state from stash, will change state in place via side effect
- let diffResults = applyDiff(stash, state);
+ let diffResults = applyDiff(stash, _.defaults({}, state, this._defaults));
if (diffResults.keys.length) {
this.emit('save_with_changes', diffResults.keys);
@@ -105,7 +137,7 @@ export default function StateProvider(Private, $rootScope, $location) {
// persist the state in the URL
let search = $location.search();
- search[this._urlParam] = this.toRISON();
+ search[this._urlParam] = this.toQueryParam(state);
if (replace) {
$location.search(search).replace();
} else {
@@ -149,6 +181,80 @@ export default function StateProvider(Private, $rootScope, $location) {
this._defaults = defaults || {};
};
+ /**
+ * Parse the query param value to it's unserialized
+ * value. Hashes are restored to their pre-hashed state.
+ *
+ * @param {string} queryParam - value from the query string
+ * @return {any} - the stored value, or null if hash does not resolve
+ */
+ State.prototype._parseQueryParamValue = function (queryParam) {
+ if (!isStateHash(queryParam)) {
+ return rison.decode(queryParam);
+ }
+
+ const json = this._hashedItemStore.getItem(queryParam);
+ if (json === null) {
+ this._notifier.error('Unable to completely restore the URL, be sure to use the share functionality.');
+ }
+
+ return JSON.parse(json);
+ };
+
+ /**
+ * Lookup the value for a hash and return it's value
+ * in rison format
+ *
+ * @param {string} hash
+ * @return {string} rison
+ */
+ State.prototype.translateHashToRison = function (hash) {
+ return rison.encode(this._parseQueryParamValue(hash));
+ };
+
+ /**
+ * Produce the hash version of the state in it's current position
+ *
+ * @return {string}
+ */
+ State.prototype.toQueryParam = function (state = this.toObject()) {
+ if (!config.get('state:storeInSessionStorage')) {
+ return rison.encode(state);
+ }
+
+ // We need to strip out Angular-specific properties.
+ const json = angular.toJson(state);
+ const hash = createStateHash(json, hash => {
+ return this._hashedItemStore.getItem(hash);
+ });
+ const isItemSet = this._hashedItemStore.setItem(hash, json);
+
+ if (isItemSet) {
+ return hash;
+ }
+
+ // If we ran out of space trying to persist the state, notify the user.
+ this._notifier.fatal(
+ new Error(
+ 'Kibana is unable to store history items in your session ' +
+ 'because it is full and there don\'t seem to be items any items safe ' +
+ 'to delete.\n' +
+ '\n' +
+ 'This can usually be fixed by moving to a fresh tab, but could ' +
+ 'be caused by a larger issue. If you are seeing this message regularly, ' +
+ 'please file an issue at https://github.com/elastic/kibana/issues.'
+ )
+ );
+ };
+
+ /**
+ * Get the query string parameter name where this state writes and reads
+ * @return {string}
+ */
+ State.prototype.getQueryParamName = function () {
+ return this._urlParam;
+ };
+
return State;
};
diff --git a/src/ui/public/state_management/state_hashing/__tests__/unhash_url.js b/src/ui/public/state_management/state_hashing/__tests__/unhash_url.js
new file mode 100644
index 0000000000000..6cf07677b3c8e
--- /dev/null
+++ b/src/ui/public/state_management/state_hashing/__tests__/unhash_url.js
@@ -0,0 +1,73 @@
+import expect from 'expect.js';
+import ngMock from 'ng_mock';
+import sinon from 'auto-release-sinon';
+
+import StateProvider from 'ui/state_management/state';
+import { unhashUrl } from 'ui/state_management/state_hashing';
+
+describe('unhashUrl', () => {
+ let unhashableStates;
+
+ beforeEach(ngMock.module('kibana'));
+
+ beforeEach(ngMock.inject(Private => {
+ const State = Private(StateProvider);
+ const unhashableState = new State('testParam');
+ sinon.stub(unhashableState, 'translateHashToRison').withArgs('hash').returns('replacement');
+ unhashableStates = [unhashableState];
+ }));
+
+ describe('does nothing', () => {
+ it('if missing input', () => {
+ expect(() => {
+ unhashUrl();
+ }).to.not.throwError();
+ });
+
+ it('if just a host and port', () => {
+ const url = 'https://localhost:5601';
+ expect(unhashUrl(url, unhashableStates)).to.be(url);
+ });
+
+ it('if just a path', () => {
+ const url = 'https://localhost:5601/app/kibana';
+ expect(unhashUrl(url, unhashableStates)).to.be(url);
+ });
+
+ it('if just a path and query', () => {
+ const url = 'https://localhost:5601/app/kibana?foo=bar';
+ expect(unhashUrl(url, unhashableStates)).to.be(url);
+ });
+
+ it('if empty hash with query', () => {
+ const url = 'https://localhost:5601/app/kibana?foo=bar#';
+ expect(unhashUrl(url, unhashableStates)).to.be(url);
+ });
+
+ it('if empty hash without query', () => {
+ const url = 'https://localhost:5601/app/kibana#';
+ expect(unhashUrl(url, unhashableStates)).to.be(url);
+ });
+
+ it('if empty hash without query', () => {
+ const url = 'https://localhost:5601/app/kibana#';
+ expect(unhashUrl(url, unhashableStates)).to.be(url);
+ });
+
+ it('if hash is just a path', () => {
+ const url = 'https://localhost:5601/app/kibana#/discover';
+ expect(unhashUrl(url, unhashableStates)).to.be(url);
+ });
+
+ it('if hash does not have matching query string vals', () => {
+ const url = 'https://localhost:5601/app/kibana#/discover?foo=bar';
+ expect(unhashUrl(url, unhashableStates)).to.be(url);
+ });
+ });
+
+ it('replaces query string vals in hash for matching states with output of state.toRISON()', () => {
+ const urlWithHashes = 'https://localhost:5601/#/?foo=bar&testParam=hash';
+ const exp = 'https://localhost:5601/#/?foo=bar&testParam=replacement';
+ expect(unhashUrl(urlWithHashes, unhashableStates)).to.be(exp);
+ });
+});
diff --git a/src/ui/public/state_management/state_hashing/get_unhashable_states_provider.js b/src/ui/public/state_management/state_hashing/get_unhashable_states_provider.js
new file mode 100644
index 0000000000000..7dfc64f3a4398
--- /dev/null
+++ b/src/ui/public/state_management/state_hashing/get_unhashable_states_provider.js
@@ -0,0 +1,5 @@
+export default function getUnhashableStatesProvider(getAppState, globalState) {
+ return function getUnhashableStates() {
+ return [getAppState(), globalState].filter(Boolean);
+ };
+}
diff --git a/src/ui/public/state_management/state_hashing/index.js b/src/ui/public/state_management/state_hashing/index.js
new file mode 100644
index 0000000000000..6905a1fd28b61
--- /dev/null
+++ b/src/ui/public/state_management/state_hashing/index.js
@@ -0,0 +1,11 @@
+export {
+ default as getUnhashableStatesProvider,
+} from './get_unhashable_states_provider';
+
+export {
+ default as unhashQueryString,
+} from './unhash_query_string';
+
+export {
+ default as unhashUrl,
+} from './unhash_url';
diff --git a/src/ui/public/state_management/state_hashing/unhash_query_string.js b/src/ui/public/state_management/state_hashing/unhash_query_string.js
new file mode 100644
index 0000000000000..f75dbf97e9042
--- /dev/null
+++ b/src/ui/public/state_management/state_hashing/unhash_query_string.js
@@ -0,0 +1,8 @@
+import { mapValues } from 'lodash';
+
+export default function unhashQueryString(parsedQueryString, states) {
+ return mapValues(parsedQueryString, (val, key) => {
+ const state = states.find(s => key === s.getQueryParamName());
+ return state ? state.translateHashToRison(val) : val;
+ });
+}
diff --git a/src/ui/public/state_management/state_hashing/unhash_url.js b/src/ui/public/state_management/state_hashing/unhash_url.js
new file mode 100644
index 0000000000000..3671b653dee22
--- /dev/null
+++ b/src/ui/public/state_management/state_hashing/unhash_url.js
@@ -0,0 +1,36 @@
+import {
+ parse as parseUrl,
+ format as formatUrl,
+} from 'url';
+
+import unhashQueryString from './unhash_query_string';
+
+export default function unhashUrl(urlWithHashes, states) {
+ if (!urlWithHashes) return urlWithHashes;
+
+ const urlWithHashesParsed = parseUrl(urlWithHashes, true);
+ if (!urlWithHashesParsed.hostname) {
+ // passing a url like "localhost:5601" or "/app/kibana" should be prevented
+ throw new TypeError(
+ 'Only absolute urls should be passed to `unhashUrl()`. ' +
+ 'Unable to detect url hostname.'
+ );
+ }
+
+ if (!urlWithHashesParsed.hash) return urlWithHashes;
+
+ const appUrl = urlWithHashesParsed.hash.slice(1); // trim the #
+ if (!appUrl) return urlWithHashes;
+
+ const appUrlParsed = parseUrl(urlWithHashesParsed.hash.slice(1), true);
+ if (!appUrlParsed.query) return urlWithHashes;
+
+ const appQueryWithoutHashes = unhashQueryString(appUrlParsed.query || {}, states);
+ return formatUrl({
+ ...urlWithHashesParsed,
+ hash: formatUrl({
+ pathname: appUrlParsed.pathname,
+ query: appQueryWithoutHashes,
+ })
+ });
+}
diff --git a/src/ui/public/state_management/state_storage/__tests__/hashed_item_store.js b/src/ui/public/state_management/state_storage/__tests__/hashed_item_store.js
new file mode 100644
index 0000000000000..fa6095ad01a8a
--- /dev/null
+++ b/src/ui/public/state_management/state_storage/__tests__/hashed_item_store.js
@@ -0,0 +1,333 @@
+import expect from 'expect.js';
+import sinon from 'sinon';
+
+import StubBrowserStorage from 'test_utils/stub_browser_storage';
+import HashedItemStore from '../hashed_item_store';
+
+describe('hashedItemStore', () => {
+ describe('interface', () => {
+ describe('#constructor', () => {
+ it('retrieves persisted index from sessionStorage', () => {
+ const sessionStorage = new StubBrowserStorage();
+ sinon.spy(sessionStorage, 'getItem');
+
+ new HashedItemStore(sessionStorage);
+ sinon.assert.calledWith(sessionStorage.getItem, HashedItemStore.PERSISTED_INDEX_KEY);
+ sessionStorage.getItem.restore();
+ });
+
+ it('sorts indexed items by touched property', () => {
+ const a = {
+ hash: 'a',
+ touched: 0,
+ };
+ const b = {
+ hash: 'b',
+ touched: 2,
+ };
+ const c = {
+ hash: 'c',
+ touched: 1,
+ };
+ const sessionStorage = new StubBrowserStorage();
+ if (!HashedItemStore.PERSISTED_INDEX_KEY) {
+ // This is very brittle and depends upon HashedItemStore implementation details,
+ // so let's protect ourselves from accidentally breaking this test.
+ throw new Error('Missing HashedItemStore.PERSISTED_INDEX_KEY');
+ }
+ sessionStorage.setItem(HashedItemStore.PERSISTED_INDEX_KEY, JSON.stringify({a, b, c}));
+
+ const hashedItemStore = new HashedItemStore(sessionStorage);
+ expect(hashedItemStore._indexedItems).to.eql([a, c, b]);
+ });
+ });
+
+ describe('#setItem', () => {
+ describe('if the item exists in sessionStorage', () => {
+ let sessionStorage;
+ let hashedItemStore;
+ const hash = 'a';
+ const item = JSON.stringify({});
+
+ beforeEach(() => {
+ sessionStorage = new StubBrowserStorage();
+ hashedItemStore = new HashedItemStore(sessionStorage);
+ });
+
+ it('persists the item in sessionStorage', () => {
+ hashedItemStore.setItem(hash, item);
+ expect(sessionStorage.getItem(hash)).to.equal(item);
+ });
+
+ it('returns true', () => {
+ const result = hashedItemStore.setItem(hash, item);
+ expect(result).to.equal(true);
+ });
+ });
+
+ describe(`if the item doesn't exist in sessionStorage`, () => {
+ describe(`if there's storage space`, () => {
+ let sessionStorage;
+ let hashedItemStore;
+ const hash = 'a';
+ const item = JSON.stringify({});
+
+ beforeEach(() => {
+ sessionStorage = new StubBrowserStorage();
+ hashedItemStore = new HashedItemStore(sessionStorage);
+ });
+
+ it('persists the item in sessionStorage', () => {
+ hashedItemStore.setItem(hash, item);
+ expect(sessionStorage.getItem(hash)).to.equal(item);
+ });
+
+ it('returns true', () => {
+ const result = hashedItemStore.setItem(hash, item);
+ expect(result).to.equal(true);
+ });
+ });
+
+ describe(`if there isn't storage space`, () => {
+ let fakeTimer;
+ let sessionStorage;
+ let hashedItemStore;
+ let storageSizeLimit;
+ const hash = 'a';
+ const item = JSON.stringify({});
+
+ function setItemLater(hash, item) {
+ // Move time forward, so this item will be "touched" most recently.
+ fakeTimer.tick(1);
+ return hashedItemStore.setItem(hash, item);
+ }
+
+ beforeEach(() => {
+ // Control time.
+ fakeTimer = sinon.useFakeTimers(Date.now());
+
+ sessionStorage = new StubBrowserStorage();
+ hashedItemStore = new HashedItemStore(sessionStorage);
+
+ // Add some items that will be removed.
+ setItemLater('b', item);
+
+ // Do this a little later so that this item is newer.
+ setItemLater('c', item);
+
+ // Cap the storage at its current size.
+ storageSizeLimit = sessionStorage.getStubbedSize();
+ sessionStorage.setStubbedSizeLimit(storageSizeLimit);
+ });
+
+ afterEach(() => {
+ // Stop controlling time.
+ fakeTimer.restore();
+ });
+
+ describe('and the item will fit', () => {
+ it('removes older items until the new item fits', () => {
+ setItemLater(hash, item);
+ expect(sessionStorage.getItem('b')).to.equal(null);
+ expect(sessionStorage.getItem('c')).to.equal(item);
+ });
+
+ it('persists the item in sessionStorage', () => {
+ setItemLater(hash, item);
+ expect(sessionStorage.getItem(hash)).to.equal(item);
+ });
+
+ it('returns true', () => {
+ const result = setItemLater(hash, item);
+ expect(result).to.equal(true);
+ });
+ });
+
+ describe(`and the item won't fit`, () => {
+ let itemTooBigToFit;
+
+ beforeEach(() => {
+ // Make sure the item is longer than the storage size limit.
+ itemTooBigToFit = '';
+ const length = storageSizeLimit + 1;
+ for (let i = 0; i < length; i++) {
+ itemTooBigToFit += 'a';
+ }
+ });
+
+ it('removes all items', () => {
+ setItemLater(hash, itemTooBigToFit);
+ expect(sessionStorage.getItem('b')).to.equal(null);
+ expect(sessionStorage.getItem('c')).to.equal(null);
+ });
+
+ it(`doesn't persist the item in sessionStorage`, () => {
+ setItemLater(hash, itemTooBigToFit);
+ expect(sessionStorage.getItem(hash)).to.equal(null);
+ });
+
+ it('returns false', () => {
+ const result = setItemLater(hash, itemTooBigToFit);
+ expect(result).to.equal(false);
+ });
+ });
+ });
+ });
+ });
+
+ describe('#getItem', () => {
+ describe('if the item exists in sessionStorage', () => {
+ let fakeTimer;
+ let sessionStorage;
+ let hashedItemStore;
+
+ function setItemLater(hash, item) {
+ // Move time forward, so this item will be "touched" most recently.
+ fakeTimer.tick(1);
+ return hashedItemStore.setItem(hash, item);
+ }
+
+ function getItemLater(hash) {
+ // Move time forward, so this item will be "touched" most recently.
+ fakeTimer.tick(1);
+ return hashedItemStore.getItem(hash);
+ }
+
+ beforeEach(() => {
+ // Control time.
+ fakeTimer = sinon.useFakeTimers(Date.now());
+
+ sessionStorage = new StubBrowserStorage();
+ hashedItemStore = new HashedItemStore(sessionStorage);
+ hashedItemStore.setItem('1', 'a');
+ });
+
+ afterEach(() => {
+ // Stop controlling time.
+ fakeTimer.restore();
+ });
+
+ it('returns the item', () => {
+ const retrievedItem = hashedItemStore.getItem('1');
+ expect(retrievedItem).to.be('a');
+ });
+
+ it('prevents the item from being first to be removed when freeing up storage spage', () => {
+ // Do this a little later so that this item is newer.
+ setItemLater('2', 'b');
+
+ // Wait a bit, then retrieve/touch the first item, making *it* newer, and 2 as the oldest.
+ getItemLater('1');
+
+ // Cap the storage at its current size.
+ const storageSizeLimit = sessionStorage.getStubbedSize();
+ sessionStorage.setStubbedSizeLimit(storageSizeLimit);
+
+ // Add a new item, causing the second item to be removed, but not the first.
+ setItemLater('3', 'c');
+ expect(hashedItemStore.getItem('2')).to.equal(null);
+ expect(hashedItemStore.getItem('1')).to.equal('a');
+ });
+ });
+
+ describe(`if the item doesn't exist in sessionStorage`, () => {
+ let sessionStorage;
+ let hashedItemStore;
+ const hash = 'a';
+
+ beforeEach(() => {
+ sessionStorage = new StubBrowserStorage();
+ hashedItemStore = new HashedItemStore(sessionStorage);
+ });
+
+ it('returns null', () => {
+ const retrievedItem = hashedItemStore.getItem(hash);
+ expect(retrievedItem).to.be(null);
+ });
+ });
+ });
+ });
+
+ describe('behavior', () => {
+ let fakeTimer;
+ let sessionStorage;
+ let hashedItemStore;
+
+ function setItemLater(hash, item) {
+ // Move time forward, so this item will be "touched" most recently.
+ fakeTimer.tick(1);
+ return hashedItemStore.setItem(hash, item);
+ }
+
+ function getItemLater(hash) {
+ // Move time forward, so this item will be "touched" most recently.
+ fakeTimer.tick(1);
+ return hashedItemStore.getItem(hash);
+ }
+
+ beforeEach(() => {
+ // Control time.
+ fakeTimer = sinon.useFakeTimers(Date.now());
+
+ sessionStorage = new StubBrowserStorage();
+ hashedItemStore = new HashedItemStore(sessionStorage);
+ });
+
+ afterEach(() => {
+ // Stop controlling time.
+ fakeTimer.restore();
+ });
+
+ it('orders items to be removed based on when they were last retrieved', () => {
+ setItemLater('1', 'a');
+ setItemLater('2', 'b');
+ setItemLater('3', 'c');
+ setItemLater('4', 'd');
+
+ // Cap the storage at its current size.
+ const storageSizeLimit = sessionStorage.getStubbedSize();
+ sessionStorage.setStubbedSizeLimit(storageSizeLimit);
+
+ // Expect items to be removed in order: 1, 3, 2, 4.
+ getItemLater('1');
+ getItemLater('3');
+ getItemLater('2');
+ getItemLater('4');
+
+ setItemLater('5', 'e');
+ expect(hashedItemStore.getItem('1')).to.equal(null);
+ expect(hashedItemStore.getItem('3')).to.equal('c');
+ expect(hashedItemStore.getItem('2')).to.equal('b');
+ expect(hashedItemStore.getItem('4')).to.equal('d');
+ expect(hashedItemStore.getItem('5')).to.equal('e');
+
+ setItemLater('6', 'f');
+ expect(hashedItemStore.getItem('3')).to.equal(null);
+ expect(hashedItemStore.getItem('2')).to.equal('b');
+ expect(hashedItemStore.getItem('4')).to.equal('d');
+ expect(hashedItemStore.getItem('5')).to.equal('e');
+ expect(hashedItemStore.getItem('6')).to.equal('f');
+
+ setItemLater('7', 'g');
+ expect(hashedItemStore.getItem('2')).to.equal(null);
+ expect(hashedItemStore.getItem('4')).to.equal('d');
+ expect(hashedItemStore.getItem('5')).to.equal('e');
+ expect(hashedItemStore.getItem('6')).to.equal('f');
+ expect(hashedItemStore.getItem('7')).to.equal('g');
+
+ setItemLater('8', 'h');
+ expect(hashedItemStore.getItem('4')).to.equal(null);
+ expect(hashedItemStore.getItem('5')).to.equal('e');
+ expect(hashedItemStore.getItem('6')).to.equal('f');
+ expect(hashedItemStore.getItem('7')).to.equal('g');
+ expect(hashedItemStore.getItem('8')).to.equal('h');
+
+ setItemLater('9', 'i');
+ expect(hashedItemStore.getItem('5')).to.equal(null);
+ expect(hashedItemStore.getItem('6')).to.equal('f');
+ expect(hashedItemStore.getItem('7')).to.equal('g');
+ expect(hashedItemStore.getItem('8')).to.equal('h');
+ expect(hashedItemStore.getItem('9')).to.equal('i');
+ });
+ });
+});
diff --git a/src/ui/public/state_management/state_storage/__tests__/state_hash.js b/src/ui/public/state_management/state_storage/__tests__/state_hash.js
new file mode 100644
index 0000000000000..97e93fcab4d67
--- /dev/null
+++ b/src/ui/public/state_management/state_storage/__tests__/state_hash.js
@@ -0,0 +1,55 @@
+import expect from 'expect.js';
+import { encode as encodeRison } from 'rison-node';
+
+import {
+ createStateHash,
+ isStateHash,
+} from '../state_hash';
+
+describe('stateHash', () => {
+ const existingJsonProvider = () => null;
+
+ describe('#createStateHash', () => {
+
+ describe('returns a hash', () => {
+ const json = JSON.stringify({a: 'a'});
+ const hash = createStateHash(json, existingJsonProvider);
+ expect(isStateHash(hash)).to.be(true);
+ });
+
+ describe('returns the same hash for the same input', () => {
+ const json = JSON.stringify({a: 'a'});
+ const hash1 = createStateHash(json, existingJsonProvider);
+ const hash2 = createStateHash(json, existingJsonProvider);
+ expect(hash1).to.equal(hash2);
+ });
+
+ describe('returns a different hash for different input', () => {
+ const json1 = JSON.stringify({a: 'a'});
+ const hash1 = createStateHash(json1, existingJsonProvider);
+
+ const json2 = JSON.stringify({a: 'b'});
+ const hash2 = createStateHash(json2, existingJsonProvider);
+ expect(hash1).to.not.equal(hash2);
+ });
+ });
+
+ describe('#isStateHash', () => {
+ it('returns true for values created using #createStateHash', () => {
+ const json = JSON.stringify({a: 'a'});
+ const hash = createStateHash(json, existingJsonProvider);
+ expect(isStateHash(hash)).to.be(true);
+ });
+
+ it('returns false for values not created using #createStateHash', () => {
+ const json = JSON.stringify({a: 'a'});
+ expect(isStateHash(json)).to.be(false);
+ });
+
+ it('returns false for RISON', () => {
+ // We're storing RISON in the URL, so let's test against this specifically.
+ const rison = encodeRison({a: 'a'});
+ expect(isStateHash(rison)).to.be(false);
+ });
+ });
+});
diff --git a/src/ui/public/state_management/state_storage/hashed_item_store.js b/src/ui/public/state_management/state_storage/hashed_item_store.js
new file mode 100644
index 0000000000000..15a52629deb3a
--- /dev/null
+++ b/src/ui/public/state_management/state_storage/hashed_item_store.js
@@ -0,0 +1,151 @@
+/**
+ * The HashedItemStore associates JSON objects with states in browser history and persists these
+ * objects in sessionStorage. We persist them so that when a tab is closed and re-opened, we can
+ * retain access to the state objects referenced by the browser history.
+ *
+ * Because there is a limit on how much data we can put into sessionStorage, the HashedItemStore
+ * will attempt to remove old items from storage once that limit is reached.
+ *
+ * -------------------------------------------------------------------------------------------------
+ *
+ * Consideration 1: We can't (easily) mirror the browser history
+ *
+ * If we use letters to indicate a unique state object, and numbers to represent the same state
+ * occurring again (due to action by the user), a history could look like this:
+ *
+ * Old < - - - - - - - - > New
+ * A1 | B1 | C1 | A2 | D1 | E1
+ *
+ * If the user navigates back to C1 and starts to create new states, persisted history states will
+ * become inaccessible:
+ *
+ * Old < - - - - - - - - - - -> New
+ * A1 | B1 | C1 | F1 | G1 | H1 | I1 (new history states)
+ * A2 | D1 | E1 (inaccessible persisted history states)
+ *
+ * Theoretically, we could build a mirror of the browser history. When the onpopstate event is
+ * dispatched, we could determine whether we have gone back or forward in history. Then, when
+ * a new state is persisted, we could delete all of the persisted items which are no longer
+ * accessible. (Note that this would require reference-counting so that A isn't removed while D and
+ * E are, since A would still have a remaining reference from A1).
+ *
+ * However, the History API doesn't allow us to read from the history beyond the current state. This
+ * means that if a session is restored, we can't rebuild this browser history mirror.
+ *
+ * Due to this imperfect implementation, HashedItemStore ignores the possibility of inaccessible
+ * history states. In the future, we could implement this history mirror and persist it in
+ * sessionStorage too. Then, when restoring a session, we can just retrieve it from sessionStorage.
+ *
+ * -------------------------------------------------------------------------------------------------
+ *
+ * Consideration 2: We can't tell when we've hit the browser history limit
+ *
+ * Because some of our persisted history states may no longer be referenced by the browser history,
+ * and we have no way of knowing which ones, we have no way of knowing whether we've persisted a
+ * number of accessible states beyond the browser history length limit.
+ *
+ * More fundamentally, the browser history length limit is a browser implementation detail, so it
+ * can change from browser to browser, or over time. Respecting this limit would introduce a lot of
+ * (unnecessary?) complexity.
+ *
+ * For these reasons, HashedItemStore doesn't concern itself with this constraint.
+ */
+
+import { pull, sortBy } from 'lodash';
+
+export default class HashedItemStore {
+
+ /**
+ * HashedItemStore uses objects called indexed items to refer to items that have been persisted
+ * in sessionStorage. An indexed item is shaped {hash, touched}. The touched date is when the item
+ * was last referenced by the browser history.
+ */
+ constructor(sessionStorage) {
+ this._sessionStorage = sessionStorage;
+
+ // Store indexed items in descending order by touched (oldest first, newest last). We'll use
+ // this to remove older items when we run out of storage space.
+ this._indexedItems = [];
+
+ // Potentially restore a previously persisted index. This happens when
+ // we re-open a closed tab.
+ const persistedItemIndex = this._sessionStorage.getItem(HashedItemStore.PERSISTED_INDEX_KEY);
+ if (persistedItemIndex) {
+ this._indexedItems = sortBy(JSON.parse(persistedItemIndex) || [], 'touched');
+ }
+ }
+
+ setItem(hash, item) {
+ const isItemPersisted = this._persistItem(hash, item);
+
+ if (isItemPersisted) {
+ this._touchHash(hash);
+ }
+
+ return isItemPersisted;
+ }
+
+ getItem(hash) {
+ const item = this._sessionStorage.getItem(hash);
+
+ if (item !== null) {
+ this._touchHash(hash);
+ }
+
+ return item;
+ }
+
+ _getIndexedItem(hash) {
+ return this._indexedItems.find(indexedItem => indexedItem.hash === hash);
+ }
+
+ _persistItem(hash, item) {
+ try {
+ this._sessionStorage.setItem(hash, item);
+ return true;
+ } catch (e) {
+ // If there was an error then we need to make some space for the item.
+ if (this._indexedItems.length === 0) {
+ // If there's nothing left to remove, then we've run out of space and we're trying to
+ // persist too large an item.
+ return false;
+ }
+
+ // We need to try to make some space for the item by removing older items (i.e. items that
+ // haven't been accessed recently).
+ this._removeOldestItem();
+
+ // Try to persist again.
+ return this._persistItem(hash, item);
+ }
+ }
+
+ _removeOldestItem() {
+ const oldestIndexedItem = this._indexedItems.shift();
+ // Remove oldest item from storage.
+ this._sessionStorage.removeItem(oldestIndexedItem.hash);
+ }
+
+ _touchHash(hash) {
+ // Touching a hash indicates that it's been used recently, so it won't be the first in line
+ // when we remove items to free up storage space.
+
+ // either get or create an indexedItem
+ const indexedItem = this._getIndexedItem(hash) || { hash };
+
+ // set/update the touched time to now so that it's the "newest" item in the index
+ indexedItem.touched = Date.now();
+
+ // ensure that the item is last in the index
+ pull(this._indexedItems, indexedItem);
+ this._indexedItems.push(indexedItem);
+
+ // Regardless of whether this is a new or updated item, we need to persist the index.
+ this._sessionStorage.setItem(
+ HashedItemStore.PERSISTED_INDEX_KEY,
+ JSON.stringify(this._indexedItems)
+ );
+ }
+}
+
+HashedItemStore.PERSISTED_INDEX_KEY = 'kbn.hashedItemsIndex.v1';
diff --git a/src/ui/public/state_management/state_storage/hashed_item_store_singleton.js b/src/ui/public/state_management/state_storage/hashed_item_store_singleton.js
new file mode 100644
index 0000000000000..bd0094a24851a
--- /dev/null
+++ b/src/ui/public/state_management/state_storage/hashed_item_store_singleton.js
@@ -0,0 +1,3 @@
+import HashedItemStore from './hashed_item_store';
+
+export default new HashedItemStore(window.sessionStorage);
diff --git a/src/ui/public/state_management/state_storage/index.js b/src/ui/public/state_management/state_storage/index.js
new file mode 100644
index 0000000000000..91342388a3ab8
--- /dev/null
+++ b/src/ui/public/state_management/state_storage/index.js
@@ -0,0 +1,8 @@
+export {
+ default as hashedItemStoreSingleton,
+} from './hashed_item_store_singleton';
+
+export {
+ createStateHash,
+ isStateHash,
+} from './state_hash';
diff --git a/src/ui/public/state_management/state_storage/state_hash.js b/src/ui/public/state_management/state_storage/state_hash.js
new file mode 100644
index 0000000000000..9303f971830f9
--- /dev/null
+++ b/src/ui/public/state_management/state_storage/state_hash.js
@@ -0,0 +1,29 @@
+import { Sha256 } from 'ui/crypto';
+
+// This prefix is used to identify hash strings that have been encoded in the URL.
+const HASH_PREFIX = 'h@';
+
+export function createStateHash(json, existingJsonProvider) {
+ if (typeof json !== 'string') {
+ throw new Error('createHash only accepts strings (JSON).');
+ }
+
+ const hash = new Sha256().update(json, 'utf8').digest('hex');
+
+ let shortenedHash;
+
+ // Shorten the hash to at minimum 7 characters. We just need to make sure that it either:
+ // a) hasn't been used yet
+ // b) or has been used already, but with the JSON we're currently hashing.
+ for (let i = 7; i < hash.length; i++) {
+ shortenedHash = hash.slice(0, i);
+ const existingJson = existingJsonProvider(shortenedHash);
+ if (existingJson === null || existingJson === json) break;
+ }
+
+ return `${HASH_PREFIX}${shortenedHash}`;
+}
+
+export function isStateHash(str) {
+ return String(str).indexOf(HASH_PREFIX) === 0;
+}
diff --git a/src/ui/public/url/__tests__/url.js b/src/ui/public/url/__tests__/url.js
index 57f2483b7beb4..d7385c748596a 100644
--- a/src/ui/public/url/__tests__/url.js
+++ b/src/ui/public/url/__tests__/url.js
@@ -15,6 +15,13 @@ let $location;
let $rootScope;
let appState;
+class StubAppState {
+ constructor() {
+ this.getQueryParamName = () => '_a';
+ this.toQueryParam = () => 'stateQueryParam';
+ this.destroy = sinon.stub();
+ }
+}
function init() {
ngMock.module('kibana/url', 'kibana', function ($provide, PrivateProvider) {
@@ -24,7 +31,7 @@ function init() {
};
});
- appState = { destroy: sinon.stub() };
+ appState = new StubAppState();
PrivateProvider.swap(AppStateProvider, $decorate => {
const AppState = $decorate();
AppState.getAppState = () => appState;
@@ -277,11 +284,11 @@ describe('kbnUrl', function () {
expect($location.search()).to.eql(search);
expect($location.hash()).to.be(hash);
- kbnUrl.change(newPath, null, {foo: 'bar'});
+ kbnUrl.change(newPath, null, new StubAppState());
// verify the ending state
expect($location.path()).to.be(newPath);
- expect($location.search()).to.eql({_a: '(foo:bar)'});
+ expect($location.search()).to.eql({ _a: 'stateQueryParam' });
expect($location.hash()).to.be('');
});
});
@@ -344,11 +351,11 @@ describe('kbnUrl', function () {
expect($location.search()).to.eql(search);
expect($location.hash()).to.be(hash);
- kbnUrl.redirect(newPath, null, {foo: 'bar'});
+ kbnUrl.redirect(newPath, null, new StubAppState());
// verify the ending state
expect($location.path()).to.be(newPath);
- expect($location.search()).to.eql({_a: '(foo:bar)'});
+ expect($location.search()).to.eql({ _a: 'stateQueryParam' });
expect($location.hash()).to.be('');
});
diff --git a/src/ui/public/url/url.js b/src/ui/public/url/url.js
index ef2442730db7e..d26a8e8976e63 100644
--- a/src/ui/public/url/url.js
+++ b/src/ui/public/url/url.js
@@ -154,7 +154,7 @@ function KbnUrlProvider($injector, $location, $rootScope, $parse, Private) {
if (replace) $location.replace();
if (appState) {
- $location.search('_a', rison.encode(appState));
+ $location.search(appState.getQueryParamName(), appState.toQueryParam());
}
let next = {
diff --git a/src/ui/settings/defaults.js b/src/ui/settings/defaults.js
index 143e25032102e..fec88985c7d73 100644
--- a/src/ui/settings/defaults.js
+++ b/src/ui/settings/defaults.js
@@ -281,6 +281,12 @@ export default function defaultSettingsProvider() {
'timelion:quandl.key': {
value: 'someKeyHere',
description: 'Your API key from www.quandl.com'
+ },
+ 'state:storeInSessionStorage': {
+ value: false,
+ description: 'The URL can sometimes grow to be too large for some browsers to ' +
+ 'handle. To counter-act this we are testing if storing parts of the URL in ' +
+ 'sessions storage could help. Please let us know how it goes!'
}
};
};