mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
7ce0a37e3e
commit
217608d11e
32 changed files with 709 additions and 651 deletions
|
@ -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: {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
};
|
|
@ -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';
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
});
|
||||
}
|
|
@ -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,
|
||||
})
|
||||
});
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -18,8 +18,3 @@
|
|||
*/
|
||||
|
||||
export { HashedItemStoreSingleton } from './hashed_item_store_singleton';
|
||||
|
||||
export {
|
||||
createStateHash,
|
||||
isStateHash,
|
||||
} from './state_hash';
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
});
|
|
@ -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();
|
||||
|
|
|
@ -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.
|
||||
// -----------------------------------------------------------------------------------------------
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
39
typings/rison_node.d.ts
vendored
Normal 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
11
x-pack/typings/encode_uri_query.d.ts
vendored
Normal 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;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue