From 8c7ca7f50af2af09e5a8c590cd801f9f389598af Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Thu, 7 Mar 2019 16:22:02 -0500 Subject: [PATCH] [APM] Moving the date picker into APM (#31311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * WIP moving the date picker into APM * Stable version of EUI date picker controls, still needs layout * Flex layout for kuery bar and date picker * Removes angular time picker logic and layout * Fixes snapshot test * Adds integration tests for date picker * Simplifies refresh cycle with setTimeout over requestAnimationFrame * Removes rison and local state from APM date picker flow * Adds refresh tests, fixes some refresh logic * Moves temporary EUI types out of component * Moves toBoolean helper and fixes TS error * Removes unused Link import * Types for datepicker (WIP) * Updates default date picker values after merging in Søren's type changes * Streamlines new APM query types to prevent duplication * Uses jest fake timers for refresh tests * Updates url handling to remove Rison from APM URLs, keeps Rison in outgoing Kibana links * Move filter bar up and out from within a react-redux-request to avoid catch-22 circular dependency * Separates rison encoding from regular url handling * Sets start and end defaults in urlParams reducer * Adds IUrlParams type to initial state with correct typing * Updated rison-related snapshots * Resolves failing tests related to query param management * Adds more tests for Kibana Link and Kibana Rison Link components * Re-enables the update button for the EUI super date picker * Adds more Discover link tests * Moved getRenderedHref to testHelpers, switched Discover Links integration tests to TS, and added ML link integration test * Changes how getRenderedHref works to make it clearer where location state is coming from * Updates obsolete snapshot * Fixes typescript-discovered errors and type errors * Finishes up url_helpers tests, removes dead commented tests * Removes temporary date picker types from APM * Fixes common case for an existing URL bug by not encoding range params --- .../components/app/ErrorGroupDetails/view.tsx | 4 +- .../__test__/__snapshots__/List.test.tsx.snap | 16 +- .../apm/public/components/app/Main/Home.tsx | 4 +- .../components/app/Main/UpdateBreadcrumbs.tsx | 14 +- .../Main/__test__/UpdateBreadcrumbs.test.js | 2 +- .../__test__/__snapshots__/Home.test.js.snap | 2 +- .../UpdateBreadcrumbs.test.js.snap | 40 ++-- .../ServiceIntegrations/index.tsx | 4 +- .../ServiceIntegrations/view.tsx | 8 +- .../components/app/ServiceDetails/view.tsx | 59 +++--- .../WaterfallContainer/Waterfall/index.tsx | 4 +- .../app/TransactionDetails/view.tsx | 5 +- .../shared/FilterBar/DatePicker.tsx | 175 ++++++++++++++++++ .../FilterBar/__test__/DatePicker.test.tsx | 133 +++++++++++++ .../components/shared/FilterBar/index.tsx | 24 +++ .../Links/DiscoverLinks/DiscoverLink.tsx | 8 +- .../DiscoverLinks/QueryWithIndexPattern.tsx | 9 +- .../__test__/DiscoverErrorLink.test.tsx | 59 ++++++ .../DiscoverLinks.integration.test.tsx | 102 ++++++++++ .../__test__/DiscoverTransactionLink.test.tsx | 30 +++ .../DiscoverErrorLink.test.tsx.snap | 39 ++++ .../DiscoverTransactionLink.test.tsx.snap | 13 ++ .../shared/Links/KibanaLink.test.tsx | 62 ++++++- .../components/shared/Links/KibanaLink.tsx | 8 +- .../shared/Links/KibanaRisonLink.test.tsx | 90 +++++++++ .../shared/Links/KibanaRisonLink.tsx | 49 +++++ .../shared/Links/MLJobLink.test.tsx | 13 +- .../components/shared/Links/MLJobLink.tsx | 4 +- .../__snapshots__/KibanaLink.test.tsx.snap | 4 +- .../KibanaRisonLink.test.tsx.snap | 11 ++ .../__snapshots__/MLJobLink.test.tsx.snap | 2 +- .../components/shared/Links/rison_helpers.ts | 91 +++++++++ .../shared/Links/url_helpers.test.tsx | 107 +++-------- .../components/shared/Links/url_helpers.ts | 125 +++++-------- .../components/shared/ManagedTable/index.tsx | 2 +- .../TransactionActionMenu.tsx | 6 +- .../TransactionActionMenu.test.tsx.snap | 14 +- x-pack/plugins/apm/public/index.tsx | 18 +- .../public/store/__jest__/rootReducer.test.js | 12 +- .../public/store/selectors/chartSelectors.ts | 7 +- x-pack/plugins/apm/public/store/urlParams.ts | 61 ++++-- .../plugins/apm/public/templates/index.html | 4 - .../utils/{testHelpers.ts => testHelpers.tsx} | 41 ++++ .../apm/public/utils/timepicker/index.js | 84 --------- .../apm/server/lib/errors/get_error_groups.ts | 2 +- x-pack/plugins/apm/server/routes/errors.ts | 2 +- 46 files changed, 1180 insertions(+), 393 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/shared/FilterBar/DatePicker.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/FilterBar/__test__/DatePicker.test.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/FilterBar/index.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverErrorLink.test.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverLinks.integration.test.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverTransactionLink.test.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverErrorLink.test.tsx.snap create mode 100644 x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverTransactionLink.test.tsx.snap create mode 100644 x-pack/plugins/apm/public/components/shared/Links/KibanaRisonLink.test.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/Links/KibanaRisonLink.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/Links/__snapshots__/KibanaRisonLink.test.tsx.snap create mode 100644 x-pack/plugins/apm/public/components/shared/Links/rison_helpers.ts rename x-pack/plugins/apm/public/utils/{testHelpers.ts => testHelpers.tsx} (65%) delete mode 100644 x-pack/plugins/apm/public/utils/timepicker/index.js diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/view.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/view.tsx index 82f6216062ec61..3a86eef4659629 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/view.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/view.tsx @@ -23,7 +23,7 @@ import { units } from '../../../style/variables'; // @ts-ignore -import { KueryBar } from '../../shared/KueryBar'; +import { FilterBar } from '../../shared/FilterBar'; import { DetailView } from './DetailView'; import { ErrorDistribution } from './Distribution'; @@ -108,7 +108,7 @@ export function ErrorGroupDetailsView({ urlParams, location }: Props) { - + diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap index 048e41dcc6a318..b1829421801024 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap @@ -553,7 +553,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > a0ce2 @@ -582,7 +582,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` @@ -660,7 +660,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > f3ac9 @@ -689,7 +689,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` @@ -767,7 +767,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > e9086 @@ -796,7 +796,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` @@ -874,7 +874,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > 8673d @@ -903,7 +903,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` diff --git a/x-pack/plugins/apm/public/components/app/Main/Home.tsx b/x-pack/plugins/apm/public/components/app/Main/Home.tsx index 843449bf197a8b..4116bea5e47a87 100644 --- a/x-pack/plugins/apm/public/components/app/Main/Home.tsx +++ b/x-pack/plugins/apm/public/components/app/Main/Home.tsx @@ -12,7 +12,7 @@ import { IHistoryTab } from 'x-pack/plugins/apm/public/components/shared/HistoryTabs'; // @ts-ignore -import { KueryBar } from '../../shared/KueryBar'; +import { FilterBar } from '../../shared/FilterBar'; import { SetupInstructionsLink } from '../../shared/Links/SetupInstructionsLink'; import { ServiceOverview } from '../ServiceOverview'; import { TraceOverview } from '../TraceOverview'; @@ -50,7 +50,7 @@ export function Home() { - + ); diff --git a/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.tsx b/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.tsx index 0bd8a76b857944..ad9dd310d3f0c0 100644 --- a/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.tsx +++ b/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.tsx @@ -5,10 +5,14 @@ */ import { Location } from 'history'; -import { last } from 'lodash'; +import { last, pick } from 'lodash'; import React from 'react'; import chrome from 'ui/chrome'; -import { toQuery } from '../../shared/Links/url_helpers'; +import { + fromQuery, + PERSISTENT_APM_PARAMS, + toQuery +} from '../../shared/Links/url_helpers'; import { Breadcrumb, ProvideBreadcrumbs } from './ProvideBreadcrumbs'; import { routes } from './routeConfig'; @@ -19,10 +23,12 @@ interface Props { class UpdateBreadcrumbsComponent extends React.Component { public updateHeaderBreadcrumbs() { - const { _g = '', kuery = '' } = toQuery(this.props.location.search); + const query = toQuery(this.props.location.search); + const persistentParams = pick(query, PERSISTENT_APM_PARAMS); + const search = fromQuery(persistentParams); const breadcrumbs = this.props.breadcrumbs.map(({ value, match }) => ({ text: value, - href: `#${match.url}?_g=${_g}&kuery=${kuery}` + href: `#${match.url}?${search}` })); const current = last(breadcrumbs) || { text: '' }; diff --git a/x-pack/plugins/apm/public/components/app/Main/__test__/UpdateBreadcrumbs.test.js b/x-pack/plugins/apm/public/components/app/Main/__test__/UpdateBreadcrumbs.test.js index 96c8b2d758ac8c..2140aec71828fa 100644 --- a/x-pack/plugins/apm/public/components/app/Main/__test__/UpdateBreadcrumbs.test.js +++ b/x-pack/plugins/apm/public/components/app/Main/__test__/UpdateBreadcrumbs.test.js @@ -37,7 +37,7 @@ jest.mock( function expectBreadcrumbToMatchSnapshot(route) { mount( - + ); diff --git a/x-pack/plugins/apm/public/components/app/Main/__test__/__snapshots__/Home.test.js.snap b/x-pack/plugins/apm/public/components/app/Main/__test__/__snapshots__/Home.test.js.snap index 4fe4e0448f0fea..ed410e77b09469 100644 --- a/x-pack/plugins/apm/public/components/app/Main/__test__/__snapshots__/Home.test.js.snap +++ b/x-pack/plugins/apm/public/components/app/Main/__test__/__snapshots__/Home.test.js.snap @@ -32,7 +32,7 @@ exports[`Home component should render 1`] = ` - + { public render() { const { urlParams, location } = this.props; return ( - { - return ( - - - - -

{urlParams.serviceName}

-
-
- - - -
+ + + + +

{urlParams.serviceName}

+
+
+ + + +
- + - + - -
- ); - }} - /> + ( + + )} + /> +
); } } diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/Transaction/WaterfallContainer/Waterfall/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/Transaction/WaterfallContainer/Waterfall/index.tsx index 889b6950470fb6..d56e8f784a0947 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/Transaction/WaterfallContainer/Waterfall/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/Transaction/WaterfallContainer/Waterfall/index.tsx @@ -10,9 +10,9 @@ import React, { Component } from 'react'; import { StickyContainer } from 'react-sticky'; import styled from 'styled-components'; import { + APMQueryParams, fromQuery, history, - QueryParams, toQuery } from 'x-pack/plugins/apm/public/components/shared/Links/url_helpers'; import { IUrlParams } from '../../../../../../store/urlParams'; @@ -150,7 +150,7 @@ export class Waterfall extends Component { ); } - private setQueryParams(params: QueryParams) { + private setQueryParams(params: APMQueryParams) { const { location } = this.props; history.replace({ ...location, diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/view.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/view.tsx index 49ffa75047f4a5..f86eb8fce41eb7 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/view.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/view.tsx @@ -14,8 +14,7 @@ import { WaterfallRequest } from '../../../store/reactReduxRequest/waterfall'; import { IUrlParams } from '../../../store/urlParams'; import { TransactionCharts } from '../../shared/charts/TransactionCharts'; import { EmptyMessage } from '../../shared/EmptyMessage'; -// @ts-ignore -import { KueryBar } from '../../shared/KueryBar'; +import { FilterBar } from '../../shared/FilterBar'; import { TransactionDistribution } from './Distribution'; import { Transaction } from './Transaction'; @@ -34,7 +33,7 @@ export function TransactionDetailsView({ urlParams, location }: Props) { - + diff --git a/x-pack/plugins/apm/public/components/shared/FilterBar/DatePicker.tsx b/x-pack/plugins/apm/public/components/shared/FilterBar/DatePicker.tsx new file mode 100644 index 00000000000000..24bc9293348f97 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/FilterBar/DatePicker.tsx @@ -0,0 +1,175 @@ +/* + * 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 datemath from '@elastic/datemath'; +import { EuiSuperDatePicker, EuiSuperDatePickerProps } from '@elastic/eui'; +import React from 'react'; +import { connect } from 'react-redux'; +import { RouteComponentProps, withRouter } from 'react-router-dom'; +import { + TIMEPICKER_DEFAULTS, + toBoolean, + toNumber, + updateTimePicker +} from 'x-pack/plugins/apm/public/store/urlParams'; +import { fromQuery, toQuery } from '../Links/url_helpers'; + +interface Props extends RouteComponentProps { + dispatchUpdateTimePicker: typeof updateTimePicker; +} + +class DatePickerComponent extends React.Component { + public refreshTimeoutId = 0; + + public getParamsFromSearch = (search: string) => { + const { rangeFrom, rangeTo, refreshPaused, refreshInterval } = { + ...TIMEPICKER_DEFAULTS, + ...toQuery(search) + }; + return { + rangeFrom, + rangeTo, + refreshPaused: toBoolean(refreshPaused), + refreshInterval: toNumber(refreshInterval) + }; + }; + + public componentDidMount() { + this.dispatchTimeRangeUpdate(); + this.restartRefreshCycle(); + } + + public componentWillUnmount() { + this.clearRefreshTimeout(); + } + + public componentDidUpdate(prevProps: Props) { + const currentParams = this.getParamsFromSearch(this.props.location.search); + const previousParams = this.getParamsFromSearch(prevProps.location.search); + if ( + currentParams.rangeFrom !== previousParams.rangeFrom || + currentParams.rangeTo !== previousParams.rangeTo + ) { + this.dispatchTimeRangeUpdate(); + } + + if ( + currentParams.refreshPaused !== previousParams.refreshPaused || + currentParams.refreshInterval !== previousParams.refreshInterval + ) { + this.restartRefreshCycle(); + } + } + + public dispatchTimeRangeUpdate() { + const { rangeFrom, rangeTo } = this.getParamsFromSearch( + this.props.location.search + ); + const parsed = { + from: datemath.parse(rangeFrom), + // roundUp: true is required for the quick select relative date values to work properly + to: datemath.parse(rangeTo, { roundUp: true }) + }; + if (!parsed.from || !parsed.to) { + return; + } + const min = parsed.from.toISOString(); + const max = parsed.to.toISOString(); + this.props.dispatchUpdateTimePicker({ min, max }); + } + + public clearRefreshTimeout() { + if (this.refreshTimeoutId) { + window.clearTimeout(this.refreshTimeoutId); + } + } + + public refresh = () => { + const { refreshPaused, refreshInterval } = this.getParamsFromSearch( + this.props.location.search + ); + + this.clearRefreshTimeout(); + + if (refreshPaused) { + return; + } + + this.dispatchTimeRangeUpdate(); + this.refreshTimeoutId = window.setTimeout(this.refresh, refreshInterval); + }; + + public restartRefreshCycle = () => { + this.clearRefreshTimeout(); + const { refreshInterval, refreshPaused } = this.getParamsFromSearch( + this.props.location.search + ); + if (refreshPaused) { + return; + } + this.refreshTimeoutId = window.setTimeout(this.refresh, refreshInterval); + }; + + public updateUrl(nextSearch: { + rangeFrom?: string; + rangeTo?: string; + refreshPaused?: boolean; + refreshInterval?: number; + }) { + const currentSearch = toQuery(this.props.location.search); + + this.props.history.replace({ + ...this.props.location, + search: fromQuery({ + ...currentSearch, + ...nextSearch + }) + }); + } + + public handleRefreshChange: EuiSuperDatePickerProps['onRefreshChange'] = ({ + isPaused, + refreshInterval + }) => { + this.updateUrl({ + refreshPaused: isPaused, + refreshInterval + }); + }; + + public handleTimeChange: EuiSuperDatePickerProps['onTimeChange'] = options => { + this.updateUrl({ rangeFrom: options.start, rangeTo: options.end }); + }; + + public render() { + const { + rangeFrom, + rangeTo, + refreshPaused, + refreshInterval + } = this.getParamsFromSearch(this.props.location.search); + return ( + + ); + } +} + +const DatePicker = withRouter( + connect( + null, + { dispatchUpdateTimePicker: updateTimePicker } + )(DatePickerComponent) +); + +export { DatePicker }; diff --git a/x-pack/plugins/apm/public/components/shared/FilterBar/__test__/DatePicker.test.tsx b/x-pack/plugins/apm/public/components/shared/FilterBar/__test__/DatePicker.test.tsx new file mode 100644 index 00000000000000..29e9b524dd8e42 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/FilterBar/__test__/DatePicker.test.tsx @@ -0,0 +1,133 @@ +/* + * 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 } from 'enzyme'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +// @ts-ignore +import configureStore from 'x-pack/plugins/apm/public/store/config/configureStore'; +import { mockNow } from 'x-pack/plugins/apm/public/utils/testHelpers'; +import { DatePicker } from '../DatePicker'; + +function mountPicker(search?: string) { + const store = configureStore(); + let path = '/whatever'; + if (search) { + path += `?${search}`; + } + const mounted = mount( + + + + + + ); + return { mounted, store }; +} + +describe('DatePicker', () => { + describe('date calculations', () => { + let restoreNow: () => void; + + beforeAll(() => { + restoreNow = mockNow('2019-02-15T12:00:00.000Z'); + }); + + afterAll(() => { + restoreNow(); + }); + + it('should initialize with APM default date range', () => { + const { store } = mountPicker(); + expect(store.getState().urlParams).toEqual({ + start: '2019-02-14T12:00:00.000Z', + end: '2019-02-15T12:00:00.000Z' + }); + }); + + it('should parse "last 15 minutes" from URL params', () => { + const { store } = mountPicker('rangeFrom=now-15m&rangeTo=now'); + expect(store.getState().urlParams).toEqual({ + start: '2019-02-15T11:45:00.000Z', + end: '2019-02-15T12:00:00.000Z' + }); + }); + + it('should parse "last 7 days" from URL params', () => { + const { store } = mountPicker('rangeFrom=now-7d&rangeTo=now'); + expect(store.getState().urlParams).toEqual({ + start: '2019-02-08T12:00:00.000Z', + end: '2019-02-15T12:00:00.000Z' + }); + }); + + it('should parse absolute dates from URL params', () => { + const { store } = mountPicker( + `rangeFrom=2019-02-03T10:00:00.000Z&rangeTo=2019-02-10T16:30:00.000Z` + ); + expect(store.getState().urlParams).toEqual({ + start: '2019-02-03T10:00:00.000Z', + end: '2019-02-10T16:30:00.000Z' + }); + }); + }); + + describe('refresh cycle', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should refresh the store once per refresh interval', async () => { + const { store } = mountPicker( + 'rangeFrom=now-15m&rangeTo=now&refreshPaused=false&refreshInterval=200' + ); + const listener = jest.fn(); + store.subscribe(listener); + jest.advanceTimersByTime(1100); + + expect(listener).toHaveBeenCalledTimes(5); + }); + + it('should not refresh when paused', async () => { + const { store } = mountPicker( + 'rangeFrom=now-15m&rangeTo=now&refreshPaused=true&refreshInterval=200' + ); + const listener = jest.fn(); + store.subscribe(listener); + jest.advanceTimersByTime(1100); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('should be paused by default', async () => { + const { store } = mountPicker( + 'rangeFrom=now-15m&rangeTo=now&refreshInterval=200' + ); + const listener = jest.fn(); + store.subscribe(listener); + jest.advanceTimersByTime(1100); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('should not attempt refreshes after unmounting', async () => { + const { store, mounted } = mountPicker( + 'rangeFrom=now-15m&rangeTo=now&refreshPaused=false&refreshInterval=200' + ); + const listener = jest.fn(); + store.subscribe(listener); + mounted.unmount(); + jest.advanceTimersByTime(1100); + + expect(listener).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/FilterBar/index.tsx b/x-pack/plugins/apm/public/components/shared/FilterBar/index.tsx new file mode 100644 index 00000000000000..130cb2c97829c7 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/FilterBar/index.tsx @@ -0,0 +1,24 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React from 'react'; +// @ts-ignore +import { KueryBar } from '../KueryBar'; +import { DatePicker } from './DatePicker'; + +export function FilterBar() { + return ( + + + + + + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverLink.tsx index 2a86655c886074..3566a996c97b71 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverLink.tsx @@ -5,12 +5,12 @@ */ import React from 'react'; -import { KibanaLink } from '../KibanaLink'; -import { QueryParamsDecoded } from '../url_helpers'; +import { KibanaRisonLink } from '../KibanaRisonLink'; +import { RisonAPMQueryParams } from '../rison_helpers'; import { QueryWithIndexPattern } from './QueryWithIndexPattern'; interface Props { - query: QueryParamsDecoded; + query: RisonAPMQueryParams; children: React.ReactNode; } @@ -18,7 +18,7 @@ export function DiscoverLink({ query, ...rest }: Props) { return ( {queryWithIndexPattern => ( - ReactElement; + query: RisonAPMQueryParams; + children: (query: RisonAPMQueryParams) => ReactElement; } interface State { diff --git a/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverErrorLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverErrorLink.test.tsx new file mode 100644 index 00000000000000..2d5a3909b8ce39 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverErrorLink.test.tsx @@ -0,0 +1,59 @@ +/* + * 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 { shallow, ShallowWrapper } from 'enzyme'; +import 'jest-styled-components'; +import React from 'react'; +import { APMError } from 'x-pack/plugins/apm/typings/es_schemas/Error'; +import { DiscoverErrorLink } from '../DiscoverErrorLink'; + +describe('DiscoverErrorLink without kuery', () => { + let wrapper: ShallowWrapper; + beforeEach(() => { + const error = { + service: { name: 'myServiceName' }, + error: { grouping_key: 'myGroupingKey' } + } as APMError; + + wrapper = shallow(); + }); + + it('should have correct query', () => { + const queryProp = wrapper.prop('query') as any; + expect(queryProp._a.query.query).toEqual( + 'service.name:"myServiceName" AND error.grouping_key:"myGroupingKey"' + ); + }); + + it('should match snapshot', () => { + expect(wrapper).toMatchSnapshot(); + }); +}); + +describe('DiscoverErrorLink with kuery', () => { + let wrapper: ShallowWrapper; + beforeEach(() => { + const error = { + service: { name: 'myServiceName' }, + error: { grouping_key: 'myGroupingKey' } + } as APMError; + + const kuery = 'transaction.sampled: true'; + + wrapper = shallow(); + }); + + it('should have correct query', () => { + const queryProp = wrapper.prop('query') as any; + expect(queryProp._a.query.query).toEqual( + 'service.name:"myServiceName" AND error.grouping_key:"myGroupingKey" AND transaction.sampled: true' + ); + }); + + it('should match snapshot', () => { + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverLinks.integration.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverLinks.integration.test.tsx new file mode 100644 index 00000000000000..430d094380dc49 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverLinks.integration.test.tsx @@ -0,0 +1,102 @@ +/* + * 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 { Location } from 'history'; +import React from 'react'; +import * as savedObjects from 'x-pack/plugins/apm/public/services/rest/savedObjects'; +import { getRenderedHref } from 'x-pack/plugins/apm/public/utils/testHelpers'; +import { APMError } from 'x-pack/plugins/apm/typings/es_schemas/Error'; +import { Span } from 'x-pack/plugins/apm/typings/es_schemas/Span'; +import { Transaction } from 'x-pack/plugins/apm/typings/es_schemas/Transaction'; +import { DiscoverErrorLink } from '../DiscoverErrorLink'; +import { DiscoverSpanLink } from '../DiscoverSpanLink'; +import { DiscoverTransactionLink } from '../DiscoverTransactionLink'; + +// NOTE: jest.mock() is broken in TS test files (b/c of ts-jest, I think) +// but using jest's "spies can be stubbed" feature, this works: +jest + .spyOn(savedObjects, 'getAPMIndexPattern') + .mockReturnValue( + Promise.resolve({ id: 'apm-index-pattern-id' } as savedObjects.ISavedObject) + ); + +test('DiscoverTransactionLink should produce the correct URL', async () => { + const transaction = { + transaction: { + id: '8b60bd32ecc6e150' + }, + trace: { + id: '8b60bd32ecc6e1506735a8b6cfcf175c' + } + } as Transaction; + const href = await getRenderedHref( + () => , + { + location: { search: '?rangeFrom=now/w&rangeTo=now' } as Location + } + ); + + expect(href).toEqual( + `/app/kibana#/discover?_g=(refreshInterval:(pause:true,value:'0'),time:(from:now%2Fw,to:now))&_a=(index:apm-index-pattern-id,interval:auto,query:(language:lucene,query:'processor.event:"transaction" AND transaction.id:"8b60bd32ecc6e150" AND trace.id:"8b60bd32ecc6e1506735a8b6cfcf175c"'))` + ); +}); + +test('DiscoverSpanLink should produce the correct URL', async () => { + const span = { + span: { + id: 'test-span-id' + } + } as Span; + const href = await getRenderedHref(() => , { + location: { search: '?rangeFrom=now/w&rangeTo=now' } as Location + }); + + expect(href).toEqual( + `/app/kibana#/discover?_g=(refreshInterval:(pause:true,value:'0'),time:(from:now%2Fw,to:now))&_a=(index:apm-index-pattern-id,interval:auto,query:(language:lucene,query:'span.id:"test-span-id"'))` + ); +}); + +test('DiscoverErrorLink should produce the correct URL', async () => { + const error = { + service: { + name: 'service-name' + }, + error: { + grouping_key: 'grouping-key' + } + } as APMError; + const href = await getRenderedHref( + () => , + { + location: { search: '?rangeFrom=now/w&rangeTo=now' } as Location + } + ); + + expect(href).toEqual( + `/app/kibana#/discover?_g=(refreshInterval:(pause:true,value:'0'),time:(from:now%2Fw,to:now))&_a=(index:apm-index-pattern-id,interval:auto,query:(language:lucene,query:'service.name:"service-name" AND error.grouping_key:"grouping-key"'),sort:('@timestamp':desc))` + ); +}); + +test('DiscoverErrorLink should include optional kuery string in URL', async () => { + const error = { + service: { + name: 'service-name' + }, + error: { + grouping_key: 'grouping-key' + } + } as APMError; + const href = await getRenderedHref( + () => , + { + location: { search: '?rangeFrom=now/w&rangeTo=now' } as Location + } + ); + + expect(href).toEqual( + `/app/kibana#/discover?_g=(refreshInterval:(pause:true,value:'0'),time:(from:now%2Fw,to:now))&_a=(index:apm-index-pattern-id,interval:auto,query:(language:lucene,query:'service.name:"service-name" AND error.grouping_key:"grouping-key" AND some:kuery-string'),sort:('@timestamp':desc))` + ); +}); diff --git a/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverTransactionLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverTransactionLink.test.tsx new file mode 100644 index 00000000000000..f713de77694f2a --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverTransactionLink.test.tsx @@ -0,0 +1,30 @@ +/* + * 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 'jest-styled-components'; +// @ts-ignore +import configureStore from 'x-pack/plugins/apm/public/store/config/configureStore'; +import { Transaction } from 'x-pack/plugins/apm/typings/es_schemas/Transaction'; +import { getDiscoverQuery } from '../DiscoverTransactionLink'; + +function getMockTransaction() { + return { + transaction: { + id: '8b60bd32ecc6e150' + }, + trace: { + id: '8b60bd32ecc6e1506735a8b6cfcf175c' + } + } as Transaction; +} + +describe('getDiscoverQuery', () => { + it('should return the correct query params object', () => { + const transaction = getMockTransaction(); + const result = getDiscoverQuery(transaction); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverErrorLink.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverErrorLink.test.tsx.snap new file mode 100644 index 00000000000000..61007f91dceddf --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverErrorLink.test.tsx.snap @@ -0,0 +1,39 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DiscoverErrorLink with kuery should match snapshot 1`] = ` + +`; + +exports[`DiscoverErrorLink without kuery should match snapshot 1`] = ` + +`; diff --git a/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverTransactionLink.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverTransactionLink.test.tsx.snap new file mode 100644 index 00000000000000..aa3a01a16c511a --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverTransactionLink.test.tsx.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getDiscoverQuery should return the correct query params object 1`] = ` +Object { + "_a": Object { + "interval": "auto", + "query": Object { + "language": "lucene", + "query": "processor.event:\\"transaction\\" AND transaction.id:\\"8b60bd32ecc6e150\\" AND trace.id:\\"8b60bd32ecc6e1506735a8b6cfcf175c\\"", + }, + }, +} +`; diff --git a/x-pack/plugins/apm/public/components/shared/Links/KibanaLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/KibanaLink.test.tsx index d2ae0a7d0d3a65..be53c409f70957 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/KibanaLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/KibanaLink.test.tsx @@ -9,18 +9,62 @@ import { Location } from 'history'; import React from 'react'; import { UnconnectedKibanaLink } from './KibanaLink'; +const getLinkWrapper = ({ + search = '', + pathname = '/app/kibana', + hash = '/something', + children = 'Some link text', + query = {} +} = {}) => + shallow( + + ); + describe('UnconnectedKibanaLink', () => { it('should render correct markup', () => { - const wrapper = shallow( - - Go to Discover - + expect(getLinkWrapper()).toMatchSnapshot(); + }); + + it('should include valid query params', () => { + const wrapper = getLinkWrapper({ query: { transactionId: 'test-id' } }); + expect(wrapper.find('EuiLink').props().href).toEqual( + '/app/kibana#/something?transactionId=test-id' ); + }); - expect(wrapper).toMatchSnapshot(); + it('should include existing APM params for APM links', () => { + const wrapper = getLinkWrapper({ + pathname: '/app/apm', + search: '?rangeFrom=now-5w&rangeTo=now-2w' + }); + expect(wrapper.find('EuiLink').props().href).toEqual( + `/app/apm#/something?rangeFrom=now-5w&rangeTo=now-2w` + ); + }); + + it('should include APM params when the pathname is an empty string', () => { + const wrapper = getLinkWrapper({ + pathname: '', + search: '?rangeFrom=now-5w&rangeTo=now-2w' + }); + expect(wrapper.find('EuiLink').props().href).toEqual( + `#/something?rangeFrom=now-5w&rangeTo=now-2w` + ); + }); + + it('should NOT include APM params for non-APM links', () => { + const wrapper = getLinkWrapper({ + pathname: '/app/something-else', + search: '?rangeFrom=now-5w&rangeTo=now-2w' + }); + expect(wrapper.find('EuiLink').props().href).toEqual( + `/app/something-else#/something?` + ); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/Links/KibanaLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/KibanaLink.tsx index 6a1bb80e365664..3e7d5b3daef62e 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/KibanaLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/KibanaLink.tsx @@ -23,7 +23,7 @@ interface Props extends KibanaHrefArgs { * * You must remember to pass in location in that case. */ -export const UnconnectedKibanaLink: React.FunctionComponent = ({ +const UnconnectedKibanaLink: React.FunctionComponent = ({ location, pathname, hash, @@ -39,11 +39,11 @@ export const UnconnectedKibanaLink: React.FunctionComponent = ({ return ; }; -UnconnectedKibanaLink.displayName = 'UnconnectedKibanaLink'; - const withLocation = connect( ({ location }: { location: Location }) => ({ location }), {} ); -export const KibanaLink = withLocation(UnconnectedKibanaLink); +const KibanaLink = withLocation(UnconnectedKibanaLink); + +export { UnconnectedKibanaLink, KibanaLink }; diff --git a/x-pack/plugins/apm/public/components/shared/Links/KibanaRisonLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/KibanaRisonLink.test.tsx new file mode 100644 index 00000000000000..ee0885780d44ef --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/Links/KibanaRisonLink.test.tsx @@ -0,0 +1,90 @@ +/* + * 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 { shallow } from 'enzyme'; +import { Location } from 'history'; +import React from 'react'; +import { UnconnectedKibanaRisonLink } from './KibanaRisonLink'; + +const getLinkWrapper = ({ + search = '', + pathname = '/app/kibana', + hash = '/discover', + children = 'Some discover link text', + query = {} +} = {}) => + shallow( + + ); + +const DEFAULT_RISON_G = `(refreshInterval:(pause:true,value:'0'),time:(from:now-24h,to:now))`; + +describe('UnconnectedKibanaLink', () => { + it('should render correct markup', () => { + expect(getLinkWrapper()).toMatchSnapshot(); + }); + + it('should include default time picker values, rison-encoded', () => { + const wrapper = getLinkWrapper(); + expect(wrapper.find('EuiLink').props().href).toEqual( + expect.stringContaining(DEFAULT_RISON_G) + ); + }); + + it('should ignore new query params except for _g and _a', () => { + const wrapper = getLinkWrapper({ query: { transactionId: 'test-id' } }); + expect(wrapper.find('EuiLink').props().href).not.toEqual( + expect.stringContaining('transactionId') + ); + }); + + it('should rison-encode and merge in custom _g value', () => { + const wrapper = getLinkWrapper({ + query: { + _g: { + something: { + nested: 'custom g value' + } + } + } + }); + + expect(wrapper.find('EuiLink').props().href).toEqual( + expect.stringContaining(`something:(nested:'custom g value')`) + ); + }); + + it('should rison-encode custom _a value', () => { + const wrapper = getLinkWrapper({ + query: { + _a: { + something: { + nested: 'custom a value' + } + } + } + }); + expect(wrapper.find('EuiLink').props().href).toEqual( + expect.stringContaining(`_a=(something:(nested:'custom a value'))`) + ); + }); + + it('should convert, url-encode, and rison-encode existing time picker values', () => { + const wrapper = getLinkWrapper({ + search: + '?rangeFrom=now/w&rangeTo=now&refreshPaused=false&refreshInterval=30000' + }); + expect(wrapper.find('EuiLink').props().href).toEqual( + "/app/kibana#/discover?_g=(refreshInterval:(pause:false,value:'30000'),time:(from:now%2Fw,to:now))" + ); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/Links/KibanaRisonLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/KibanaRisonLink.tsx new file mode 100644 index 00000000000000..aef5a131388993 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/Links/KibanaRisonLink.tsx @@ -0,0 +1,49 @@ +/* + * 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 { EuiLink } from '@elastic/eui'; +import { Location } from 'history'; +import React from 'react'; +import { connect } from 'react-redux'; +import { StringMap } from 'x-pack/plugins/apm/typings/common'; +import { getRisonHref, RisonHrefArgs } from './rison_helpers'; + +interface Props extends RisonHrefArgs { + disabled?: boolean; + to?: StringMap; + className?: string; +} + +/** + * NOTE: Use this component directly if you have to use a link that is + * going to be rendered outside of React, e.g. in the Kibana global toast loader. + * + * You must remember to pass in location in that case. + */ +const UnconnectedKibanaRisonLink: React.FunctionComponent = ({ + location, + pathname, + hash, + query, + ...props +}) => { + const href = getRisonHref({ + location, + pathname, + hash, + query + }); + return ; +}; + +const withLocation = connect( + ({ location }: { location: Location }) => ({ location }), + {} +); + +const KibanaRisonLink = withLocation(UnconnectedKibanaRisonLink); + +export { UnconnectedKibanaRisonLink, KibanaRisonLink }; diff --git a/x-pack/plugins/apm/public/components/shared/Links/MLJobLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/MLJobLink.test.tsx index 575f9dbe1c1584..b99fc40f2ba348 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MLJobLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MLJobLink.test.tsx @@ -7,6 +7,7 @@ import { shallow } from 'enzyme'; import { Location } from 'history'; import React from 'react'; +import { getRenderedHref } from 'x-pack/plugins/apm/public/utils/testHelpers'; import { MLJobLink } from './MLJobLink'; describe('MLJobLink', () => { @@ -23,18 +24,18 @@ describe('MLJobLink', () => { expect(wrapper).toMatchSnapshot(); }); - it('should have correct path props', () => { - const location = { search: '' } as Location; - const wrapper = shallow( + it('should produce the correct URL', async () => { + const location = { search: '?rangeFrom=now/w&rangeTo=now-4h' } as Location; + const href = await getRenderedHref(() => ( - ); + )); - expect(wrapper.prop('href')).toBe( - '/app/ml#/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),time:(from:now-24h,mode:quick,to:now))' + expect(href).toEqual( + `/app/ml#/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:true,value:'0'),time:(from:now%2Fw,to:now-4h))` ); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/Links/MLJobLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/MLJobLink.tsx index 7daa081ca8126e..21749433349325 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MLJobLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MLJobLink.tsx @@ -8,7 +8,7 @@ import { EuiLink } from '@elastic/eui'; import { Location } from 'history'; import React from 'react'; import { getMlJobId } from 'x-pack/plugins/apm/common/ml_job_constants'; -import { getKibanaHref } from './url_helpers'; +import { getRisonHref } from './rison_helpers'; interface Props { serviceName: string; @@ -29,7 +29,7 @@ export const MLJobLink: React.SFC = ({ _g: { ml: { jobIds: [jobId] } } }; - const href = getKibanaHref({ + const href = getRisonHref({ location, pathname, hash, diff --git a/x-pack/plugins/apm/public/components/shared/Links/__snapshots__/KibanaLink.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/Links/__snapshots__/KibanaLink.test.tsx.snap index 3626664a597a73..9aaa0d7fe629c1 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/__snapshots__/KibanaLink.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/shared/Links/__snapshots__/KibanaLink.test.tsx.snap @@ -3,9 +3,9 @@ exports[`UnconnectedKibanaLink should render correct markup 1`] = ` - Go to Discover + Some link text `; diff --git a/x-pack/plugins/apm/public/components/shared/Links/__snapshots__/KibanaRisonLink.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/Links/__snapshots__/KibanaRisonLink.test.tsx.snap new file mode 100644 index 00000000000000..e46f25aa1d095d --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/Links/__snapshots__/KibanaRisonLink.test.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`UnconnectedKibanaLink should render correct markup 1`] = ` + + Some discover link text + +`; diff --git a/x-pack/plugins/apm/public/components/shared/Links/__snapshots__/MLJobLink.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/Links/__snapshots__/MLJobLink.test.tsx.snap index bd6aa67c1e737e..203580880d145c 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/__snapshots__/MLJobLink.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/shared/Links/__snapshots__/MLJobLink.test.tsx.snap @@ -3,7 +3,7 @@ exports[`MLJobLink should render component 1`] = ` `; diff --git a/x-pack/plugins/apm/public/components/shared/Links/rison_helpers.ts b/x-pack/plugins/apm/public/components/shared/Links/rison_helpers.ts new file mode 100644 index 00000000000000..ba8b7c67a156b0 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/Links/rison_helpers.ts @@ -0,0 +1,91 @@ +/* + * 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 { pick, set } from 'lodash'; +import qs from 'querystring'; +import rison from 'rison-node'; +import chrome from 'ui/chrome'; +import url from 'url'; +import { TIMEPICKER_DEFAULTS } from 'x-pack/plugins/apm/public/store/urlParams'; +import { StringMap } from 'x-pack/plugins/apm/typings/common'; +import { + APMQueryParams, + KibanaHrefArgs, + PERSISTENT_APM_PARAMS, + toQuery +} from './url_helpers'; + +interface RisonEncoded { + _g?: string; + _a?: string; +} + +export interface RisonDecoded { + _g?: StringMap; + _a?: StringMap; +} + +export type RisonAPMQueryParams = APMQueryParams & RisonDecoded; +export type RisonHrefArgs = KibanaHrefArgs; + +function createG(query: RisonAPMQueryParams) { + const { _g: nextG = {} } = query; + const g: RisonDecoded['_g'] = { ...nextG }; + + if (typeof query.rangeFrom !== 'undefined') { + set(g, 'time.from', encodeURIComponent(query.rangeFrom)); + } + if (typeof query.rangeTo !== 'undefined') { + set(g, 'time.to', encodeURIComponent(query.rangeTo)); + } + + if (typeof query.refreshPaused !== 'undefined') { + set(g, 'refreshInterval.pause', String(query.refreshPaused)); + } + if (typeof query.refreshInterval !== 'undefined') { + set(g, 'refreshInterval.value', String(query.refreshInterval)); + } + + return g; +} + +export function getRisonHref({ + location, + pathname, + hash, + query = {} +}: RisonHrefArgs) { + const currentQuery = toQuery(location.search); + const nextQuery = { + ...TIMEPICKER_DEFAULTS, + ...pick(currentQuery, PERSISTENT_APM_PARAMS), + ...query + }; + + // Create _g value for non-apm links + const g = createG(nextQuery); + const encodedG = rison.encode(g); + const encodedA = query._a ? rison.encode(query._a) : ''; // TODO: Do we need to url-encode the _a values before rison encoding _a? + const risonQuery: RisonEncoded = { + _g: encodedG + }; + + if (encodedA) { + risonQuery._a = encodedA; + } + + // don't URI-encode the already-encoded rison + const search = qs.stringify(risonQuery, undefined, undefined, { + encodeURIComponent: (v: string) => v + }); + + const href = url.format({ + pathname: chrome.addBasePath(pathname), + hash: `${hash}?${search}` + }); + + return href; +} diff --git a/x-pack/plugins/apm/public/components/shared/Links/url_helpers.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/url_helpers.test.tsx index 808770938b61ce..20edd11b95be9a 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/url_helpers.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/url_helpers.test.tsx @@ -23,99 +23,44 @@ describe('fromQuery', () => { it('should parse object to string', () => { expect( fromQuery({ - foo: 'bar', - name: 'john doe' - } as any) - ).toEqual('foo=bar&name=john%20doe'); + traceId: 'bar', + transactionId: 'john doe' + }) + ).toEqual('traceId=bar&transactionId=john%20doe'); }); - it('should not encode _a and _g', () => { + it('should not encode range params', () => { expect( fromQuery({ - g: 'john doe:', - _g: 'john doe:', - a: 'john doe:', - _a: 'john doe:' - } as any) - ).toEqual('g=john%20doe%3A&_g=john%20doe:&a=john%20doe%3A&_a=john%20doe:'); + traceId: 'b/c', + rangeFrom: '2019-03-03T12:00:00.000Z', + rangeTo: '2019-03-05T12:00:00.000Z' + }) + ).toEqual( + 'traceId=b%2Fc&rangeFrom=2019-03-03T12:00:00.000Z&rangeTo=2019-03-05T12:00:00.000Z' + ); }); }); describe('getKibanaHref', () => { - it('should build correct url', () => { - const location = {} as Location; - const pathname = '/app/kibana'; - const hash = '/discover'; - const href = getKibanaHref({ location, pathname, hash }); - expect(href).toBe( - '/app/kibana#/discover?_g=(time:(from:now-24h,mode:quick,to:now))' + it('should build correct URL for APM paths, include existing date range params', () => { + const location = { search: '?rangeFrom=now/w&rangeTo=now-24h' } as Location; + const pathname = '/app/apm'; + const hash = '/services/x/transactions'; + const query = { transactionId: 'something' }; + const href = getKibanaHref({ location, pathname, hash, query }); + expect(href).toEqual( + '/app/apm#/services/x/transactions?rangeFrom=now/w&rangeTo=now-24h&transactionId=something' ); }); - it('should rison encode _a', () => { - const location = {} as Location; + it('should build correct url for non-APM paths, ignoring date range params', () => { + const location = { search: '?rangeFrom=now/w&rangeTo=now-24h' } as Location; const pathname = '/app/kibana'; - const hash = '/discover'; - const query = { - _a: { - interval: 'auto', - query: { - language: 'lucene', - query: `context.service.name:"myServiceName" AND error.grouping_key:"myGroupId"` - }, - sort: { '@timestamp': 'desc' } - } - }; - const href = getKibanaHref({ query, location, pathname, hash }); - const { _a } = getUrlQuery(href); - expect(_a).toEqual( - `(interval:auto,query:(language:lucene,query:'context.service.name:\"myServiceName\" AND error.grouping_key:\"myGroupId\"'),sort:('@timestamp':desc))` - ); - }); - - describe('_g', () => { - it('should preserve _g from location', () => { - const location = { - search: '?_g=(time:(from:now-7d,mode:relative,to:now-1d))' - } as Location; - const pathname = '/app/kibana'; - const hash = '/discover'; - const href = getKibanaHref({ location, pathname, hash }); - const { _g } = getUrlQuery(href); - expect(_g).toBe('(time:(from:now-7d,mode:relative,to:now-1d))'); - }); - - it('should use default time range when _g is empty', () => { - const location = {} as Location; - const pathname = '/app/kibana'; - const hash = '/discover'; - const href = getKibanaHref({ location, pathname, hash }); - const { _g } = getUrlQuery(href); - expect(_g).toBe('(time:(from:now-24h,mode:quick,to:now))'); - }); - - it('should use default value when given invalid input', () => { - const location = { search: '?_g=H@whatever' } as Location; - const pathname = '/app/kibana'; - const hash = '/discover'; - const href = getKibanaHref({ location, pathname, hash }); - const { _g } = getUrlQuery(href); - expect(_g).toBe('(time:(from:now-24h,mode:quick,to:now))'); - }); - - it('should merge in _g query values', () => { - const location = { - search: '?_g=(time:(from:now-7d,mode:relative,to:now-1d))' - } as Location; - const pathname = '/app/kibana'; - const hash = '/discover'; - const query = { _g: { ml: { jobIds: [1337] } } }; - const href = getKibanaHref({ location, query, pathname, hash }); - const { _g } = getUrlQuery(href); - expect(_g).toBe( - '(ml:(jobIds:!(1337)),time:(from:now-7d,mode:relative,to:now-1d))' - ); - }); + const hash = '/outside'; + const query = { transactionId: 'something' }; + const href = getKibanaHref({ location, pathname, hash, query }); + expect(href).toEqual('/app/kibana#/outside?transactionId=something'); }); describe('when location contains kuery', () => { diff --git a/x-pack/plugins/apm/public/components/shared/Links/url_helpers.ts b/x-pack/plugins/apm/public/components/shared/Links/url_helpers.ts index 0345e7d59360f1..7293b0159fd6e5 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/url_helpers.ts +++ b/x-pack/plugins/apm/public/components/shared/Links/url_helpers.ts @@ -6,109 +6,75 @@ import { Location } from 'history'; import createHistory from 'history/createHashHistory'; -import { isPlainObject, mapValues } from 'lodash'; +import { mapValues, pick } from 'lodash'; import qs from 'querystring'; -import rison from 'rison-node'; import chrome from 'ui/chrome'; import url from 'url'; -import { StringMap } from 'x-pack/plugins/apm/typings/common'; -export function toQuery(search?: string): QueryParams { +export function toQuery(search?: string): APMQueryParamsRaw { return search ? qs.parse(search.slice(1)) : {}; } -export function fromQuery(query: QueryParams) { - const encodedQuery = encodeQuery(query, ['_g', '_a']); - return stringifyWithoutEncoding(encodedQuery); -} - -export function encodeQuery(query: QueryParams, exclude: string[] = []) { - return mapValues(query, (value, key) => { - if (exclude.includes(key as string)) { - return encodeURI(value); +export function fromQuery(query: APMQueryParams) { + // we have to avoid encoding range params because they cause + // Kibana angular to decode them and append them to the existing + // URL as an encoded hash /shrug + const encoded = mapValues(query, (value, key) => { + if (['rangeFrom', 'rangeTo'].includes(key!)) { + return value; } - return qs.escape(value); + return qs.escape(value.toString()); }); -} -function stringifyWithoutEncoding(query: QueryParams) { - return qs.stringify(query, undefined, undefined, { - encodeURIComponent: (v: string) => v + return qs.stringify(encoded, '&', '=', { + encodeURIComponent: (value: string) => value }); } -function risonSafeDecode(value?: string) { - if (!value) { - return {}; - } - - try { - const decoded = rison.decode(value); - return isPlainObject(decoded) ? (decoded as StringMap) : {}; - } catch (e) { - return {}; - } -} - -// 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' - } -}; +export const PERSISTENT_APM_PARAMS = [ + 'kuery', + 'rangeFrom', + 'rangeTo', + 'refreshPaused', + 'refreshInterval' +]; -function getQueryWithRisonParams( +function getSearchString( location: Location, pathname: string, - query: RisonDecoded = {} + query: APMQueryParams = {} ) { - // Preserve current _g and _a const currentQuery = toQuery(location.search); - const decodedG = risonSafeDecode(currentQuery._g); - const combinedG = { ...DEFAULT_KIBANA_TIME_RANGE, ...decodedG, ...query._g }; - const encodedG = rison.encode(combinedG); - const encodedA = query._a ? rison.encode(query._a) : ''; - - const nextQuery: StringMap = { - ...query, - _g: encodedG - }; - // Preserve kuery for apm links + // Preserve existing params for apm links const isApmLink = pathname.includes('app/apm') || pathname === ''; - if (currentQuery.kuery && isApmLink) { - nextQuery.kuery = currentQuery.kuery; - } - - if (encodedA) { - nextQuery._a = encodedA; + if (isApmLink) { + const nextQuery = { + ...pick(currentQuery, PERSISTENT_APM_PARAMS), + ...query + }; + return fromQuery(nextQuery); } - return nextQuery; + return fromQuery(query); } -export interface KibanaHrefArgs { +export interface KibanaHrefArgs { location: Location; pathname?: string; hash?: string; - query?: QueryParamsDecoded; + query?: T; } +// TODO: Will eventually need to solve for the case when we need to use this helper to link to +// another Kibana app which requires url query params not covered by APMQueryParams export function getKibanaHref({ location, pathname = '', hash, query = {} }: KibanaHrefArgs): string { - const queryWithRisonParams = getQueryWithRisonParams( - location, - pathname, - query - ); - const search = stringifyWithoutEncoding(queryWithRisonParams); + const search = getSearchString(location, pathname, query); const href = url.format({ pathname: chrome.addBasePath(pathname), hash: `${hash}?${search}` @@ -116,31 +82,26 @@ export function getKibanaHref({ return href; } -interface APMQueryParams { +export interface APMQueryParams { transactionId?: string; traceId?: string; detailTab?: string; flyoutDetailTab?: string; waterfallItemId?: string; spanId?: string; - page?: string; + page?: string | number; sortDirection?: string; sortField?: string; kuery?: string; + rangeFrom?: string; + rangeTo?: string; + refreshPaused?: string | boolean; + refreshInterval?: string | number; } -interface RisonEncoded { - _g?: string; - _a?: string; -} - -interface RisonDecoded { - _g?: StringMap; - _a?: StringMap; -} - -export type QueryParams = APMQueryParams & RisonEncoded; -export type QueryParamsDecoded = APMQueryParams & RisonDecoded; +// forces every value of T[K] to be type: string +type StringifyAll = { [K in keyof T]: string }; +type APMQueryParamsRaw = StringifyAll; // This is downright horrible 😭 💔 // Angular decodes encoded url tokens like "%2F" to "/" which causes the route to change. diff --git a/x-pack/plugins/apm/public/components/shared/ManagedTable/index.tsx b/x-pack/plugins/apm/public/components/shared/ManagedTable/index.tsx index 2a8eafaef4333b..79b212558fd2e3 100644 --- a/x-pack/plugins/apm/public/components/shared/ManagedTable/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/ManagedTable/index.tsx @@ -36,7 +36,7 @@ interface Props { hidePerPageOptions?: boolean; initialSort?: { field: keyof T; - direction: 'asc' | 'desc'; + direction: string; }; noItemsMessage?: React.ReactNode; } diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx index 2761b97dcfb08f..87cc4d83a4bf4c 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx @@ -18,7 +18,7 @@ import { i18n } from '@kbn/i18n'; import { Location } from 'history'; import React from 'react'; import { idx } from 'x-pack/plugins/apm/common/idx'; -import { getKibanaHref } from 'x-pack/plugins/apm/public/components/shared/Links/url_helpers'; +import { getRisonHref } from 'x-pack/plugins/apm/public/components/shared/Links/rison_helpers'; import { StringMap } from 'x-pack/plugins/apm/typings/common'; import { Transaction } from 'x-pack/plugins/apm/typings/es_schemas/Transaction'; import { getDiscoverQuery } from '../Links/DiscoverLinks/DiscoverTransactionLink'; @@ -147,7 +147,7 @@ export class TransactionActionMenu extends React.Component { ] .filter(({ target }) => Boolean(target)) .map(({ icon, label, hash, query }, index) => { - const href = getKibanaHref({ + const href = getRisonHref({ location, pathname, hash, @@ -174,7 +174,7 @@ export class TransactionActionMenu extends React.Component { return ( {query => { - const discoverTransactionHref = getKibanaHref({ + const discoverTransactionHref = getRisonHref({ location, pathname: '/app/kibana', hash: '/discover', diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/__snapshots__/TransactionActionMenu.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/__snapshots__/TransactionActionMenu.test.tsx.snap index ac9bfbca57002a..ac393fdb6ad87a 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/__snapshots__/TransactionActionMenu.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/__snapshots__/TransactionActionMenu.test.tsx.snap @@ -20,7 +20,7 @@ exports[`TransactionActionMenu component should render with data 1`] = ` items={ Array [ , , , , , , { @@ -34,12 +32,24 @@ chrome.helpExtension.set(domElement => { ReactDOM.unmountComponentAtNode(domElement); }; }); +const REACT_APP_ROOT_ID = 'react-apm-root'; + +type PromiseResolver = (value?: {} | PromiseLike<{}> | undefined) => void; // @ts-ignore chrome.setRootTemplate(template); const store = configureStore(); +const checkForRoot = (resolve: PromiseResolver) => { + const ready = !!document.getElementById(REACT_APP_ROOT_ID); + if (ready) { + resolve(); + } else { + setTimeout(() => checkForRoot(resolve), 10); + } +}; +const waitForRoot = new Promise(resolve => checkForRoot(resolve)); -initTimepicker(history, store.dispatch).then(() => { +waitForRoot.then(() => { ReactDOM.render( @@ -51,6 +61,6 @@ initTimepicker(history, store.dispatch).then(() => { , - document.getElementById('react-apm-root') + document.getElementById(REACT_APP_ROOT_ID) ); }); diff --git a/x-pack/plugins/apm/public/store/__jest__/rootReducer.test.js b/x-pack/plugins/apm/public/store/__jest__/rootReducer.test.js index 2db1f1f23eb269..2aa4213e69cfea 100644 --- a/x-pack/plugins/apm/public/store/__jest__/rootReducer.test.js +++ b/x-pack/plugins/apm/public/store/__jest__/rootReducer.test.js @@ -6,9 +6,19 @@ import { rootReducer } from '../rootReducer'; +const ISO_DATE_PATTERN = /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)/; + describe('root reducer', () => { it('should return the initial state', () => { - expect(rootReducer(undefined, {})).toEqual({ + const state = rootReducer(undefined, {}); + + expect(state.urlParams.start).toMatch(ISO_DATE_PATTERN); + expect(state.urlParams.end).toMatch(ISO_DATE_PATTERN); + + delete state.urlParams.start; + delete state.urlParams.end; + + expect(state).toEqual({ location: { hash: '', pathname: '', search: '' }, reactReduxRequest: {}, urlParams: {} diff --git a/x-pack/plugins/apm/public/store/selectors/chartSelectors.ts b/x-pack/plugins/apm/public/store/selectors/chartSelectors.ts index 5045eb502f8954..424fe1515819fc 100644 --- a/x-pack/plugins/apm/public/store/selectors/chartSelectors.ts +++ b/x-pack/plugins/apm/public/store/selectors/chartSelectors.ts @@ -28,7 +28,10 @@ import { import { IUrlParams } from '../urlParams'; export const getEmptySerie = memoize( - (start = Date.now() - 3600000, end = Date.now()) => { + ( + start: string | number = Date.now() - 3600000, + end: string | number = Date.now() + ) => { const dates = d3.time .scale() .domain([new Date(start), new Date(end)]) @@ -43,7 +46,7 @@ export const getEmptySerie = memoize( } ]; }, - (start: number, end: number) => [start, end].join('_') + (start: string, end: string) => [start, end].join('_') ); interface IEmptySeries { diff --git a/x-pack/plugins/apm/public/store/urlParams.ts b/x-pack/plugins/apm/public/store/urlParams.ts index 5b73fbe06c64d5..5fb407340037ea 100644 --- a/x-pack/plugins/apm/public/store/urlParams.ts +++ b/x-pack/plugins/apm/public/store/urlParams.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import datemath from '@elastic/datemath'; import { Location } from 'history'; import { compact, pick } from 'lodash'; import { createSelector } from 'reselect'; @@ -18,6 +19,31 @@ import { IReduxState } from './rootReducer'; // ACTION TYPES export const TIMEPICKER_UPDATE = 'TIMEPICKER_UPDATE'; +export const TIMEPICKER_DEFAULTS = { + rangeFrom: 'now-24h', + rangeTo: 'now', + refreshPaused: 'true', + refreshInterval: '0' +}; + +function calculateTimePickerDefaults() { + const parsed = { + from: datemath.parse(TIMEPICKER_DEFAULTS.rangeFrom), + // roundUp: true is required for the quick select relative date values to work properly + to: datemath.parse(TIMEPICKER_DEFAULTS.rangeTo, { roundUp: true }) + }; + + const result: IUrlParams = {}; + if (parsed.from) { + result.start = parsed.from.toISOString(); + } + if (parsed.to) { + result.end = parsed.to.toISOString(); + } + return result; +} + +const INITIAL_STATE: IUrlParams = calculateTimePickerDefaults(); interface LocationAction { type: typeof LOCATION_UPDATE; @@ -25,9 +51,9 @@ interface LocationAction { } interface TimepickerAction { type: typeof TIMEPICKER_UPDATE; - time: { min: number; max: number }; + time: { min: string; max: string }; } -type Action = LocationAction | TimepickerAction; +export type APMAction = LocationAction | TimepickerAction; // "urlParams" contains path and query parameters from the url, that can be easily consumed from // any (container) component with access to the store @@ -37,7 +63,7 @@ type Action = LocationAction | TimepickerAction; // serviceName: opbeans-backend (path param) // transactionType: Brewing%20Bot (path param) // transactionId: 1321 (query param) -export function urlParamsReducer(state = {}, action: Action) { +export function urlParamsReducer(state = INITIAL_STATE, action: APMAction) { switch (action.type) { case LOCATION_UPDATE: { const { @@ -74,7 +100,7 @@ export function urlParamsReducer(state = {}, action: Action) { detailTab: toString(detailTab), flyoutDetailTab: toString(flyoutDetailTab), spanId: toNumber(spanId), - kuery: legacyDecodeURIComponent(kuery as string | undefined), + kuery: legacyDecodeURIComponent(kuery), // path params processorEvent, @@ -86,15 +112,19 @@ export function urlParamsReducer(state = {}, action: Action) { } case TIMEPICKER_UPDATE: - return { ...state, start: action.time.min, end: action.time.max }; + return { + ...state, + start: action.time.min, + end: action.time.max + }; default: return state; } } -function toNumber(value?: string | string[]) { - if (value !== undefined && !Array.isArray(value)) { +export function toNumber(value?: string) { + if (value !== undefined) { return parseInt(value, 10); } } @@ -111,6 +141,10 @@ function toString(str?: string | string[]) { return str; } +export function toBoolean(value?: string) { + return value === 'true'; +} + function getPathAsArray(pathname: string) { return compact(pathname.split('/')); } @@ -147,8 +181,13 @@ function getPathParams(pathname: string) { } } +interface TimeUpdate { + min: string; + max: string; +} + // ACTION CREATORS -export function updateTimePicker(time: string) { +export function updateTimePicker(time: TimeUpdate) { return { type: TIMEPICKER_UPDATE, time }; } @@ -173,14 +212,14 @@ export const getUrlParams = createSelector( export interface IUrlParams { detailTab?: string; - end?: number; + end?: string; errorGroupId?: string; flyoutDetailTab?: string; kuery?: string; serviceName?: string; sortField?: string; - sortDirection?: 'asc' | 'desc'; - start?: number; + sortDirection?: string; + start?: string; traceId?: string; transactionId?: string; transactionName?: string; diff --git a/x-pack/plugins/apm/public/templates/index.html b/x-pack/plugins/apm/public/templates/index.html index e1345cc1a92549..78e0ade3ad6243 100644 --- a/x-pack/plugins/apm/public/templates/index.html +++ b/x-pack/plugins/apm/public/templates/index.html @@ -1,5 +1 @@ -
- -
-
diff --git a/x-pack/plugins/apm/public/utils/testHelpers.ts b/x-pack/plugins/apm/public/utils/testHelpers.tsx similarity index 65% rename from x-pack/plugins/apm/public/utils/testHelpers.ts rename to x-pack/plugins/apm/public/utils/testHelpers.tsx index 93a2659285917c..ef34cae67cf877 100644 --- a/x-pack/plugins/apm/public/utils/testHelpers.ts +++ b/x-pack/plugins/apm/public/utils/testHelpers.tsx @@ -13,8 +13,14 @@ import 'jest-styled-components'; import moment from 'moment'; import { Moment } from 'moment-timezone'; import PropTypes from 'prop-types'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; // @ts-ignore import { createMockStore } from 'redux-test-utils'; +// @ts-ignore +import configureStore from '../store/config/configureStore'; +import { IReduxState } from '../store/rootReducer'; export function toJson(wrapper: ReactWrapper) { return enzymeToJson(wrapper, { @@ -83,3 +89,38 @@ export function mockMoment() { return `1337 minutes ago (mocking ${this.unix()})`; }); } + +// Await this when you need to "flush" promises to immediately resolve or throw in tests +export async function asyncFlush() { + return new Promise(resolve => setTimeout(resolve, 0)); +} + +// Useful for getting the rendered href from any kind of link component +export async function getRenderedHref( + Component: React.FunctionComponent<{}>, + globalState: Partial = {} +) { + const store = configureStore(globalState); + const mounted = mount( + + + + + + ); + + await asyncFlush(); + + return mounted.render().attr('href'); +} + +export function mockNow(date: string) { + const fakeNow = new Date(date).getTime(); + const realDateNow = global.Date.now.bind(global.Date); + + global.Date.now = jest.fn(() => fakeNow); + + return () => { + global.Date.now = realDateNow; + }; +} diff --git a/x-pack/plugins/apm/public/utils/timepicker/index.js b/x-pack/plugins/apm/public/utils/timepicker/index.js deleted file mode 100644 index 25cb1113d74d8b..00000000000000 --- a/x-pack/plugins/apm/public/utils/timepicker/index.js +++ /dev/null @@ -1,84 +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 { uiModules } from 'ui/modules'; -import chrome from 'ui/chrome'; -import 'ui/autoload/all'; -import { updateTimePicker } from '../../store/urlParams'; -import { timefilter, registerTimefilterWithGlobalState } from 'ui/timefilter'; - -let currentInterval; - -// hack to wait for angular template to be ready -const waitForAngularReady = new Promise(resolve => { - const checkInterval = setInterval(() => { - const hasElm = !!document.querySelector('#kibana-angular-template'); - if (hasElm) { - clearInterval(checkInterval); - resolve(); - } - }, 10); -}); - -export function initTimepicker(history, dispatch) { - return new Promise(resolve => { - // default the timepicker to the last 24 hours - chrome.getUiSettingsClient().overrideLocalDefault( - 'timepicker:timeDefaults', - JSON.stringify({ - from: 'now-24h', - to: 'now', - mode: 'quick' - }) - ); - - uiModules - .get('app/apm', []) - .controller('TimePickerController', ($scope, globalState, $rootScope) => { - history.listen(() => { - updateRefreshRate(dispatch); - globalState.fetch(); // ensure global state is updated when url changes - }); - - // ensure that redux is notified after timefilter has updated - $scope.$listen(timefilter, 'timeUpdate', () => - dispatch(updateTimePickerAction()) - ); - - // ensure that timepicker updates when global state changes - registerTimefilterWithGlobalState(globalState, $rootScope); - - timefilter.enableTimeRangeSelector(); - timefilter.enableAutoRefreshSelector(); - - dispatch(updateTimePickerAction()); - updateRefreshRate(dispatch); - - Promise.all([waitForAngularReady]).then(resolve); - }); - }); -} - -function updateTimePickerAction() { - return updateTimePicker({ - min: timefilter.getBounds().min.toISOString(), - max: timefilter.getBounds().max.toISOString() - }); -} - -function updateRefreshRate(dispatch) { - const refreshInterval = timefilter.getRefreshInterval().value; - if (currentInterval) { - clearInterval(currentInterval); - } - - if (refreshInterval > 0 && !timefilter.getRefreshInterval().pause) { - currentInterval = setInterval( - () => dispatch(updateTimePickerAction()), - refreshInterval - ); - } -} diff --git a/x-pack/plugins/apm/server/lib/errors/get_error_groups.ts b/x-pack/plugins/apm/server/lib/errors/get_error_groups.ts index 10b5e7775e18ac..025c9004b5a1ff 100644 --- a/x-pack/plugins/apm/server/lib/errors/get_error_groups.ts +++ b/x-pack/plugins/apm/server/lib/errors/get_error_groups.ts @@ -38,7 +38,7 @@ export async function getErrorGroups({ }: { serviceName: string; sortField: string; - sortDirection: 'desc' | 'asc'; + sortDirection: string; setup: Setup; }): Promise { const { start, end, esFilterQuery, client, config } = setup; diff --git a/x-pack/plugins/apm/server/routes/errors.ts b/x-pack/plugins/apm/server/routes/errors.ts index 0804acbe75679a..7db8ae4e6d84da 100644 --- a/x-pack/plugins/apm/server/routes/errors.ts +++ b/x-pack/plugins/apm/server/routes/errors.ts @@ -38,7 +38,7 @@ export function initErrorsApi(server: Server) { const { serviceName } = req.params; const { sortField, sortDirection } = req.query as { sortField: string; - sortDirection: 'desc' | 'asc'; + sortDirection: string; }; return getErrorGroups({