Merge pull request #8022 from spalger/implement/storeStateInLocalstorage

[state] store actual state value in session storage
This commit is contained in:
Spencer 2016-09-06 17:25:22 -07:00 committed by GitHub
commit 0ee5d4b61c
26 changed files with 1317 additions and 122 deletions

View file

@ -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,

View file

@ -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);
});
});
});

View file

@ -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;
}
_calcSizeOfAdd(key, value) {
const i = this[keys].indexOf(key);
getStubbedSizeLimit() {
return this._sizeLimit;
}
getStubbedSize() {
return this._size;
}
_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;
}
}

View file

@ -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');
});
});
});

View file

@ -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: `
<p>
The URL has gotten big and may cause Kibana
to stop working. Please either enable the
<code>state:storeInSessionStorage</code>
option in the <a href="#/management/kibana/settings">advanced
settings</a> or simplify the onscreen visuals.
</p>
`
}, {
type: 'error',
actions: [{ text: 'close' }]
});
}
} catch (e) {
const { host, path, search, protocol } = parseUrl(window.location.href);

View file

@ -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);

View file

@ -0,0 +1 @@
export { Sha256 } from './sha256';

View file

@ -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);
}

View file

@ -9,6 +9,7 @@
<h3>Ok, how do I fix this?</h3>
<p>This usually only happens with big, complex dashboards, so you have some options:</p>
<ol>
<li>Enable the <code>state:storeInSessionStorage</code> option in the <a href="#/management/kibana/settings">advanced settings</a>. This will prevent the URLs from getting long, but makes them a bit less portable.</li>
<li>Remove some stuff from your dashboard. This will reduce the length of the URL and keep IE in a good place.</li>
<li>Don't use IE. Every other supported browser we know of doesn't have this limit.</li>
</ol>

View file

@ -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);

View file

@ -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/);
});
});
});
});

View file

@ -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();
try {
return search[this._urlParam] ? rison.decode(search[this._urlParam]) : null;
} catch (e) {
notify.error('Unable to parse URL');
search[this._urlParam] = rison.encode(this._defaults);
$location.search(search).replace();
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 {
risonEncoded = rison.decode(urlVal);
} catch (e) {
unableToParse = true;
}
if (unableToParse) {
this._notifier.error('Unable to parse URL');
search[this._urlParam] = this.toQueryParam(this._defaults);
$location.search(search).replace();
}
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;
};

View file

@ -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);
});
});

View file

@ -0,0 +1,5 @@
export default function getUnhashableStatesProvider(getAppState, globalState) {
return function getUnhashableStates() {
return [getAppState(), globalState].filter(Boolean);
};
}

View file

@ -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';

View file

@ -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;
});
}

View file

@ -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,
})
});
}

View file

@ -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');
});
});
});

View file

@ -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);
});
});
});

View file

@ -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';

View file

@ -0,0 +1,3 @@
import HashedItemStore from './hashed_item_store';
export default new HashedItemStore(window.sessionStorage);

View file

@ -0,0 +1,8 @@
export {
default as hashedItemStoreSingleton,
} from './hashed_item_store_singleton';
export {
createStateHash,
isStateHash,
} from './state_hash';

View file

@ -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;
}

View file

@ -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('');
});

View file

@ -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 = {

View file

@ -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!'
}
};
};