diff --git a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app_controller.tsx b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app_controller.tsx index 7972c7603d311..b5a6db912bdf0 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app_controller.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app_controller.tsx @@ -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: { diff --git a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_state.test.ts b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_state.test.ts index c236ac7843c03..8b786144c7420 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_state.test.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_state.test.ts @@ -18,7 +18,6 @@ */ import './np_core.test.mocks'; - import { DashboardStateManager } from './dashboard_state_manager'; import { getAppStateMock, getSavedDashboardMock } from './__tests__'; import { AppStateClass } from './legacy_imports'; diff --git a/src/legacy/core_plugins/kibana/public/discover/angular/discover.js b/src/legacy/core_plugins/kibana/public/discover/angular/discover.js index 7af7ee235a166..7abb7166aa902 100644 --- a/src/legacy/core_plugins/kibana/public/discover/angular/discover.js +++ b/src/legacy/core_plugins/kibana/public/discover/angular/discover.js @@ -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: { diff --git a/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts b/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts index 497427136f415..0d9dab96d6120 100644 --- a/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts +++ b/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts @@ -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'; diff --git a/src/legacy/core_plugins/kibana/public/visualize/editor/editor.js b/src/legacy/core_plugins/kibana/public/visualize/editor/editor.js index 5410289bfc2d7..2cf2584810741 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/editor/editor.js +++ b/src/legacy/core_plugins/kibana/public/visualize/editor/editor.js @@ -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: { diff --git a/src/legacy/core_plugins/kibana/public/visualize/kibana_services.ts b/src/legacy/core_plugins/kibana/public/visualize/kibana_services.ts index 612f0ba0f077b..6477d1941c205 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/kibana_services.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/kibana_services.ts @@ -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'; diff --git a/src/legacy/core_plugins/state_session_storage_redirect/public/index.js b/src/legacy/core_plugins/state_session_storage_redirect/public/index.js index f64237000ae41..1aa7bce2af699 100644 --- a/src/legacy/core_plugins/state_session_storage_redirect/public/index.js +++ b/src/legacy/core_plugins/state_session_storage_redirect/public/index.js @@ -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); + } } } }); diff --git a/src/legacy/ui/public/chrome/api/sub_url_hooks.js b/src/legacy/ui/public/chrome/api/sub_url_hooks.js index 142f11e029b35..e38a1f4b19e56 100644 --- a/src/legacy/ui/public/chrome/api/sub_url_hooks.js +++ b/src/legacy/ui/public/chrome/api/sub_url_hooks.js @@ -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); } diff --git a/src/legacy/ui/public/state_management/__tests__/state.js b/src/legacy/ui/public/state_management/__tests__/state.js index cbeb5e3650a16..6f6f74c9d2bec 100644 --- a/src/legacy/ui/public/state_management/__tests__/state.js +++ b/src/legacy/ui/public/state_management/__tests__/state.js @@ -25,14 +25,12 @@ 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'; -import { HashedItemStore } from '../state_storage/hashed_item_store'; + unhashQuery +} from '../state_hashing'; +import { HashedItemStore } from '../../../../../plugins/kibana_utils/public'; 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 }; }; diff --git a/src/legacy/ui/public/state_management/state.js b/src/legacy/ui/public/state_management/state.js index 8d55a6929a617..359dfa5749611 100644 --- a/src/legacy/ui/public/state_management/state.js +++ b/src/legacy/ui/public/state_management/state.js @@ -35,12 +35,11 @@ import { fatalError, toastNotifications } from '../notify'; import './config_provider'; import { createLegacyClass } from '../utils/legacy_class'; import { callEach } from '../utils/function'; - +import { hashedItemStore } from '../../../../plugins/kibana_utils/public'; import { createStateHash, - HashedItemStoreSingleton, - isStateHash, -} from './state_storage'; + isStateHash +} from './state_hashing'; export function StateProvider(Private, $rootScope, $location, stateManagementConfig, config, kbnUrl, $injector) { const Events = Private(EventsProvider); @@ -54,13 +53,13 @@ export function StateProvider(Private, $rootScope, $location, stateManagementCon function State( urlParam, defaults, - hashedItemStore = HashedItemStoreSingleton + _hashedItemStore = hashedItemStore ) { State.Super.call(this); this.setDefaults(defaults); this._urlParam = urlParam || '_s'; - this._hashedItemStore = hashedItemStore; + this._hashedItemStore = _hashedItemStore; // When the URL updates we need to fetch the values from the URL this._cleanUpListeners = _.partial(callEach, [ @@ -293,9 +292,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) { diff --git a/src/legacy/ui/public/state_management/state_hashing/__tests__/hash_url.js b/src/legacy/ui/public/state_management/state_hashing/__tests__/hash_url.js deleted file mode 100644 index 1d5b88a22d9c6..0000000000000 --- a/src/legacy/ui/public/state_management/state_hashing/__tests__/hash_url.js +++ /dev/null @@ -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); - }); - }); -}); diff --git a/src/legacy/ui/public/state_management/state_hashing/__tests__/unhash_url.js b/src/legacy/ui/public/state_management/state_hashing/__tests__/unhash_url.js deleted file mode 100644 index 671194ecb50f5..0000000000000 --- a/src/legacy/ui/public/state_management/state_hashing/__tests__/unhash_url.js +++ /dev/null @@ -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); - }); -}); diff --git a/src/legacy/ui/public/state_management/state_hashing/hash_unhash_url.test.ts b/src/legacy/ui/public/state_management/state_hashing/hash_unhash_url.test.ts new file mode 100644 index 0000000000000..afbe86a4b4d12 --- /dev/null +++ b/src/legacy/ui/public/state_management/state_hashing/hash_unhash_url.test.ts @@ -0,0 +1,287 @@ +/* + * 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. + */ + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { mockStorage } from '../../../../../plugins/kibana_utils/public/storage/hashed_item_store/mock'; +import { HashedItemStore } from '../../../../../plugins/kibana_utils/public'; +import { hashUrl, unhashUrl } from './hash_unhash_url'; + +describe('hash unhash url', () => { + beforeEach(() => { + mockStorage.clear(); + mockStorage.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(mockStorage.getItem('kbn.hashedItemsIndex.v1')).toBeTruthy(); + expect(mockStorage.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(mockStorage.getItem('h@4e60e02')).toEqual(JSON.stringify({ yes: true })); + expect(mockStorage.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(mockStorage.getItem(HashedItemStore.PERSISTED_INDEX_KEY)).toBeTruthy(); + expect(mockStorage.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(mockStorage.length).toBe(3); // 2 hashes + HashedItemStoreSingleton.PERSISTED_INDEX_KEY + }); + }); + + it('throws error if unable to hash url', () => { + const stateParamKey1 = '_g'; + const stateParamValue1 = '(yes:!t)'; + mockStorage.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 }; + mockStorage.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 }; + + mockStorage.setItem(stateParamValueHashed1, JSON.stringify(state1)); + mockStorage.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 }; + + mockStorage.setItem(stateParamValueHashed1, JSON.stringify(state1)); + mockStorage.setItem(stateParamValueHashed2, JSON.stringify(state2)); + mockStorage.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); + }); + }); +}); diff --git a/src/legacy/ui/public/state_management/state_hashing/hash_unhash_url.ts b/src/legacy/ui/public/state_management/state_hashing/hash_unhash_url.ts new file mode 100644 index 0000000000000..b52be635952c8 --- /dev/null +++ b/src/legacy/ui/public/state_management/state_hashing/hash_unhash_url.ts @@ -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 { hashedItemStore } from '../../../../../plugins/kibana_utils/public'; +import { createStateHash, isStateHash } from './state_hash'; + +export type IQuery = Record; + +interface QueryMapperOptions { + hashableParams: string[]; +} +export type QueryReplacerOptions = QueryMapperOptions; + +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: IQuery, + options: QueryMapperOptions = { + 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: IQuery, options?: QueryMapperOptions) => IQuery, + options?: QueryReplacerOptions +) { + 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 = hashedItemStore.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 = hashedItemStore.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); +} diff --git a/src/legacy/ui/public/state_management/state_hashing/hash_url.js b/src/legacy/ui/public/state_management/state_hashing/hash_url.js deleted file mode 100644 index 973266fcf8685..0000000000000 --- a/src/legacy/ui/public/state_management/state_hashing/hash_url.js +++ /dev/null @@ -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, - }); -}; diff --git a/src/legacy/ui/public/state_management/state_hashing/index.d.ts b/src/legacy/ui/public/state_management/state_hashing/index.ts similarity index 85% rename from src/legacy/ui/public/state_management/state_hashing/index.d.ts rename to src/legacy/ui/public/state_management/state_hashing/index.ts index 163d9ed07f2cc..6225202f90978 100644 --- a/src/legacy/ui/public/state_management/state_hashing/index.d.ts +++ b/src/legacy/ui/public/state_management/state_hashing/index.ts @@ -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'; diff --git a/src/legacy/ui/public/state_management/state_storage/__tests__/state_hash.js b/src/legacy/ui/public/state_management/state_hashing/state_hash.test.ts similarity index 62% rename from src/legacy/ui/public/state_management/state_storage/__tests__/state_hash.js rename to src/legacy/ui/public/state_management/state_hashing/state_hash.test.ts index 71fcf33274fc6..83a94e37785c4 100644 --- a/src/legacy/ui/public/state_management/state_storage/__tests__/state_hash.js +++ b/src/legacy/ui/public/state_management/state_hashing/state_hash.test.ts @@ -17,58 +17,56 @@ * under the License. */ -import expect from '@kbn/expect'; import { encode as encodeRison } from 'rison-node'; - -import { - createStateHash, - isStateHash, -} from '../state_hash'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { mockStorage } from '../../../../../plugins/kibana_utils/public/storage/hashed_item_store/mock'; +import { createStateHash, isStateHash } from '../state_hashing'; describe('stateHash', () => { - const existingJsonProvider = () => null; + beforeEach(() => { + mockStorage.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); }); }); }); diff --git a/src/legacy/ui/public/state_management/state_storage/state_hash.js b/src/legacy/ui/public/state_management/state_hashing/state_hash.ts similarity index 76% rename from src/legacy/ui/public/state_management/state_storage/state_hash.js rename to src/legacy/ui/public/state_management/state_hashing/state_hash.ts index 0e8dc2c05c0f2..b3574876bafae 100644 --- a/src/legacy/ui/public/state_management/state_storage/state_hash.js +++ b/src/legacy/ui/public/state_management/state_hashing/state_hash.ts @@ -17,12 +17,16 @@ * under the License. */ -import { Sha256 } from '../../../../../core/public/utils/'; +import { Sha256 } from '../../../../../core/public/utils'; +import { hashedItemStore } from '../../../../../plugins/kibana_utils/public'; // 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) + : hashedItemStore.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; } diff --git a/src/legacy/ui/public/state_management/state_hashing/unhash_query_string.ts b/src/legacy/ui/public/state_management/state_hashing/unhash_query_string.ts deleted file mode 100644 index 242b840282f39..0000000000000 --- a/src/legacy/ui/public/state_management/state_hashing/unhash_query_string.ts +++ /dev/null @@ -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; - }); -} diff --git a/src/legacy/ui/public/state_management/state_hashing/unhash_url.js b/src/legacy/ui/public/state_management/state_hashing/unhash_url.js deleted file mode 100644 index 272237a8b81ef..0000000000000 --- a/src/legacy/ui/public/state_management/state_hashing/unhash_url.js +++ /dev/null @@ -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, - }) - }); -} diff --git a/src/plugins/kibana_utils/public/index.ts b/src/plugins/kibana_utils/public/index.ts index 04845c72cb755..22ac720246d4b 100644 --- a/src/plugins/kibana_utils/public/index.ts +++ b/src/plugins/kibana_utils/public/index.ts @@ -27,3 +27,4 @@ export * from './store'; export * from './errors'; export * from './field_mapping'; export * from './storage'; +export * from './storage/hashed_item_store'; diff --git a/src/legacy/ui/public/state_management/state_storage/__tests__/hashed_item_store.js b/src/plugins/kibana_utils/public/storage/hashed_item_store/hashed_item_store.test.ts similarity index 55% rename from src/legacy/ui/public/state_management/state_storage/__tests__/hashed_item_store.js rename to src/plugins/kibana_utils/public/storage/hashed_item_store/hashed_item_store.test.ts index dfb726ce53299..a6e83ab45b293 100644 --- a/src/legacy/ui/public/state_management/state_storage/__tests__/hashed_item_store.js +++ b/src/plugins/kibana_utils/public/storage/hashed_item_store/hashed_item_store.test.ts @@ -17,22 +17,20 @@ * 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(); + const hashedItemStore = new HashedItemStore(sessionStorage); + (hashedItemStore as any).getIndexedItems(); // trigger retrieving of indexedItems array from HashedItemStore.PERSISTED_INDEX_KEY + expect(spy).toBeCalledWith(HashedItemStore.PERSISTED_INDEX_KEY); + spy.mockReset(); }); it('sorts indexed items by touched property', () => { @@ -57,14 +55,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).getIndexedItems()).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 +73,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 +96,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 +138,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 +173,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 +193,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 +219,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 +240,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 +257,112 @@ describe('hashedItemStore', () => { it('returns null', () => { const retrievedItem = hashedItemStore.getItem(hash); - expect(retrievedItem).to.be(null); + expect(retrievedItem).toBe(null); + }); + }); + }); + + describe('#removeItem', () => { + describe('if the item exists in sessionStorage', () => { + let sessionStorage: Storage; + let hashedItemStore: HashedItemStore; + + beforeEach(() => { + sessionStorage = new StubBrowserStorage(); + hashedItemStore = new HashedItemStore(sessionStorage); + hashedItemStore.setItem('1', 'a'); + hashedItemStore.setItem('2', 'b'); + }); + + it('removes and returns an item', () => { + const removedItem = hashedItemStore.removeItem('1'); + expect(removedItem).toBe('a'); + expect(hashedItemStore.getItem('1')).toBeNull(); + expect(hashedItemStore.getItem('2')).not.toBeNull(); + expect((hashedItemStore as any).getIndexedItems()).toHaveLength(1); + }); + }); + + describe(`if the item doesn't exist in sessionStorage`, () => { + let sessionStorage: Storage; + let hashedItemStore: HashedItemStore; + const hash = 'a'; + + beforeEach(() => { + sessionStorage = new StubBrowserStorage(); + hashedItemStore = new HashedItemStore(sessionStorage); + }); + + it('returns null', () => { + const removedItem = hashedItemStore.removeItem(hash); + expect(removedItem).toBe(null); + }); + }); + }); + + describe('#clear', () => { + describe('if the items exist in sessionStorage', () => { + let sessionStorage: Storage; + let hashedItemStore: HashedItemStore; + + beforeEach(() => { + sessionStorage = new StubBrowserStorage(); + hashedItemStore = new HashedItemStore(sessionStorage); + hashedItemStore.setItem('1', 'a'); + hashedItemStore.setItem('2', 'b'); + }); + + it('removes all items', () => { + hashedItemStore.clear(); + + expect(hashedItemStore.getItem('1')).toBeNull(); + expect(hashedItemStore.getItem('2')).toBeNull(); + expect((hashedItemStore as any).getIndexedItems()).toHaveLength(0); + }); + }); + + describe(`if items don't exist in sessionStorage`, () => { + let sessionStorage: Storage; + let hashedItemStore: HashedItemStore; + + beforeEach(() => { + sessionStorage = new StubBrowserStorage(); + hashedItemStore = new HashedItemStore(sessionStorage); + }); + + it("doesn't throw", () => { + expect(() => hashedItemStore.clear()).not.toThrowError(); }); }); }); }); 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 +382,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'); }); }); }); diff --git a/src/legacy/ui/public/state_management/state_storage/hashed_item_store.js b/src/plugins/kibana_utils/public/storage/hashed_item_store/hashed_item_store.ts similarity index 60% rename from src/legacy/ui/public/state_management/state_storage/hashed_item_store.js rename to src/plugins/kibana_utils/public/storage/hashed_item_store/hashed_item_store.ts index 7130652416072..932ecb394a5ee 100644 --- a/src/legacy/ui/public/state_management/state_storage/hashed_item_store.js +++ b/src/plugins/kibana_utils/public/storage/hashed_item_store/hashed_item_store.ts @@ -71,60 +71,104 @@ */ import { pull, sortBy } from 'lodash'; +import { IStorage } from '../types'; -export class HashedItemStore { +interface IndexedItem { + hash: string; + touched?: number; // Date.now() +} + +export class HashedItemStore implements IStorage { + static readonly PERSISTED_INDEX_KEY = 'kbn.hashedItemsIndex.v1'; + private storage: Storage; /** * 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 + * in storage. An indexed item is shaped {hash, touched}. The touched date is when the item * was last referenced by the browser history. */ - constructor(sessionStorage) { - this._sessionStorage = sessionStorage; - - // Store indexed items in descending order by touched (oldest first, newest last). We'll use - // this to remove older items when we run out of storage space. - this._indexedItems = []; - - // Potentially restore a previously persisted index. This happens when - // we re-open a closed tab. - const persistedItemIndex = this._sessionStorage.getItem(HashedItemStore.PERSISTED_INDEX_KEY); - if (persistedItemIndex) { - this._indexedItems = sortBy(JSON.parse(persistedItemIndex) || [], 'touched'); - } + constructor(storage: Storage) { + this.storage = storage; } - 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.storage.getItem(hash); if (item !== null) { - this._touchHash(hash); + this.touchHash(hash); } return item; } - _getIndexedItem(hash) { - return this._indexedItems.find(indexedItem => indexedItem.hash === hash); + removeItem(hash: string): string | null { + const indexedItems = this.getIndexedItems(); + const itemToRemove = this.storage.getItem(hash); + const indexToRemove = this.getIndexedItem(hash, indexedItems); + + if (indexToRemove) { + pull(indexedItems, indexToRemove); + this.setIndexedItems(indexedItems); + } + + if (itemToRemove) { + this.storage.removeItem(hash); + } + + return itemToRemove || null; + } + + clear() { + const indexedItems = this.getIndexedItems(); + indexedItems.forEach(({ hash }) => { + this.storage.removeItem(hash); + }); + this.setIndexedItems([]); } - _persistItem(hash, item) { + // 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 ensuredSorting = false; + private getIndexedItems(): IndexedItem[] { + // Restore a previously persisted index + const persistedItemIndex = this.storage.getItem(HashedItemStore.PERSISTED_INDEX_KEY); + let items = persistedItemIndex ? JSON.parse(persistedItemIndex) || [] : []; + + // ensure sorting once, as sorting all indexed items on each get is a performance hit + if (!this.ensuredSorting) { + items = sortBy(items, 'touched'); + this.setIndexedItems(items); + this.ensuredSorting = true; + } + + return items; + } + + private setIndexedItems(items: IndexedItem[]) { + this.storage.setItem(HashedItemStore.PERSISTED_INDEX_KEY, JSON.stringify(items)); + } + + private getIndexedItem(hash: string, indexedItems: IndexedItem[] = this.getIndexedItems()) { + return indexedItems.find(indexedItem => indexedItem.hash === hash); + } + + private persistItem(hash: string, item: string): boolean { try { - this._sessionStorage.setItem(hash, item); + this.storage.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.getIndexedItems().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 +176,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 indexedItems = this.getIndexedItems(); + const oldestIndexedItem = indexedItems.shift(); + if (oldestIndexedItem) { + // Remove oldest item from storage. + this.storage.removeItem(oldestIndexedItem.hash); + this.setIndexedItems(indexedItems); + } } - _touchHash(hash) { + private touchHash(hash: string) { + const indexedItems = this.getIndexedItems(); // 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, indexedItems) || { 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(indexedItems, indexedItem); + indexedItems.push(indexedItem); // Regardless of whether this is a new or updated item, we need to persist the index. - this._sessionStorage.setItem( - HashedItemStore.PERSISTED_INDEX_KEY, - JSON.stringify(this._indexedItems) - ); + this.setIndexedItems(indexedItems); } } - -HashedItemStore.PERSISTED_INDEX_KEY = 'kbn.hashedItemsIndex.v1'; diff --git a/src/legacy/ui/public/state_management/state_storage/hashed_item_store_singleton.js b/src/plugins/kibana_utils/public/storage/hashed_item_store/index.ts similarity index 89% rename from src/legacy/ui/public/state_management/state_storage/hashed_item_store_singleton.js rename to src/plugins/kibana_utils/public/storage/hashed_item_store/index.ts index 234559c95ebf7..062266359c6c5 100644 --- a/src/legacy/ui/public/state_management/state_storage/hashed_item_store_singleton.js +++ b/src/plugins/kibana_utils/public/storage/hashed_item_store/index.ts @@ -18,5 +18,5 @@ */ import { HashedItemStore } from './hashed_item_store'; - -export const HashedItemStoreSingleton = new HashedItemStore(window.sessionStorage); +export { HashedItemStore }; +export const hashedItemStore = new HashedItemStore(window.sessionStorage); diff --git a/src/legacy/ui/public/state_management/state_hashing/get_unhashable_states_provider.ts b/src/plugins/kibana_utils/public/storage/hashed_item_store/mock.ts similarity index 55% rename from src/legacy/ui/public/state_management/state_hashing/get_unhashable_states_provider.ts rename to src/plugins/kibana_utils/public/storage/hashed_item_store/mock.ts index 6c43947640ed3..e3360e0e3cf51 100644 --- a/src/legacy/ui/public/state_management/state_hashing/get_unhashable_states_provider.ts +++ b/src/plugins/kibana_utils/public/storage/hashed_item_store/mock.ts @@ -17,12 +17,22 @@ * 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 mockStorage = new StubBrowserStorage(); +const mockHashedItemStore = new HashedItemStore(mockStorage); +jest.mock('./', () => { + return { + HashedItemStore: require('./hashed_item_store').HashedItemStore, + hashedItemStore: mockHashedItemStore, }; -} +}); diff --git a/src/plugins/kibana_utils/public/storage/types.ts b/src/plugins/kibana_utils/public/storage/types.ts index 875bb44bcad17..a25d4729fd320 100644 --- a/src/plugins/kibana_utils/public/storage/types.ts +++ b/src/plugins/kibana_utils/public/storage/types.ts @@ -17,16 +17,16 @@ * under the License. */ -export interface IStorageWrapper { - get: (key: string) => any; - set: (key: string, value: any) => void; - remove: (key: string) => any; +export interface IStorageWrapper { + get: (key: string) => T | null; + set: (key: string, value: T) => S; + remove: (key: string) => T | null; clear: () => void; } -export interface IStorage { - getItem: (key: string) => any; - setItem: (key: string, value: any) => void; - removeItem: (key: string) => any; +export interface IStorage { + getItem: (key: string) => T | null; + setItem: (key: string, value: T) => S; + removeItem: (key: string) => T | null; clear: () => void; } diff --git a/src/test_utils/public/stub_browser_storage.test.ts b/src/test_utils/public/stub_browser_storage.test.ts index 7a02221c52051..26070cde25232 100644 --- a/src/test_utils/public/stub_browser_storage.test.ts +++ b/src/test_utils/public/stub_browser_storage.test.ts @@ -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(); diff --git a/src/test_utils/public/stub_browser_storage.ts b/src/test_utils/public/stub_browser_storage.ts index aa6820d943818..b5ee9a24e4c2a 100644 --- a/src/test_utils/public/stub_browser_storage.ts +++ b/src/test_utils/public/stub_browser_storage.ts @@ -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. // ----------------------------------------------------------------------------------------------- diff --git a/test/functional/apps/home/_navigation.ts b/test/functional/apps/home/_navigation.ts index 58e0793b2d547..96285901289b6 100644 --- a/test/functional/apps/home/_navigation.ts +++ b/test/functional/apps/home/_navigation.ts @@ -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); diff --git a/test/functional/apps/visualize/_area_chart.js b/test/functional/apps/visualize/_area_chart.js index c6b33886a2456..22afffeaab404 100644 --- a/test/functional/apps/visualize/_area_chart.js +++ b/test/functional/apps/visualize/_area_chart.js @@ -236,7 +236,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(); @@ -245,7 +245,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); }); }); diff --git a/src/legacy/ui/public/state_management/state_storage/index.js b/typings/encode_uri_query.d.ts similarity index 79% rename from src/legacy/ui/public/state_management/state_storage/index.js rename to typings/encode_uri_query.d.ts index eb878fc88b578..4bfc554624446 100644 --- a/src/legacy/ui/public/state_management/state_storage/index.js +++ b/typings/encode_uri_query.d.ts @@ -17,9 +17,8 @@ * under the License. */ -export { HashedItemStoreSingleton } from './hashed_item_store_singleton'; - -export { - createStateHash, - isStateHash, -} from './state_hash'; +declare module 'encode-uri-query' { + function encodeUriQuery(query: string, usePercentageSpace?: boolean): string; + // eslint-disable-next-line import/no-default-export + export default encodeUriQuery; +} diff --git a/src/legacy/ui/public/state_management/state_hashing/index.js b/typings/rison_node.d.ts similarity index 52% rename from src/legacy/ui/public/state_management/state_hashing/index.js rename to typings/rison_node.d.ts index fe868a9b67bde..2592c36e8ae9a 100644 --- a/src/legacy/ui/public/state_management/state_hashing/index.js +++ b/typings/rison_node.d.ts @@ -17,7 +17,23 @@ * 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 '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 {} + + 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: Input) => string; + + // eslint-disable-next-line @typescript-eslint/camelcase + export const encode_object: (input: Input) => string; +} diff --git a/x-pack/typings/encode_uri_query.d.ts b/x-pack/typings/encode_uri_query.d.ts new file mode 100644 index 0000000000000..e1ab5f4a70abf --- /dev/null +++ b/x-pack/typings/encode_uri_query.d.ts @@ -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; +} diff --git a/x-pack/legacy/plugins/infra/types/rison_node.d.ts b/x-pack/typings/rison_node.d.ts similarity index 100% rename from x-pack/legacy/plugins/infra/types/rison_node.d.ts rename to x-pack/typings/rison_node.d.ts