[State Management] Typescripify, jestify, simplify state_hashing and state_storage (#51835)

The hashUrl and unhashUrl functions no longer rely on states being provided as an argument, therefore getUnhashableStates/getUnhashableStatesProvider have been removed.
This commit is contained in:
Anton Dosov 2019-12-04 12:36:03 +01:00 committed by GitHub
parent 7ce0a37e3e
commit 217608d11e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 709 additions and 651 deletions

View file

@ -120,10 +120,6 @@ export class DashboardAppController {
new FilterStateManager(globalState, getAppState, filterManager);
const queryFilter = filterManager;
function getUnhashableStates(): State[] {
return [getAppState(), globalState].filter(Boolean);
}
let lastReloadRequestTime = 0;
const dash = ($scope.dash = $route.current.locals.dash);
@ -751,7 +747,7 @@ export class DashboardAppController {
anchorElement,
allowEmbed: true,
allowShortUrl: !dashboardConfig.getHideWriteControls(),
shareableUrl: unhashUrl(window.location.href, getUnhashableStates()),
shareableUrl: unhashUrl(window.location.href),
objectId: dash.id,
objectType: 'dashboard',
sharingData: {

View file

@ -18,7 +18,7 @@
*/
import './np_core.test.mocks';
import 'ui/state_management/state_storage/mock';
import { DashboardStateManager } from './dashboard_state_manager';
import { getAppStateMock, getSavedDashboardMock } from './__tests__';
import { AppStateClass } from './legacy_imports';

View file

@ -42,7 +42,6 @@ import {
getRequestInspectorStats,
getResponseInspectorStats,
getServices,
getUnhashableStatesProvider,
hasSearchStategyForIndexPattern,
intervalOptions,
isDefaultTypeIndexPattern,
@ -195,10 +194,8 @@ function discoverController(
globalState,
) {
const responseHandler = vislibSeriesResponseHandlerProvider().handler;
const getUnhashableStates = Private(getUnhashableStatesProvider);
const filterStateManager = new FilterStateManager(globalState, getAppState, filterManager);
const inspectorAdapters = {
requests: new RequestAdapter()
};
@ -333,7 +330,7 @@ function discoverController(
anchorElement,
allowEmbed: false,
allowShortUrl: uiCapabilities.discover.createShortUrl,
shareableUrl: unhashUrl(window.location.href, getUnhashableStates()),
shareableUrl: unhashUrl(window.location.href),
objectId: savedSearch.id,
objectType: 'search',
sharingData: {

View file

@ -74,8 +74,6 @@ export { subscribeWithScope } from 'ui/utils/subscribe_with_scope';
// @ts-ignore
export { timezoneProvider } from 'ui/vis/lib/timezone';
// @ts-ignore
export { getUnhashableStatesProvider } from 'ui/state_management/state_hashing';
// @ts-ignore
export { tabifyAggResponse } from 'ui/agg_response/tabify';
// @ts-ignore
export { vislibSeriesResponseHandlerProvider } from 'ui/vis/response_handlers/vislib';

View file

@ -39,7 +39,6 @@ import {
getServices,
angular,
absoluteToParsedUrl,
getUnhashableStatesProvider,
KibanaParsedUrl,
migrateLegacyQuery,
SavedObjectSaveModal,
@ -166,7 +165,6 @@ function VisEditor(
localStorage,
) {
const queryFilter = Private(FilterBarQueryFilterProvider);
const getUnhashableStates = Private(getUnhashableStatesProvider);
// Retrieve the resolved SavedVis instance.
const savedVis = $route.current.locals.savedVis;
@ -250,7 +248,7 @@ function VisEditor(
anchorElement,
allowEmbed: true,
allowShortUrl: capabilities.visualize.createShortUrl,
shareableUrl: unhashUrl(window.location.href, getUnhashableStates()),
shareableUrl: unhashUrl(window.location.href),
objectId: savedVis.id,
objectType: 'visualization',
sharingData: {

View file

@ -96,8 +96,6 @@ export { getFromSavedObject } from 'ui/index_patterns';
export { PersistedState } from 'ui/persisted_state';
// @ts-ignore
export { VisEditorTypesRegistryProvider } from 'ui/registry/vis_editor_types';
// @ts-ignore
export { getUnhashableStatesProvider } from 'ui/state_management/state_hashing';
export { showSaveModal } from 'ui/saved_objects/show_saved_object_save_modal';
export { stateMonitorFactory } from 'ui/state_management/state_monitor_factory';
export { absoluteToParsedUrl } from 'ui/url/absolute_to_parsed_url';

View file

@ -20,6 +20,7 @@
import chrome from 'ui/chrome';
import { hashUrl } from 'ui/state_management/state_hashing';
import uiRoutes from 'ui/routes';
import { fatalError } from 'ui/notify';
uiRoutes.enable();
uiRoutes
@ -27,11 +28,14 @@ uiRoutes
resolve: {
url: function (AppState, globalState, $window) {
const redirectUrl = chrome.getInjected('redirectUrl');
try {
const hashedUrl = hashUrl(redirectUrl);
const url = chrome.addBasePath(hashedUrl);
const hashedUrl = hashUrl([new AppState(), globalState], redirectUrl);
const url = chrome.addBasePath(hashedUrl);
$window.location = url;
$window.location = url;
} catch (e) {
fatalError(e);
}
}
}
});

View file

@ -20,18 +20,16 @@
import url from 'url';
import {
getUnhashableStatesProvider,
unhashUrl,
} from '../../state_management/state_hashing';
export function registerSubUrlHooks(angularModule, internals) {
angularModule.run(($rootScope, Private, $location) => {
const getUnhashableStates = Private(getUnhashableStatesProvider);
const subUrlRouteFilter = Private(SubUrlRouteFilterProvider);
function updateSubUrls() {
const urlWithHashes = window.location.href;
const urlWithStates = unhashUrl(urlWithHashes, getUnhashableStates());
const urlWithStates = unhashUrl(urlWithHashes);
internals.trackPossibleSubUrl(urlWithStates);
}

View file

@ -25,13 +25,11 @@ import '../../private';
import { toastNotifications } from '../../notify';
import * as FatalErrorNS from '../../notify/fatal_error';
import { StateProvider } from '../state';
import {
unhashQueryString,
} from '../state_hashing';
import {
createStateHash,
isStateHash,
} from '../state_storage';
unhashQuery
} from '../state_hashing';
import { HashedItemStore } from '../state_storage/hashed_item_store';
import { StubBrowserStorage } from 'test_utils/stub_browser_storage';
import { EventsProvider } from '../../events';
@ -60,9 +58,7 @@ describe('State Management', () => {
const hashedItemStore = new HashedItemStore(store);
const state = new State(param, initial, hashedItemStore);
const getUnhashedSearch = state => {
return unhashQueryString($location.search(), [ state ]);
};
const getUnhashedSearch = () => unhashQuery($location.search());
return { store, hashedItemStore, state, getUnhashedSearch };
};

View file

@ -37,10 +37,12 @@ import { createLegacyClass } from '../utils/legacy_class';
import { callEach } from '../utils/function';
import {
createStateHash,
HashedItemStoreSingleton,
isStateHash,
} from './state_storage';
import {
createStateHash,
isStateHash
} from './state_hashing';
export function StateProvider(Private, $rootScope, $location, stateManagementConfig, config, kbnUrl, $injector) {
const Events = Private(EventsProvider);
@ -293,9 +295,7 @@ export function StateProvider(Private, $rootScope, $location, stateManagementCon
// We need to strip out Angular-specific properties.
const json = angular.toJson(state);
const hash = createStateHash(json, hash => {
return this._hashedItemStore.getItem(hash);
});
const hash = createStateHash(json);
const isItemSet = this._hashedItemStore.setItem(hash, json);
if (isItemSet) {

View file

@ -1,158 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import expect from '@kbn/expect';
import ngMock from 'ng_mock';
import sinon from 'sinon';
import { parse as parseUrl } from 'url';
import { StateProvider } from '../../state';
import { hashUrl } from '..';
describe('hashUrl', function () {
let State;
beforeEach(ngMock.module('kibana'));
beforeEach(ngMock.inject((Private, config) => {
State = Private(StateProvider);
sinon.stub(config, 'get').withArgs('state:storeInSessionStorage').returns(true);
}));
describe('throws error', () => {
it('if states parameter is null', () => {
expect(() => {
hashUrl(null, '');
}).to.throwError();
});
it('if states parameter is empty array', () => {
expect(() => {
hashUrl([], '');
}).to.throwError();
});
});
describe('does nothing', () => {
let states;
beforeEach(() => {
states = [new State('testParam')];
});
it('if url is empty', () => {
const url = '';
expect(hashUrl(states, url)).to.be(url);
});
it('if just a host and port', () => {
const url = 'https://localhost:5601';
expect(hashUrl(states, url)).to.be(url);
});
it('if just a path', () => {
const url = 'https://localhost:5601/app/kibana';
expect(hashUrl(states, url)).to.be(url);
});
it('if just a path and query', () => {
const url = 'https://localhost:5601/app/kibana?foo=bar';
expect(hashUrl(states, url)).to.be(url);
});
it('if empty hash with query', () => {
const url = 'https://localhost:5601/app/kibana?foo=bar#';
expect(hashUrl(states, url)).to.be(url);
});
it('if query parameter matches and there is no hash', () => {
const url = 'https://localhost:5601/app/kibana?testParam=(yes:!t)';
expect(hashUrl(states, url)).to.be(url);
});
it(`if query parameter matches and it's before the hash`, () => {
const url = 'https://localhost:5601/app/kibana?testParam=(yes:!t)';
expect(hashUrl(states, url)).to.be(url);
});
it('if empty hash without query', () => {
const url = 'https://localhost:5601/app/kibana#';
expect(hashUrl(states, url)).to.be(url);
});
it('if hash is just a path', () => {
const url = 'https://localhost:5601/app/kibana#/discover';
expect(hashUrl(states, url)).to.be(url);
});
it('if hash does not have matching query string vals', () => {
const url = 'https://localhost:5601/app/kibana#/discover?foo=bar';
expect(hashUrl(states, url)).to.be(url);
});
});
describe('replaces querystring value with hash', () => {
const getAppQuery = (url) => {
const parsedUrl = parseUrl(url);
const parsedAppUrl = parseUrl(parsedUrl.hash.slice(1), true);
return parsedAppUrl.query;
};
it('if using a single State', () => {
const stateParamKey = 'testParam';
const url = `https://localhost:5601/app/kibana#/discover?foo=bar&${stateParamKey}=(yes:!t)`;
const mockHashedItemStore = {
getItem: () => null,
setItem: sinon.stub().returns(true)
};
const state = new State(stateParamKey, {}, mockHashedItemStore);
const actualUrl = hashUrl([state], url);
expect(mockHashedItemStore.setItem.calledOnce).to.be(true);
const appQuery = getAppQuery(actualUrl);
const hashKey = mockHashedItemStore.setItem.firstCall.args[0];
expect(appQuery[stateParamKey]).to.eql(hashKey);
});
it('if using multiple States', () => {
const stateParamKey1 = 'testParam1';
const stateParamKey2 = 'testParam2';
const url = `https://localhost:5601/app/kibana#/discover?foo=bar&${stateParamKey1}=(yes:!t)&${stateParamKey2}=(yes:!f)`;
const mockHashedItemStore = {
getItem: () => null,
setItem: sinon.stub().returns(true)
};
const state1 = new State(stateParamKey1, {}, mockHashedItemStore);
const state2 = new State(stateParamKey2, {}, mockHashedItemStore);
const actualUrl = hashUrl([state1, state2], url);
expect(mockHashedItemStore.setItem.calledTwice).to.be(true);
const appQuery = getAppQuery(actualUrl);
const hashKey1 = mockHashedItemStore.setItem.firstCall.args[0];
const hashKey2 = mockHashedItemStore.setItem.secondCall.args[0];
expect(appQuery[stateParamKey1]).to.eql(hashKey1);
expect(appQuery[stateParamKey2]).to.eql(hashKey2);
});
});
});

View file

@ -1,87 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import expect from '@kbn/expect';
import ngMock from 'ng_mock';
import sinon from 'sinon';
import { StateProvider } from '../../state';
import { unhashUrl } from '..';
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 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,286 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { mockSessionStorage } from '../state_storage/mock';
import { HashedItemStore } from '../state_storage/hashed_item_store';
import { hashUrl, unhashUrl } from './hash_unhash_url';
describe('hash unhash url', () => {
beforeEach(() => {
mockSessionStorage.clear();
mockSessionStorage.setStubbedSizeLimit(5000000);
});
describe('hash url', () => {
describe('does nothing', () => {
it('if missing input', () => {
expect(() => {
// @ts-ignore
hashUrl();
}).not.toThrowError();
});
it('if url is empty', () => {
const url = '';
expect(hashUrl(url)).toBe(url);
});
it('if just a host and port', () => {
const url = 'https://localhost:5601';
expect(hashUrl(url)).toBe(url);
});
it('if just a path', () => {
const url = 'https://localhost:5601/app/kibana';
expect(hashUrl(url)).toBe(url);
});
it('if just a path and query', () => {
const url = 'https://localhost:5601/app/kibana?foo=bar';
expect(hashUrl(url)).toBe(url);
});
it('if empty hash with query', () => {
const url = 'https://localhost:5601/app/kibana?foo=bar#';
expect(hashUrl(url)).toBe(url);
});
it('if query parameter matches and there is no hash', () => {
const url = 'https://localhost:5601/app/kibana?testParam=(yes:!t)';
expect(hashUrl(url)).toBe(url);
});
it(`if query parameter matches and it's before the hash`, () => {
const url = 'https://localhost:5601/app/kibana?testParam=(yes:!t)';
expect(hashUrl(url)).toBe(url);
});
it('if empty hash without query', () => {
const url = 'https://localhost:5601/app/kibana#';
expect(hashUrl(url)).toBe(url);
});
it('if hash is just a path', () => {
const url = 'https://localhost:5601/app/kibana#/discover';
expect(hashUrl(url)).toBe(url);
});
it('if hash does not have matching query string vals', () => {
const url = 'https://localhost:5601/app/kibana#/discover?foo=bar';
expect(hashUrl(url)).toBe(url);
});
});
describe('replaces expanded state with hash', () => {
it('if uses single state param', () => {
const stateParamKey = '_g';
const stateParamValue = '(yes:!t)';
const url = `https://localhost:5601/app/kibana#/discover?foo=bar&${stateParamKey}=${stateParamValue}`;
const result = hashUrl(url);
expect(result).toMatchInlineSnapshot(
`"https://localhost:5601/app/kibana#/discover?foo=bar&_g=h@4e60e02"`
);
expect(mockSessionStorage.getItem('kbn.hashedItemsIndex.v1')).toBeTruthy();
expect(mockSessionStorage.getItem('h@4e60e02')).toEqual(JSON.stringify({ yes: true }));
});
it('if uses multiple states params', () => {
const stateParamKey1 = '_g';
const stateParamValue1 = '(yes:!t)';
const stateParamKey2 = '_a';
const stateParamValue2 = '(yes:!f)';
const stateParamKey3 = '_b';
const stateParamValue3 = '(yes:!f)';
const url = `https://localhost:5601/app/kibana#/discover?foo=bar&${stateParamKey1}=${stateParamValue1}&${stateParamKey2}=${stateParamValue2}&${stateParamKey3}=${stateParamValue3}`;
const result = hashUrl(url);
expect(result).toMatchInlineSnapshot(
`"https://localhost:5601/app/kibana#/discover?foo=bar&_g=h@4e60e02&_a=h@61fa078&_b=(yes:!f)"`
);
expect(mockSessionStorage.getItem('h@4e60e02')).toEqual(JSON.stringify({ yes: true }));
expect(mockSessionStorage.getItem('h@61fa078')).toEqual(JSON.stringify({ yes: false }));
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');
}
expect(mockSessionStorage.getItem(HashedItemStore.PERSISTED_INDEX_KEY)).toBeTruthy();
expect(mockSessionStorage.length).toBe(3);
});
it('hashes only whitelisted properties', () => {
const stateParamKey1 = '_g';
const stateParamValue1 = '(yes:!t)';
const stateParamKey2 = '_a';
const stateParamValue2 = '(yes:!f)';
const stateParamKey3 = '_someother';
const stateParamValue3 = '(yes:!f)';
const url = `https://localhost:5601/app/kibana#/discover?foo=bar&${stateParamKey1}=${stateParamValue1}&${stateParamKey2}=${stateParamValue2}&${stateParamKey3}=${stateParamValue3}`;
const result = hashUrl(url);
expect(result).toMatchInlineSnapshot(
`"https://localhost:5601/app/kibana#/discover?foo=bar&_g=h@4e60e02&_a=h@61fa078&_someother=(yes:!f)"`
);
expect(mockSessionStorage.length).toBe(3); // 2 hashes + HashedItemStoreSingleton.PERSISTED_INDEX_KEY
});
});
it('throws error if unable to hash url', () => {
const stateParamKey1 = '_g';
const stateParamValue1 = '(yes:!t)';
mockSessionStorage.setStubbedSizeLimit(1);
const url = `https://localhost:5601/app/kibana#/discover?foo=bar&${stateParamKey1}=${stateParamValue1}`;
expect(() => hashUrl(url)).toThrowError();
});
});
describe('unhash url', () => {
describe('does nothing', () => {
it('if missing input', () => {
expect(() => {
// @ts-ignore
}).not.toThrowError();
});
it('if just a host and port', () => {
const url = 'https://localhost:5601';
expect(unhashUrl(url)).toBe(url);
});
it('if just a path', () => {
const url = 'https://localhost:5601/app/kibana';
expect(unhashUrl(url)).toBe(url);
});
it('if just a path and query', () => {
const url = 'https://localhost:5601/app/kibana?foo=bar';
expect(unhashUrl(url)).toBe(url);
});
it('if empty hash with query', () => {
const url = 'https://localhost:5601/app/kibana?foo=bar#';
expect(unhashUrl(url)).toBe(url);
});
it('if empty hash without query', () => {
const url = 'https://localhost:5601/app/kibana#';
expect(unhashUrl(url)).toBe(url);
});
it('if hash is just a path', () => {
const url = 'https://localhost:5601/app/kibana#/discover';
expect(unhashUrl(url)).toBe(url);
});
it('if hash does not have matching query string vals', () => {
const url = 'https://localhost:5601/app/kibana#/discover?foo=bar';
expect(unhashUrl(url)).toBe(url);
});
it("if hash has matching query, but it isn't hashed", () => {
const stateParamKey = '_g';
const stateParamValue = '(yes:!t)';
const url = `https://localhost:5601/app/kibana#/discover?foo=bar&${stateParamKey}=${stateParamValue}`;
expect(unhashUrl(url)).toBe(url);
});
});
describe('replaces expanded state with hash', () => {
it('if uses single state param', () => {
const stateParamKey = '_g';
const stateParamValueHashed = 'h@4e60e02';
const state = { yes: true };
mockSessionStorage.setItem(stateParamValueHashed, JSON.stringify(state));
const url = `https://localhost:5601/app/kibana#/discover?foo=bar&${stateParamKey}=${stateParamValueHashed}`;
const result = unhashUrl(url);
expect(result).toMatchInlineSnapshot(
`"https://localhost:5601/app/kibana#/discover?foo=bar&_g=(yes:!t)"`
);
});
it('if uses multiple state param', () => {
const stateParamKey1 = '_g';
const stateParamValueHashed1 = 'h@4e60e02';
const state1 = { yes: true };
const stateParamKey2 = '_a';
const stateParamValueHashed2 = 'h@61fa078';
const state2 = { yes: false };
mockSessionStorage.setItem(stateParamValueHashed1, JSON.stringify(state1));
mockSessionStorage.setItem(stateParamValueHashed2, JSON.stringify(state2));
const url = `https://localhost:5601/app/kibana#/discover?foo=bar&${stateParamKey1}=${stateParamValueHashed1}&${stateParamKey2}=${stateParamValueHashed2}`;
const result = unhashUrl(url);
expect(result).toMatchInlineSnapshot(
`"https://localhost:5601/app/kibana#/discover?foo=bar&_g=(yes:!t)&_a=(yes:!f)"`
);
});
it('unhashes only whitelisted properties', () => {
const stateParamKey1 = '_g';
const stateParamValueHashed1 = 'h@4e60e02';
const state1 = { yes: true };
const stateParamKey2 = '_a';
const stateParamValueHashed2 = 'h@61fa078';
const state2 = { yes: false };
const stateParamKey3 = '_someother';
const stateParamValueHashed3 = 'h@61fa078';
const state3 = { yes: false };
mockSessionStorage.setItem(stateParamValueHashed1, JSON.stringify(state1));
mockSessionStorage.setItem(stateParamValueHashed2, JSON.stringify(state2));
mockSessionStorage.setItem(stateParamValueHashed3, JSON.stringify(state3));
const url = `https://localhost:5601/app/kibana#/discover?foo=bar&${stateParamKey1}=${stateParamValueHashed1}&${stateParamKey2}=${stateParamValueHashed2}&${stateParamKey3}=${stateParamValueHashed3}`;
const result = unhashUrl(url);
expect(result).toMatchInlineSnapshot(
`"https://localhost:5601/app/kibana#/discover?foo=bar&_g=(yes:!t)&_a=(yes:!f)&_someother=h@61fa078"`
);
});
});
it('throws error if unable to restore the url', () => {
const stateParamKey1 = '_g';
const stateParamValueHashed1 = 'h@4e60e02';
const url = `https://localhost:5601/app/kibana#/discover?foo=bar&${stateParamKey1}=${stateParamValueHashed1}`;
expect(() => unhashUrl(url)).toThrowErrorMatchingInlineSnapshot(
`"Unable to completely restore the URL, be sure to use the share functionality."`
);
});
});
describe('hash unhash url integration', () => {
it('hashing and unhashing url should produce the same result', () => {
const stateParamKey1 = '_g';
const stateParamValue1 = '(yes:!t)';
const stateParamKey2 = '_a';
const stateParamValue2 = '(yes:!f)';
const stateParamKey3 = '_someother';
const stateParamValue3 = '(yes:!f)';
const url = `https://localhost:5601/app/kibana#/discover?foo=bar&${stateParamKey1}=${stateParamValue1}&${stateParamKey2}=${stateParamValue2}&${stateParamKey3}=${stateParamValue3}`;
const result = unhashUrl(hashUrl(url));
expect(url).toEqual(result);
});
});
});

View file

@ -0,0 +1,157 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { i18n } from '@kbn/i18n';
import rison, { RisonObject } from 'rison-node';
import { stringify as stringifyQueryString } from 'querystring';
import encodeUriQuery from 'encode-uri-query';
import { format as formatUrl, parse as parseUrl } from 'url';
import { HashedItemStoreSingleton } from '../state_storage';
import { createStateHash, isStateHash } from './state_hash';
export type IParsedUrlQuery = Record<string, any>;
interface IUrlQueryMapperOptions {
hashableParams: string[];
}
export type IUrlQueryReplacerOptions = IUrlQueryMapperOptions;
export const unhashQuery = createQueryMapper(stateHashToRisonState);
export const hashQuery = createQueryMapper(risonStateToStateHash);
export const unhashUrl = createQueryReplacer(unhashQuery);
export const hashUrl = createQueryReplacer(hashQuery);
// naive hack, but this allows to decouple these utils from AppState, GlobalState for now
// when removing AppState, GlobalState and migrating to IState containers,
// need to make sure that apps explicitly passing this whitelist to hash
const __HACK_HARDCODED_LEGACY_HASHABLE_PARAMS = ['_g', '_a', '_s'];
function createQueryMapper(queryParamMapper: (q: string) => string | null) {
return (
query: IParsedUrlQuery,
options: IUrlQueryMapperOptions = {
hashableParams: __HACK_HARDCODED_LEGACY_HASHABLE_PARAMS,
}
) =>
Object.fromEntries(
Object.entries(query || {}).map(([name, value]) => {
if (!options.hashableParams.includes(name)) return [name, value];
return [name, queryParamMapper(value) || value];
})
);
}
function createQueryReplacer(
queryMapper: (q: IParsedUrlQuery, options?: IUrlQueryMapperOptions) => IParsedUrlQuery,
options?: IUrlQueryReplacerOptions
) {
return (url: string) => {
if (!url) return url;
const parsedUrl = parseUrl(url, true);
if (!parsedUrl.hash) return url;
const appUrl = parsedUrl.hash.slice(1); // trim the #
if (!appUrl) return url;
const appUrlParsed = parseUrl(appUrl, true);
if (!appUrlParsed.query) return url;
const changedAppQuery = queryMapper(appUrlParsed.query, options);
// encodeUriQuery implements the less-aggressive encoding done naturally by
// the browser. We use it to generate the same urls the browser would
const changedAppQueryString = stringifyQueryString(changedAppQuery, undefined, undefined, {
encodeURIComponent: encodeUriQuery,
});
return formatUrl({
...parsedUrl,
hash: formatUrl({
pathname: appUrlParsed.pathname,
search: changedAppQueryString,
}),
});
};
}
// TODO: this helper should be merged with or replaced by
// src/legacy/ui/public/state_management/state_storage/hashed_item_store.ts
// maybe to become simplified stateless version
export function retrieveState(stateHash: string): RisonObject {
const json = HashedItemStoreSingleton.getItem(stateHash);
const throwUnableToRestoreUrlError = () => {
throw new Error(
i18n.translate('common.ui.stateManagement.unableToRestoreUrlErrorMessage', {
defaultMessage:
'Unable to completely restore the URL, be sure to use the share functionality.',
})
);
};
if (json === null) {
return throwUnableToRestoreUrlError();
}
try {
return JSON.parse(json);
} catch (e) {
return throwUnableToRestoreUrlError();
}
}
// TODO: this helper should be merged with or replaced by
// src/legacy/ui/public/state_management/state_storage/hashed_item_store.ts
// maybe to become simplified stateless version
export function persistState(state: RisonObject): string {
const json = JSON.stringify(state);
const hash = createStateHash(json);
const isItemSet = HashedItemStoreSingleton.setItem(hash, json);
if (isItemSet) return hash;
// If we ran out of space trying to persist the state, notify the user.
const message = i18n.translate(
'common.ui.stateManagement.unableToStoreHistoryInSessionErrorMessage',
{
defaultMessage:
'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 {gitHubIssuesUrl}.',
values: { gitHubIssuesUrl: 'https://github.com/elastic/kibana/issues' },
}
);
throw new Error(message);
}
function stateHashToRisonState(stateHashOrRison: string): string {
if (isStateHash(stateHashOrRison)) {
return rison.encode(retrieveState(stateHashOrRison));
}
return stateHashOrRison;
}
function risonStateToStateHash(stateHashOrRison: string): string | null {
if (isStateHash(stateHashOrRison)) {
return stateHashOrRison;
}
return persistState(rison.decode(stateHashOrRison) as RisonObject);
}

View file

@ -1,97 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import encodeUriQuery from 'encode-uri-query';
import rison from 'rison-node';
import { parse as parseUrl, format as formatUrl } from 'url';
import { stringify as stringifyQuerystring } from 'querystring';
const conservativeStringifyQuerystring = (query) => {
return stringifyQuerystring(query, null, null, {
encodeURIComponent: encodeUriQuery
});
};
const hashStateInQuery = (state, query) => {
const name = state.getQueryParamName();
const value = query[name];
if (!value) {
return { name, value };
}
const decodedValue = rison.decode(value);
const hashedValue = state.toQueryParam(decodedValue);
return { name, value: hashedValue };
};
const hashStatesInQuery = (states, query) => {
const hashedQuery = states.reduce((result, state) => {
const { name, value } = hashStateInQuery(state, query);
if (value) {
result[name] = value;
}
return result;
}, {});
return {
...query,
...hashedQuery
};
};
export const hashUrl = (states, redirectUrl) => {
// we need states to proceed, throwing an error if we don't have any
if (states === null || !states.length) {
throw new Error('states parameter must be an Array with length greater than 0');
}
const parsedUrl = parseUrl(redirectUrl);
// if we don't have a hash, we return the redirectUrl without hashing anything
if (!parsedUrl.hash) {
return redirectUrl;
}
// The URLs that we use aren't "conventional" and the hash is sometimes appearing before
// the querystring, even though conventionally they appear after it. The parsedUrl
// is the entire URL, and the parsedAppUrl is everything after the hash.
//
// EXAMPLE
// parsedUrl: /app/kibana#/visualize/edit/somelongguid?g=()&a=()
// parsedAppUrl: /visualize/edit/somelongguid?g=()&a=()
const parsedAppUrl = parseUrl(parsedUrl.hash.slice(1), true);
// the parsedAppUrl actually has the query that we care about
const query = parsedAppUrl.query;
const newQuery = hashStatesInQuery(states, query);
const newHash = formatUrl({
search: conservativeStringifyQuerystring(newQuery),
pathname: parsedAppUrl.pathname
});
return formatUrl({
hash: `#${newHash}`,
host: parsedUrl.host,
search: parsedUrl.search,
pathname: parsedUrl.pathname,
protocol: parsedUrl.protocol,
});
};

View file

@ -17,4 +17,5 @@
* under the License.
*/
export function unhashUrl(url: string, kbnStates: any[]): any;
export { hashUrl, unhashUrl, hashQuery, unhashQuery } from './hash_unhash_url';
export { createStateHash, isStateHash } from './state_hash';

View file

@ -17,58 +17,55 @@
* under the License.
*/
import expect from '@kbn/expect';
import { encode as encodeRison } from 'rison-node';
import {
createStateHash,
isStateHash,
} from '../state_hash';
import { mockSessionStorage } from '../state_storage/mock';
import { createStateHash, isStateHash } from '../state_hashing';
describe('stateHash', () => {
const existingJsonProvider = () => null;
beforeEach(() => {
mockSessionStorage.clear();
});
describe('#createStateHash', () => {
describe('returns a hash', () => {
it('returns a hash', () => {
const json = JSON.stringify({ a: 'a' });
const hash = createStateHash(json, existingJsonProvider);
expect(isStateHash(hash)).to.be(true);
const hash = createStateHash(json);
expect(isStateHash(hash)).toBe(true);
});
describe('returns the same hash for the same input', () => {
it('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);
const hash1 = createStateHash(json);
const hash2 = createStateHash(json);
expect(hash1).toEqual(hash2);
});
describe('returns a different hash for different input', () => {
it('returns a different hash for different input', () => {
const json1 = JSON.stringify({ a: 'a' });
const hash1 = createStateHash(json1, existingJsonProvider);
const hash1 = createStateHash(json1);
const json2 = JSON.stringify({ a: 'b' });
const hash2 = createStateHash(json2, existingJsonProvider);
expect(hash1).to.not.equal(hash2);
const hash2 = createStateHash(json2);
expect(hash1).not.toEqual(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);
const hash = createStateHash(json);
expect(isStateHash(hash)).toBe(true);
});
it('returns false for values not created using #createStateHash', () => {
const json = JSON.stringify({ a: 'a' });
expect(isStateHash(json)).to.be(false);
expect(isStateHash(json)).toBe(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);
expect(isStateHash(rison)).toBe(false);
});
});
});

View file

@ -17,12 +17,16 @@
* under the License.
*/
import { Sha256 } from '../../../../../core/public/utils/';
import { Sha256 } from '../../../../../core/public/utils';
import { HashedItemStoreSingleton } from '../state_storage';
// This prefix is used to identify hash strings that have been encoded in the URL.
const HASH_PREFIX = 'h@';
export function createStateHash(json, existingJsonProvider) {
export function createStateHash(
json: string,
existingJsonProvider?: (hash: string) => string | null // TODO: temp while state.js relies on this in tests
) {
if (typeof json !== 'string') {
throw new Error('createHash only accepts strings (JSON).');
}
@ -36,13 +40,15 @@ export function createStateHash(json, existingJsonProvider) {
// 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);
const existingJson = existingJsonProvider
? existingJsonProvider(shortenedHash)
: HashedItemStoreSingleton.getItem(shortenedHash);
if (existingJson === null || existingJson === json) break;
}
return `${HASH_PREFIX}${shortenedHash}`;
}
export function isStateHash(str) {
export function isStateHash(str: string) {
return String(str).indexOf(HASH_PREFIX) === 0;
}

View file

@ -1,37 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { mapValues } from 'lodash';
import { ParsedUrlQuery } from 'querystring';
import { State } from '../state';
/**
* Takes in a parsed url query and state objects, finding the state objects that match the query parameters and expanding
* the hashed state. For example, a url query string like '?_a=@12353&_g=@19028df' will become
* '?_a=[expanded app state here]&_g=[expanded global state here]. This is used when storeStateInSessionStorage is turned on.
*/
export function unhashQueryString(
parsedQueryString: ParsedUrlQuery,
states: State[]
): ParsedUrlQuery {
return mapValues(parsedQueryString, (val, key) => {
const state = states.find(s => key === s.getQueryParamName());
return state ? state.translateHashToRison(val) : val;
});
}

View file

@ -1,68 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
parse as parseUrl,
format as formatUrl,
} from 'url';
import encodeUriQuery from 'encode-uri-query';
import {
stringify as stringifyQueryString
} from 'querystring';
import { unhashQueryString } from './unhash_query_string';
export 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);
// encodeUriQuery implements the less-aggressive encoding done naturally by
// the browser. We use it to generate the same urls the browser would
const appQueryStringWithoutHashes = stringifyQueryString(appQueryWithoutHashes, null, null, {
encodeURIComponent: encodeUriQuery
});
return formatUrl({
...urlWithHashesParsed,
hash: formatUrl({
pathname: appUrlParsed.pathname,
search: appQueryStringWithoutHashes,
})
});
}

View file

@ -17,22 +17,19 @@
* under the License.
*/
import expect from '@kbn/expect';
import sinon from 'sinon';
import { StubBrowserStorage } from 'test_utils/stub_browser_storage';
import { HashedItemStore } from '../hashed_item_store';
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');
const spy = jest.spyOn(sessionStorage, 'getItem');
new HashedItemStore(sessionStorage);
sinon.assert.calledWith(sessionStorage.getItem, HashedItemStore.PERSISTED_INDEX_KEY);
sessionStorage.getItem.restore();
expect(spy).toBeCalledWith(HashedItemStore.PERSISTED_INDEX_KEY);
spy.mockReset();
});
it('sorts indexed items by touched property', () => {
@ -57,14 +54,14 @@ describe('hashedItemStore', () => {
sessionStorage.setItem(HashedItemStore.PERSISTED_INDEX_KEY, JSON.stringify({ a, b, c }));
const hashedItemStore = new HashedItemStore(sessionStorage);
expect(hashedItemStore._indexedItems).to.eql([a, c, b]);
expect((hashedItemStore as any).indexedItems).toEqual([a, c, b]);
});
});
describe('#setItem', () => {
describe('if the item exists in sessionStorage', () => {
let sessionStorage;
let hashedItemStore;
let sessionStorage: Storage;
let hashedItemStore: HashedItemStore;
const hash = 'a';
const item = JSON.stringify({});
@ -75,19 +72,19 @@ describe('hashedItemStore', () => {
it('persists the item in sessionStorage', () => {
hashedItemStore.setItem(hash, item);
expect(sessionStorage.getItem(hash)).to.equal(item);
expect(sessionStorage.getItem(hash)).toEqual(item);
});
it('returns true', () => {
const result = hashedItemStore.setItem(hash, item);
expect(result).to.equal(true);
expect(result).toEqual(true);
});
});
describe(`if the item doesn't exist in sessionStorage`, () => {
describe(`if there's storage space`, () => {
let sessionStorage;
let hashedItemStore;
let sessionStorage: Storage;
let hashedItemStore: HashedItemStore;
const hash = 'a';
const item = JSON.stringify({});
@ -98,32 +95,31 @@ describe('hashedItemStore', () => {
it('persists the item in sessionStorage', () => {
hashedItemStore.setItem(hash, item);
expect(sessionStorage.getItem(hash)).to.equal(item);
expect(sessionStorage.getItem(hash)).toEqual(item);
});
it('returns true', () => {
const result = hashedItemStore.setItem(hash, item);
expect(result).to.equal(true);
expect(result).toEqual(true);
});
});
describe(`if there isn't storage space`, () => {
let fakeTimer;
let sessionStorage;
let hashedItemStore;
let storageSizeLimit;
let sessionStorage: Storage;
let hashedItemStore: HashedItemStore;
let storageSizeLimit: number;
const hash = 'a';
const item = JSON.stringify({});
function setItemLater(hash, item) {
function setItemLater(_hash: string, _item: string) {
// Move time forward, so this item will be "touched" most recently.
fakeTimer.tick(1);
return hashedItemStore.setItem(hash, item);
jest.advanceTimersByTime(1);
return hashedItemStore.setItem(_hash, _item);
}
beforeEach(() => {
// Control time.
fakeTimer = sinon.useFakeTimers(Date.now());
jest.useFakeTimers();
sessionStorage = new StubBrowserStorage();
hashedItemStore = new HashedItemStore(sessionStorage);
@ -141,29 +137,29 @@ describe('hashedItemStore', () => {
afterEach(() => {
// Stop controlling time.
fakeTimer.restore();
jest.useRealTimers();
});
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);
expect(sessionStorage.getItem('b')).toEqual(null);
expect(sessionStorage.getItem('c')).toEqual(item);
});
it('persists the item in sessionStorage', () => {
setItemLater(hash, item);
expect(sessionStorage.getItem(hash)).to.equal(item);
expect(sessionStorage.getItem(hash)).toEqual(item);
});
it('returns true', () => {
const result = setItemLater(hash, item);
expect(result).to.equal(true);
expect(result).toEqual(true);
});
});
describe(`and the item won't fit`, () => {
let itemTooBigToFit;
let itemTooBigToFit: string;
beforeEach(() => {
// Make sure the item is longer than the storage size limit.
@ -176,18 +172,18 @@ describe('hashedItemStore', () => {
it('removes all items', () => {
setItemLater(hash, itemTooBigToFit);
expect(sessionStorage.getItem('b')).to.equal(null);
expect(sessionStorage.getItem('c')).to.equal(null);
expect(sessionStorage.getItem('b')).toEqual(null);
expect(sessionStorage.getItem('c')).toEqual(null);
});
it(`doesn't persist the item in sessionStorage`, () => {
setItemLater(hash, itemTooBigToFit);
expect(sessionStorage.getItem(hash)).to.equal(null);
expect(sessionStorage.getItem(hash)).toEqual(null);
});
it('returns false', () => {
const result = setItemLater(hash, itemTooBigToFit);
expect(result).to.equal(false);
expect(result).toEqual(false);
});
});
});
@ -196,25 +192,24 @@ describe('hashedItemStore', () => {
describe('#getItem', () => {
describe('if the item exists in sessionStorage', () => {
let fakeTimer;
let sessionStorage;
let hashedItemStore;
let sessionStorage: Storage;
let hashedItemStore: HashedItemStore;
function setItemLater(hash, item) {
function setItemLater(hash: string, item: string) {
// Move time forward, so this item will be "touched" most recently.
fakeTimer.tick(1);
jest.advanceTimersByTime(1);
return hashedItemStore.setItem(hash, item);
}
function getItemLater(hash) {
function getItemLater(hash: string) {
// Move time forward, so this item will be "touched" most recently.
fakeTimer.tick(1);
jest.advanceTimersByTime(1);
return hashedItemStore.getItem(hash);
}
beforeEach(() => {
// Control time.
fakeTimer = sinon.useFakeTimers(Date.now());
jest.useFakeTimers();
sessionStorage = new StubBrowserStorage();
hashedItemStore = new HashedItemStore(sessionStorage);
@ -223,12 +218,12 @@ describe('hashedItemStore', () => {
afterEach(() => {
// Stop controlling time.
fakeTimer.restore();
jest.useRealTimers();
});
it('returns the item', () => {
const retrievedItem = hashedItemStore.getItem('1');
expect(retrievedItem).to.be('a');
expect(retrievedItem).toBe('a');
});
it('prevents the item from being first to be removed when freeing up storage space', () => {
@ -244,14 +239,14 @@ describe('hashedItemStore', () => {
// 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');
expect(hashedItemStore.getItem('2')).toEqual(null);
expect(hashedItemStore.getItem('1')).toEqual('a');
});
});
describe(`if the item doesn't exist in sessionStorage`, () => {
let sessionStorage;
let hashedItemStore;
let sessionStorage: Storage;
let hashedItemStore: HashedItemStore;
const hash = 'a';
beforeEach(() => {
@ -261,40 +256,38 @@ describe('hashedItemStore', () => {
it('returns null', () => {
const retrievedItem = hashedItemStore.getItem(hash);
expect(retrievedItem).to.be(null);
expect(retrievedItem).toBe(null);
});
});
});
});
describe('behavior', () => {
let fakeTimer;
let sessionStorage;
let hashedItemStore;
let sessionStorage: Storage;
let hashedItemStore: HashedItemStore;
function setItemLater(hash, item) {
function setItemLater(hash: string, item: string) {
// Move time forward, so this item will be "touched" most recently.
fakeTimer.tick(1);
jest.advanceTimersByTime(1);
return hashedItemStore.setItem(hash, item);
}
function getItemLater(hash) {
function getItemLater(hash: string) {
// Move time forward, so this item will be "touched" most recently.
fakeTimer.tick(1);
jest.advanceTimersByTime(1);
return hashedItemStore.getItem(hash);
}
beforeEach(() => {
// Control time.
fakeTimer = sinon.useFakeTimers(Date.now());
jest.useFakeTimers();
sessionStorage = new StubBrowserStorage();
hashedItemStore = new HashedItemStore(sessionStorage);
});
afterEach(() => {
// Stop controlling time.
fakeTimer.restore();
jest.useRealTimers();
});
it('orders items to be removed based on when they were last retrieved', () => {
@ -314,39 +307,39 @@ describe('hashedItemStore', () => {
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');
expect(hashedItemStore.getItem('1')).toEqual(null);
expect(hashedItemStore.getItem('3')).toEqual('c');
expect(hashedItemStore.getItem('2')).toEqual('b');
expect(hashedItemStore.getItem('4')).toEqual('d');
expect(hashedItemStore.getItem('5')).toEqual('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');
expect(hashedItemStore.getItem('3')).toEqual(null);
expect(hashedItemStore.getItem('2')).toEqual('b');
expect(hashedItemStore.getItem('4')).toEqual('d');
expect(hashedItemStore.getItem('5')).toEqual('e');
expect(hashedItemStore.getItem('6')).toEqual('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');
expect(hashedItemStore.getItem('2')).toEqual(null);
expect(hashedItemStore.getItem('4')).toEqual('d');
expect(hashedItemStore.getItem('5')).toEqual('e');
expect(hashedItemStore.getItem('6')).toEqual('f');
expect(hashedItemStore.getItem('7')).toEqual('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');
expect(hashedItemStore.getItem('4')).toEqual(null);
expect(hashedItemStore.getItem('5')).toEqual('e');
expect(hashedItemStore.getItem('6')).toEqual('f');
expect(hashedItemStore.getItem('7')).toEqual('g');
expect(hashedItemStore.getItem('8')).toEqual('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');
expect(hashedItemStore.getItem('5')).toEqual(null);
expect(hashedItemStore.getItem('6')).toEqual('f');
expect(hashedItemStore.getItem('7')).toEqual('g');
expect(hashedItemStore.getItem('8')).toEqual('h');
expect(hashedItemStore.getItem('9')).toEqual('i');
});
});
});

View file

@ -72,59 +72,66 @@
import { pull, sortBy } from 'lodash';
interface IndexedItem {
hash: string;
touched?: number; // Date.now()
}
export class HashedItemStore {
static readonly PERSISTED_INDEX_KEY = 'kbn.hashedItemsIndex.v1';
private sessionStorage: Storage;
// 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.
private indexedItems: IndexedItem[] = [];
/**
* 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 = [];
constructor(sessionStorage: Storage) {
this.sessionStorage = sessionStorage;
// Potentially restore a previously persisted index. This happens when
// we re-open a closed tab.
const persistedItemIndex = this._sessionStorage.getItem(HashedItemStore.PERSISTED_INDEX_KEY);
const persistedItemIndex = this.sessionStorage.getItem(HashedItemStore.PERSISTED_INDEX_KEY);
if (persistedItemIndex) {
this._indexedItems = sortBy(JSON.parse(persistedItemIndex) || [], 'touched');
this.indexedItems = sortBy(JSON.parse(persistedItemIndex) || [], 'touched');
}
}
setItem(hash, item) {
const isItemPersisted = this._persistItem(hash, item);
setItem(hash: string, item: string): boolean {
const isItemPersisted = this.persistItem(hash, item);
if (isItemPersisted) {
this._touchHash(hash);
this.touchHash(hash);
}
return isItemPersisted;
}
getItem(hash) {
const item = this._sessionStorage.getItem(hash);
getItem(hash: string): string | null {
const item = this.sessionStorage.getItem(hash);
if (item !== null) {
this._touchHash(hash);
this.touchHash(hash);
}
return item;
}
_getIndexedItem(hash) {
return this._indexedItems.find(indexedItem => indexedItem.hash === hash);
private getIndexedItem(hash: string) {
return this.indexedItems.find(indexedItem => indexedItem.hash === hash);
}
_persistItem(hash, item) {
private persistItem(hash: string, item: string): boolean {
try {
this._sessionStorage.setItem(hash, item);
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 (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;
@ -132,39 +139,39 @@ export class HashedItemStore {
// 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();
this.removeOldestItem();
// Try to persist again.
return this._persistItem(hash, item);
return this.persistItem(hash, item);
}
}
_removeOldestItem() {
const oldestIndexedItem = this._indexedItems.shift();
// Remove oldest item from storage.
this._sessionStorage.removeItem(oldestIndexedItem.hash);
private removeOldestItem() {
const oldestIndexedItem = this.indexedItems.shift();
if (oldestIndexedItem) {
// Remove oldest item from storage.
this.sessionStorage.removeItem(oldestIndexedItem.hash);
}
}
_touchHash(hash) {
private touchHash(hash: string) {
// 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 };
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();
indexedItem.touched = Date.now();
// ensure that the item is last in the index
pull(this._indexedItems, indexedItem);
this._indexedItems.push(indexedItem);
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(
this.sessionStorage.setItem(
HashedItemStore.PERSISTED_INDEX_KEY,
JSON.stringify(this._indexedItems)
JSON.stringify(this.indexedItems)
);
}
}
HashedItemStore.PERSISTED_INDEX_KEY = 'kbn.hashedItemsIndex.v1';

View file

@ -18,8 +18,3 @@
*/
export { HashedItemStoreSingleton } from './hashed_item_store_singleton';
export {
createStateHash,
isStateHash,
} from './state_hash';

View file

@ -17,12 +17,21 @@
* under the License.
*/
import { AppState } from '../app_state';
import { GlobalState } from '../global_state';
import { State } from '../state';
import { StubBrowserStorage } from 'test_utils/stub_browser_storage';
import { HashedItemStore } from './hashed_item_store';
export function getUnhashableStatesProvider(getAppState: () => AppState, globalState: GlobalState) {
return function getUnhashableStates(): State[] {
return [getAppState(), globalState].filter(Boolean);
/**
* Useful for mocking state_storage from jest,
*
* import { mockSessionStorage } from '../state_storage/mock;
*
* And all tests in the test file will use HashedItemStoreSingleton
* with underlying mockSessionStorage we have access to
*/
export const mockSessionStorage = new StubBrowserStorage();
const mockHashedItemStore = new HashedItemStore(mockSessionStorage);
jest.mock('../state_storage', () => {
return {
HashedItemStoreSingleton: mockHashedItemStore,
};
}
});

View file

@ -39,6 +39,18 @@ describe('StubBrowserStorage', () => {
});
});
describe('#clear()', () => {
it('clears items', () => {
const store = new StubBrowserStorage();
store.setItem('1', '1');
store.setItem('2', '2');
store.clear();
expect(store.getItem('1')).toBe(null);
expect(store.getItem('2')).toBe(null);
expect(store.length).toBe(0);
});
});
describe('#length', () => {
it('reports the number of items stored', () => {
const store = new StubBrowserStorage();

View file

@ -17,9 +17,9 @@
* under the License.
*/
export class StubBrowserStorage {
private readonly keys: string[] = [];
private readonly values: string[] = [];
export class StubBrowserStorage implements Storage {
private keys: string[] = [];
private values: string[] = [];
private size = 0;
private sizeLimit = 5000000; // 5mb, minimum browser storage size;
@ -73,6 +73,12 @@ export class StubBrowserStorage {
this.values.splice(i, 1);
}
public clear() {
this.size = 0;
this.keys = [];
this.values = [];
}
// -----------------------------------------------------------------------------------------------
// Test-specific methods.
// -----------------------------------------------------------------------------------------------

View file

@ -105,7 +105,7 @@ export default function({ getService, getPageObjects }: FtrProviderContext) {
await browser.get(`${basePath}/app/kibana#/home`, false);
await retry.waitFor(
'navigation to home app',
async () => (await browser.getCurrentUrl()) === `${basePath}/app/kibana#/home?_g=()`
async () => (await browser.getCurrentUrl()) === `${basePath}/app/kibana#/home`
);
await browser.get(`${basePath}/app/kibana#/home?_g=()&a=b/c`, false);

View file

@ -234,7 +234,7 @@ export default function ({ getService, getPageObjects }) {
describe('embedded mode', () => {
it('should hide side editor if embed is set to true in url', async () => {
const url = await browser.getCurrentUrl();
const embedUrl = url.split('/visualize/').pop().replace('?_g=', '?embed=true&_g=');
const embedUrl = url.split('/visualize/').pop() + '&embed=true';
await PageObjects.common.navigateToUrl('visualize', embedUrl);
await PageObjects.header.waitUntilLoadingHasFinished();
const sideEditorExists = await PageObjects.visualize.getSideEditorExists();
@ -243,7 +243,7 @@ export default function ({ getService, getPageObjects }) {
after(async () => {
const url = await browser.getCurrentUrl();
const embedUrl = url.split('/visualize/').pop().replace('?embed=true&', '?');
const embedUrl = url.split('/visualize/').pop().replace('embed=true', '');
await PageObjects.common.navigateToUrl('visualize', embedUrl);
});
});

View file

@ -17,7 +17,8 @@
* under the License.
*/
export { getUnhashableStatesProvider } from './get_unhashable_states_provider';
export { hashUrl } from './hash_url';
export { unhashQueryString } from './unhash_query_string';
export { unhashUrl } from './unhash_url';
declare module 'encode-uri-query' {
function encodeUriQuery(query: string, usePercentageSpace?: boolean): string;
// eslint-disable-next-line import/no-default-export
export default encodeUriQuery;
}

39
typings/rison_node.d.ts vendored Normal file
View file

@ -0,0 +1,39 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
declare module 'rison-node' {
export type RisonValue = null | boolean | number | string | RisonObject | RisonArray;
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface RisonArray extends Array<RisonValue> {}
export interface RisonObject {
[key: string]: RisonValue;
}
export const decode: (input: string) => RisonValue;
// eslint-disable-next-line @typescript-eslint/camelcase
export const decode_object: (input: string) => RisonObject;
export const encode: <Input extends RisonValue>(input: Input) => string;
// eslint-disable-next-line @typescript-eslint/camelcase
export const encode_object: <Input extends RisonObject>(input: Input) => string;
}

11
x-pack/typings/encode_uri_query.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
declare module 'encode-uri-query' {
function encodeUriQuery(query: string, usePercentageSpace?: boolean): string;
// eslint-disable-next-line import/no-default-export
export default encodeUriQuery;
}