diff --git a/src/core_plugins/state_session_storage_redirect/public/index.js b/src/core_plugins/state_session_storage_redirect/public/index.js index b22cd4363bebb..027f240e1fc66 100644 --- a/src/core_plugins/state_session_storage_redirect/public/index.js +++ b/src/core_plugins/state_session_storage_redirect/public/index.js @@ -1,70 +1,19 @@ import 'ui/autoload/styles'; import chrome from 'ui/chrome'; -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[state.getQueryParamName()]; - const decodedValue = rison.decode(value); - const hashedValue = state.toQueryParam(decodedValue); - return { name, hashedValue }; -}; - -const hashStatesInQuery = (states, query) => { - const hashedQuery = states.reduce((result, state) => { - const { name, hashedValue } = hashStateInQuery(state, query); - result[name] = hashedValue; - return result; - }, {}); - - - return Object.assign({}, query, hashedQuery); -}; - -const hashUrl = (states, redirectUrl) => { - // The URLs that we use aren't "conventional" and the hash is 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 parsedUrl = parseUrl(redirectUrl); - const parsedAppUrl = parseUrl(parsedUrl.hash.slice(1), true); - - // the parsedAppState 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({ - pathname: parsedUrl.pathname, - hash: newHash - }); -}; - +import { hashUrl } from 'ui/state_management/state_hashing'; import uiRoutes from 'ui/routes'; + uiRoutes.enable(); uiRoutes -.when('/', function (AppState, globalState, $window) { - const redirectUrl = chrome.getInjected('redirectUrl'); +.when('/', { + resolve: { + url: function (AppState, globalState, $window) { + const redirectUrl = chrome.getInjected('redirectUrl'); - const hashedUrl = hashUrl([new AppState(), globalState], redirectUrl); - const url = chrome.addBasePath(hashedUrl); + const hashedUrl = hashUrl([new AppState(), globalState], redirectUrl); + const url = chrome.addBasePath(hashedUrl); - $window.location = url; + $window.location = url; + } + } }); diff --git a/src/ui/public/state_management/state_hashing/__tests__/hash_url.js b/src/ui/public/state_management/state_hashing/__tests__/hash_url.js new file mode 100644 index 0000000000000..8ec747c325a8a --- /dev/null +++ b/src/ui/public/state_management/state_hashing/__tests__/hash_url.js @@ -0,0 +1,144 @@ +import expect from 'expect.js'; +import ngMock from 'ng_mock'; +import sinon from 'auto-release-sinon'; +import { parse as parseUrl } from 'url'; + +import StateProvider from 'ui/state_management/state'; +import { hashUrl } from 'ui/state_management/state_hashing'; + +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 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/ui/public/state_management/state_hashing/hash_url.js b/src/ui/public/state_management/state_hashing/hash_url.js new file mode 100644 index 0000000000000..1739a14ea9546 --- /dev/null +++ b/src/ui/public/state_management/state_hashing/hash_url.js @@ -0,0 +1,77 @@ +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 Object.assign({}, query, hashedQuery); +}; + +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, + }); +}; + +export default hashUrl; diff --git a/src/ui/public/state_management/state_hashing/index.js b/src/ui/public/state_management/state_hashing/index.js index 6905a1fd28b61..6f3f5b6484931 100644 --- a/src/ui/public/state_management/state_hashing/index.js +++ b/src/ui/public/state_management/state_hashing/index.js @@ -2,6 +2,10 @@ export { default as getUnhashableStatesProvider, } from './get_unhashable_states_provider'; +export { + default as hashUrl, +} from './hash_url'; + export { default as unhashQueryString, } from './unhash_query_string';