diff --git a/x-pack/plugins/apm/public/utils/__test__/__snapshots__/url.test.js.snap b/x-pack/plugins/apm/public/utils/__test__/__snapshots__/url.test.js.snap deleted file mode 100644 index 8fb163cbd10b9..0000000000000 --- a/x-pack/plugins/apm/public/utils/__test__/__snapshots__/url.test.js.snap +++ /dev/null @@ -1,67 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`RelativeLinkComponent should render correct markup 1`] = ` - - Go to Discover - -`; - -exports[`UnconnectedKibanaLink should include existing _g values in link href 1`] = ` - - Go to Discover - -`; - -exports[`UnconnectedKibanaLink should include existing _g values in link href 2`] = ` - - Go to Discover - -`; - -exports[`UnconnectedKibanaLink should render correct markup 1`] = ` - - Go to Discover - -`; - -exports[`ViewMLJob should render component 1`] = ` - - View Job - -`; diff --git a/x-pack/plugins/apm/public/utils/__test__/__snapshots__/url.test.tsx.snap b/x-pack/plugins/apm/public/utils/__test__/__snapshots__/url.test.tsx.snap new file mode 100644 index 0000000000000..afb2f6c7926d5 --- /dev/null +++ b/x-pack/plugins/apm/public/utils/__test__/__snapshots__/url.test.tsx.snap @@ -0,0 +1,46 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RelativeLinkComponent should render correct markup 1`] = ` + + Go to Discover + +`; + +exports[`UnconnectedKibanaLink should render correct markup 1`] = ` + + Go to Discover + +`; + +exports[`ViewMLJob should render component 1`] = ` + + View Job + +`; diff --git a/x-pack/plugins/apm/public/utils/__test__/url.test.js b/x-pack/plugins/apm/public/utils/__test__/url.test.js deleted file mode 100644 index be5c80d98a521..0000000000000 --- a/x-pack/plugins/apm/public/utils/__test__/url.test.js +++ /dev/null @@ -1,271 +0,0 @@ -/* - * 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. - */ - -import React from 'react'; -import { Router } from 'react-router-dom'; -import { mount, shallow } from 'enzyme'; -import createHistory from 'history/createMemoryHistory'; -import { - toQuery, - fromQuery, - UnconnectedKibanaLink, - RelativeLinkComponent, - encodeKibanaSearchParams, - decodeKibanaSearchParams, - ViewMLJob -} from '../url'; -import { toJson } from '../testHelpers'; - -jest.mock('ui/chrome', () => ({ - addBasePath: path => `myBasePath${path}` -})); - -describe('encodeKibanaSearchParams and decodeKibanaSearchParams should return the original string', () => { - it('should convert string to object', () => { - const search = `?_g=(ml:(jobIds:!(opbeans-node-request-high_mean_response_time)),refreshInterval:(display:Off,pause:!f,value:0),time:(from:'2018-06-06T08:20:45.437Z',mode:absolute,to:'2018-06-14T21:56:58.505Z'))&_a=(filters:!(),mlSelectInterval:(interval:(display:Auto,val:auto)),mlSelectSeverity:(threshold:(display:warning,val:0)),mlTimeSeriesExplorer:(),query:(query_string:(analyze_wildcard:!t,query:'*')))`; - const nextSearch = encodeKibanaSearchParams( - decodeKibanaSearchParams(search) - ); - expect(search).toBe(`?${nextSearch}`); - }); -}); - -describe('decodeKibanaSearchParams', () => { - it('when both _a and _g are defined', () => { - const search = `?_g=(ml:(jobIds:!(opbeans-node-request-high_mean_response_time)),refreshInterval:(display:Off,pause:!f,value:0),time:(from:'2018-06-06T08:20:45.437Z',mode:absolute,to:'2018-06-14T21:56:58.505Z'))&_a=(filters:!(),mlSelectInterval:(interval:(display:Auto,val:auto)),mlSelectSeverity:(threshold:(display:warning,val:0)),mlTimeSeriesExplorer:(),query:(query_string:(analyze_wildcard:!t,query:'*')))`; - const query = decodeKibanaSearchParams(search); - expect(query).toEqual({ - _a: { - filters: [], - mlSelectInterval: { interval: { display: 'Auto', val: 'auto' } }, - mlSelectSeverity: { threshold: { display: 'warning', val: 0 } }, - mlTimeSeriesExplorer: {}, - query: { query_string: { analyze_wildcard: true, query: '*' } } - }, - _g: { - ml: { jobIds: ['opbeans-node-request-high_mean_response_time'] }, - refreshInterval: { display: 'Off', pause: false, value: 0 }, - time: { - from: '2018-06-06T08:20:45.437Z', - mode: 'absolute', - to: '2018-06-14T21:56:58.505Z' - } - } - }); - }); - - it('when only _g is defined', () => { - const search = `?_g=(ml:(jobIds:!(opbeans-node-request-high_mean_response_time)))`; - const query = decodeKibanaSearchParams(search); - expect(query).toEqual({ - _a: null, - _g: { - ml: { jobIds: ['opbeans-node-request-high_mean_response_time'] } - } - }); - }); -}); - -describe('encodeKibanaSearchParams', () => { - it('should convert object to string', () => { - const query = { - _a: { - filters: [], - mlSelectInterval: { interval: { display: 'Auto', val: 'auto' } }, - mlSelectSeverity: { threshold: { display: 'warning', val: 0 } }, - mlTimeSeriesExplorer: {}, - query: { query_string: { analyze_wildcard: true, query: '*' } } - }, - _g: { - ml: { jobIds: ['opbeans-node-request-high_mean_response_time'] }, - refreshInterval: { display: 'Off', pause: false, value: 0 }, - time: { - from: '2018-06-06T08:20:45.437Z', - mode: 'absolute', - to: '2018-06-14T21:56:58.505Z' - } - } - }; - const search = encodeKibanaSearchParams(query); - expect(search).toBe( - `_g=(ml:(jobIds:!(opbeans-node-request-high_mean_response_time)),refreshInterval:(display:Off,pause:!f,value:0),time:(from:'2018-06-06T08:20:45.437Z',mode:absolute,to:'2018-06-14T21:56:58.505Z'))&_a=(filters:!(),mlSelectInterval:(interval:(display:Auto,val:auto)),mlSelectSeverity:(threshold:(display:warning,val:0)),mlTimeSeriesExplorer:(),query:(query_string:(analyze_wildcard:!t,query:'*')))` - ); - }); -}); - -describe('toQuery', () => { - it('should parse string to object', () => { - expect(toQuery('?foo=bar&name=john%20doe')).toEqual({ - foo: 'bar', - name: 'john doe' - }); - }); -}); - -describe('fromQuery', () => { - it('should parse object to string', () => { - expect( - fromQuery({ - foo: 'bar', - name: 'john doe' - }) - ).toEqual('foo=bar&name=john%20doe'); - }); - - it('should not encode _a and _g', () => { - expect( - fromQuery({ - g: 'john doe:', - _g: 'john doe:', - a: 'john doe:', - _a: 'john doe:' - }) - ).toEqual('g=john%20doe%3A&_g=john%20doe:&a=john%20doe%3A&_a=john%20doe:'); - }); -}); - -describe('RelativeLinkComponent', () => { - let history; - let wrapper; - - beforeEach(() => { - history = createHistory(); - history.location = { - ...history.location, - pathname: '/opbeans-node/transactions', - search: '?foo=bar' - }; - - wrapper = mount( - - - Go to Discover - - - ); - }); - - it('should have correct url', () => { - expect(wrapper.find('a').prop('href')).toBe( - '/opbeans-node/errors?foo=bar&foo2=bar2' - ); - }); - - it('should render correct markup', () => { - expect(toJson(wrapper)).toMatchSnapshot(); - }); - - it('should have initial location', () => { - expect(history.location).toEqual( - expect.objectContaining({ - pathname: '/opbeans-node/transactions', - search: '?foo=bar' - }) - ); - }); - - it('should update location on click', () => { - wrapper.simulate('click', { button: 0 }); - expect(history.location).toEqual( - expect.objectContaining({ - pathname: '/opbeans-node/errors', - search: '?foo=bar&foo2=bar2' - }) - ); - }); -}); - -describe('UnconnectedKibanaLink', () => { - let wrapper; - - beforeEach(() => { - const discoverQuery = { - _a: { - interval: 'auto', - query: { - language: 'lucene', - query: `context.service.name:"myServiceName" AND error.grouping_key:"myGroupId"` - }, - sort: { '@timestamp': 'desc' } - } - }; - - wrapper = shallow( - - Go to Discover - - ); - }); - - it('should have correct url', () => { - expect(wrapper.find('EuiLink').prop('href')).toBe( - 'myBasePath/app/kibana#/discover?_a=(interval:auto,query:(language:lucene,query:\'context.service.name:"myServiceName" AND error.grouping_key:"myGroupId"\'),sort:(\'@timestamp\':desc))&_g=(time:(from:now-24h,mode:quick,to:now))' - ); - }); - - it('should render correct markup', () => { - expect(wrapper).toMatchSnapshot(); - }); - - it('should include existing _g values in link href', () => { - wrapper.setProps({ - location: { - search: - '?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-7d,mode:relative,to:now-1d))' - } - }); - expect(wrapper).toMatchSnapshot(); - - wrapper.setProps({ location: { search: '?_g=H@whatever' } }); - expect(wrapper).toMatchSnapshot(); - }); -}); - -describe('ViewMLJob', () => { - it('should render component', () => { - const location = { search: '' }; - const wrapper = shallow( - - ); - - expect(toJson(wrapper)).toMatchSnapshot(); - }); - - it('should have correct path props', () => { - const location = { search: '' }; - const wrapper = shallow( - - ); - - expect(wrapper.prop('pathname')).toBe('/app/ml'); - expect(wrapper.prop('hash')).toBe('/timeseriesexplorer'); - expect(wrapper.prop('query')).toEqual({ - _a: null, - _g: { - ml: { - jobIds: ['myServiceName-myTransactionType-high_mean_response_time'] - } - } - }); - }); -}); diff --git a/x-pack/plugins/apm/public/utils/__test__/url.test.tsx b/x-pack/plugins/apm/public/utils/__test__/url.test.tsx new file mode 100644 index 0000000000000..a21f3b7a526b5 --- /dev/null +++ b/x-pack/plugins/apm/public/utils/__test__/url.test.tsx @@ -0,0 +1,249 @@ +/* + * 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. + */ + +import { mount, ReactWrapper, shallow } from 'enzyme'; +import createHistory, { MemoryHistory } from 'history/createMemoryHistory'; +import React from 'react'; +import { Router } from 'react-router-dom'; +import url from 'url'; +// @ts-ignore +import { toJson } from '../testHelpers'; +import { + fromQuery, + RelativeLinkComponent, + toQuery, + UnconnectedKibanaLink, + ViewMLJob +} from '../url'; + +describe('toQuery', () => { + it('should parse string to object', () => { + expect(toQuery('?foo=bar&name=john%20doe')).toEqual({ + foo: 'bar', + name: 'john doe' + }); + }); +}); + +describe('fromQuery', () => { + it('should parse object to string', () => { + expect( + fromQuery({ + foo: 'bar', + name: 'john doe' + }) + ).toEqual('foo=bar&name=john%20doe'); + }); + + it('should not encode _a and _g', () => { + expect( + fromQuery({ + g: 'john doe:', + _g: 'john doe:', + a: 'john doe:', + _a: 'john doe:' + }) + ).toEqual('g=john%20doe%3A&_g=john%20doe:&a=john%20doe%3A&_a=john%20doe:'); + }); +}); + +describe('RelativeLinkComponent', () => { + let history: MemoryHistory; + let wrapper: ReactWrapper; + + beforeEach(() => { + history = createHistory(); + history.location = { + ...history.location, + pathname: '/opbeans-node/transactions', + search: '?foo=bar' + }; + + wrapper = mount( + + + Go to Discover + + + ); + }); + + it('should have correct url', () => { + expect(wrapper.find('a').prop('href')).toBe( + '/opbeans-node/errors?foo=bar&foo2=bar2' + ); + }); + + it('should render correct markup', () => { + expect(toJson(wrapper)).toMatchSnapshot(); + }); + + it('should have initial location', () => { + expect(history.location).toEqual( + expect.objectContaining({ + pathname: '/opbeans-node/transactions', + search: '?foo=bar' + }) + ); + }); + + it('should update location on click', () => { + wrapper.simulate('click', { button: 0 }); + expect(history.location).toEqual( + expect.objectContaining({ + pathname: '/opbeans-node/errors', + search: '?foo=bar&foo2=bar2' + }) + ); + }); +}); + +function getUnconnectedKibanLink() { + const discoverQuery = { + _a: { + interval: 'auto', + query: { + language: 'lucene', + query: `context.service.name:"myServiceName" AND error.grouping_key:"myGroupId"` + }, + sort: { '@timestamp': 'desc' } + } + }; + + return shallow( + + Go to Discover + + ); +} + +describe('UnconnectedKibanaLink', () => { + it('should have correct url', () => { + const wrapper = getUnconnectedKibanLink(); + const href = wrapper.find('EuiLink').prop('href') || ''; + const { _g, _a } = getUrlQuery(href); + const { pathname } = url.parse(href); + + expect(pathname).toBe('/app/kibana'); + expect(_a).toBe( + '(interval:auto,query:(language:lucene,query:\'context.service.name:"myServiceName" AND error.grouping_key:"myGroupId"\'),sort:(\'@timestamp\':desc))' + ); + expect(_g).toBe('(time:(from:now-24h,mode:quick,to:now))'); + }); + + it('should render correct markup', () => { + const wrapper = getUnconnectedKibanLink(); + expect(wrapper).toMatchSnapshot(); + }); + + it('should include existing _g values in link href', () => { + const wrapper = getUnconnectedKibanLink(); + wrapper.setProps({ + location: { + search: + '?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-7d,mode:relative,to:now-1d))' + } + }); + const href = wrapper.find('EuiLink').prop('href'); + const { _g } = getUrlQuery(href); + + expect(_g).toBe( + '(refreshInterval:(pause:!t,value:0),time:(from:now-7d,mode:relative,to:now-1d))' + ); + }); + + it('should not throw due to hashed args', () => { + const wrapper = getUnconnectedKibanLink(); + expect(() => { + wrapper.setProps({ location: { search: '?_g=H@whatever' } }); + }).not.toThrow(); + }); + + it('should use default time range when _g is empty', () => { + const wrapper = getUnconnectedKibanLink(); + wrapper.setProps({ location: { search: '?_g=()' } }); + const href = wrapper.find('EuiLink').prop('href') as string; + const { _g } = getUrlQuery(href); + expect(_g).toBe('(time:(from:now-24h,mode:quick,to:now))'); + }); + + it('should merge in _g query values', () => { + const discoverQuery = { + _g: { + ml: { + jobIds: [1337] + } + } + }; + + const wrapper = shallow( + + Go to Discover + + ); + + const href = wrapper.find('EuiLink').prop('href') as string; + const { _g } = getUrlQuery(href); + expect(_g).toBe( + '(ml:(jobIds:!(1337)),time:(from:now-24h,mode:quick,to:now))' + ); + }); +}); + +function getUrlQuery(href?: string) { + const hash = url.parse(href!).hash!.slice(1); + return url.parse(hash, true).query; +} + +describe('ViewMLJob', () => { + it('should render component', () => { + const location = { search: '' }; + const wrapper = shallow( + + ); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should have correct path props', () => { + const location = { search: '' }; + const wrapper = shallow( + + ); + + expect(wrapper.prop('pathname')).toBe('/app/ml'); + expect(wrapper.prop('hash')).toBe('/timeseriesexplorer'); + expect(wrapper.prop('query')).toEqual({ + _g: { + ml: { + jobIds: ['myServiceName-myTransactionType-high_mean_response_time'] + } + } + }); + }); +}); diff --git a/x-pack/plugins/apm/public/utils/url.tsx b/x-pack/plugins/apm/public/utils/url.tsx index c2b2b2613e639..6ae3fcb643a6c 100644 --- a/x-pack/plugins/apm/public/utils/url.tsx +++ b/x-pack/plugins/apm/public/utils/url.tsx @@ -18,13 +18,19 @@ import { StringMap } from '../../typings/common'; // Kibana default set in: https://github.com/elastic/kibana/blob/e13e47fc4eb6112f2a5401408e9f765eae90f55d/x-pack/plugins/apm/public/utils/timepicker/index.js#L31-L35 // TODO: store this in config or a shared constant? -const DEFAULT_KIBANA_TIME_RANGE = '(time:(from:now-24h,mode:quick,to:now))'; +const DEFAULT_KIBANA_TIME_RANGE = { + time: { + from: 'now-24h', + mode: 'quick', + to: 'now' + } +}; interface ViewMlJobArgs { serviceName: string; transactionType: string; location: any; - children: any; + children?: any; } export function ViewMLJob({ @@ -33,18 +39,15 @@ export function ViewMLJob({ location, children = 'View Job' }: ViewMlJobArgs) { - const { _g, _a } = decodeKibanaSearchParams(location.search); const pathname = '/app/ml'; const hash = '/timeseriesexplorer'; const jobId = `${serviceName}-${transactionType}-high_mean_response_time`; const query = { _g: { - ...(_g as object), ml: { jobIds: [jobId] } - }, - _a + } }; return ( @@ -82,28 +85,18 @@ function stringifyWithoutEncoding(query: StringMap) { }); } -function decodeAsObject(value: string) { - const decoded = rison.decode(value); - return isPlainObject(decoded) ? decoded : {}; -} - -export function decodeKibanaSearchParams(search: string) { - const query = toQuery(search); - return { - _g: - query._g && typeof query._g === 'string' - ? decodeAsObject(query._g) - : null, - _a: - query._a && typeof query._a === 'string' ? decodeAsObject(query._a) : null - }; +function risonSafeDecode(value: string) { + try { + const decoded = rison.decode(value); + return isPlainObject(decoded) ? (decoded as StringMap) : {}; + } catch (e) { + return {}; + } } -export function encodeKibanaSearchParams(query: StringMap) { - return stringifyWithoutEncoding({ - _g: rison.encode(query._g), - _a: rison.encode(query._a) - }); +function decodeAndMergeG(g: string, toBeMerged?: StringMap) { + const decoded = risonSafeDecode(g); + return { ...DEFAULT_KIBANA_TIME_RANGE, ...decoded, ...toBeMerged }; } export interface RelativeLinkComponentArgs { @@ -113,9 +106,9 @@ export interface RelativeLinkComponentArgs { }; path: string; query?: StringMap; - disabled: boolean; - to: StringMap; - className: string; + disabled?: boolean; + to?: StringMap; + className?: string; } export function RelativeLinkComponent({ location, @@ -156,6 +149,10 @@ export function RelativeLinkComponent({ // The two components have different APIs: `path` vs `pathname` and one uses EuiLink the other react-router's Link (which behaves differently) // Suggestion: Deprecate RelativeLink, and clean up KibanaLink +export interface QueryWithG extends StringMap { + _g?: StringMap; +} + export interface KibanaLinkArgs { location: { search?: string; @@ -163,7 +160,7 @@ export interface KibanaLinkArgs { }; pathname: string; hash?: string; - query?: StringMap; + query?: QueryWithG; disabled?: boolean; to?: StringMap; className?: string; @@ -175,7 +172,6 @@ export interface KibanaLinkArgs { * * You must remember to pass in location in that case. */ - export const UnconnectedKibanaLink: React.SFC = ({ location, pathname, @@ -185,10 +181,10 @@ export const UnconnectedKibanaLink: React.SFC = ({ }) => { // Preserve current _g and _a const currentQuery = toQuery(location.search); + const g = decodeAndMergeG(currentQuery._g, query._g); const nextQuery = { ...query, - // use "_g" if it's set in the url, otherwise use default - _g: currentQuery._g || DEFAULT_KIBANA_TIME_RANGE, + _g: rison.encode(g), _a: query._a ? rison.encode(query._a) : '' };