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) : ''
};