mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
Merge pull request #8022 from spalger/implement/storeStateInLocalstorage
[state] store actual state value in session storage
This commit is contained in:
commit
0ee5d4b61c
26 changed files with 1317 additions and 122 deletions
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
20
src/ui/public/chrome/api/angular.js
vendored
20
src/ui/public/chrome/api/angular.js
vendored
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
1
src/ui/public/crypto/index.js
Normal file
1
src/ui/public/crypto/index.js
Normal file
|
@ -0,0 +1 @@
|
|||
export { Sha256 } from './sha256';
|
216
src/ui/public/crypto/sha256.js
Normal file
216
src/ui/public/crypto/sha256.js
Normal 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);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,5 @@
|
|||
export default function getUnhashableStatesProvider(getAppState, globalState) {
|
||||
return function getUnhashableStates() {
|
||||
return [getAppState(), globalState].filter(Boolean);
|
||||
};
|
||||
}
|
11
src/ui/public/state_management/state_hashing/index.js
Normal file
11
src/ui/public/state_management/state_hashing/index.js
Normal 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';
|
|
@ -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;
|
||||
});
|
||||
}
|
36
src/ui/public/state_management/state_hashing/unhash_url.js
Normal file
36
src/ui/public/state_management/state_hashing/unhash_url.js
Normal 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,
|
||||
})
|
||||
});
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -0,0 +1,3 @@
|
|||
import HashedItemStore from './hashed_item_store';
|
||||
|
||||
export default new HashedItemStore(window.sessionStorage);
|
8
src/ui/public/state_management/state_storage/index.js
Normal file
8
src/ui/public/state_management/state_storage/index.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
export {
|
||||
default as hashedItemStoreSingleton,
|
||||
} from './hashed_item_store_singleton';
|
||||
|
||||
export {
|
||||
createStateHash,
|
||||
isStateHash,
|
||||
} from './state_hash';
|
29
src/ui/public/state_management/state_storage/state_hash.js
Normal file
29
src/ui/public/state_management/state_storage/state_hash.js
Normal 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;
|
||||
}
|
|
@ -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('');
|
||||
});
|
||||
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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!'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue