From ed2874bf5462bfc23a474d4b6bb5d6bb3e11aa53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Fri, 22 Mar 2019 15:56:57 +0100 Subject: [PATCH] [APM] Fix Datepicker double loading and move date parsing to urlParams (#33560) --- .../MachineLearningFlyout.tsx | 9 +- .../ServiceIntegrations/index.tsx | 4 +- .../ServiceIntegrations/view.tsx | 6 +- .../components/app/ServiceDetails/view.tsx | 59 +++--- .../shared/FilterBar/DatePicker.tsx | 102 +++------ .../FilterBar/__test__/DatePicker.test.tsx | 193 ++++++++---------- .../public/components/shared/KueryBar/view.js | 10 +- ...ootReducer.test.js => rootReducer.test.ts} | 10 +- .../public/store/__jest__/urlParams.test.js | 69 ------- .../public/store/__jest__/urlParams.test.tsx | 113 ++++++++++ x-pack/plugins/apm/public/store/urlParams.ts | 93 +++++---- .../plugins/apm/public/utils/testHelpers.tsx | 8 +- 12 files changed, 347 insertions(+), 329 deletions(-) rename x-pack/plugins/apm/public/store/__jest__/{rootReducer.test.js => rootReducer.test.ts} (60%) delete mode 100644 x-pack/plugins/apm/public/store/__jest__/urlParams.test.js create mode 100644 x-pack/plugins/apm/public/store/__jest__/urlParams.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout.tsx index 0a4561a194a5..aea1ae113c53 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout.tsx @@ -54,10 +54,17 @@ export class MachineLearningFlyout extends Component { hasMLJob: false, selectedTransactionType: this.props.urlParams.transactionType }; + public willUnmount = false; + + public componentWillUnmount() { + this.willUnmount = true; + } public async componentDidMount() { const indexPattern = await getAPMIndexPattern(); - this.setState({ hasIndexPattern: !!indexPattern }); + if (!this.willUnmount) { + this.setState({ hasIndexPattern: !!indexPattern }); + } } // TODO: This should use `getDerivedStateFromProps` diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/index.tsx index 7d51ba9b1184..3e6b8a6f2bac 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/index.tsx @@ -5,15 +5,13 @@ */ import { connect } from 'react-redux'; -import { getServiceDetails } from 'x-pack/plugins/apm/public/store/reactReduxRequest/serviceDetails'; import { IReduxState } from 'x-pack/plugins/apm/public/store/rootReducer'; import { selectIsMLAvailable } from 'x-pack/plugins/apm/public/store/selectors/license'; import { ServiceIntegrationsView } from './view'; function mapStateToProps(state = {} as IReduxState) { return { - mlAvailable: selectIsMLAvailable(state), - serviceDetails: getServiceDetails(state).data + mlAvailable: selectIsMLAvailable(state) }; } diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/view.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/view.tsx index 8e701679fc71..c7e60934ddcd 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/view.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/view.tsx @@ -22,9 +22,7 @@ import { WatcherFlyout } from './WatcherFlyout'; export interface ServiceIntegrationProps { mlAvailable: boolean; location: Location; - serviceDetails: { - types: string[]; - }; + transactionTypes: string[]; urlParams: IUrlParams; } interface ServiceIntegrationState { @@ -171,7 +169,7 @@ export class ServiceIntegrationsView extends React.Component< isOpen={this.state.activeFlyout === 'ML'} onClose={this.closeFlyouts} urlParams={this.props.urlParams} - serviceTransactionTypes={this.props.serviceDetails.types} + serviceTransactionTypes={this.props.transactionTypes} /> { public render() { const { urlParams, location } = this.props; return ( - - - - -

{urlParams.serviceName}

-
-
- - - -
+ { + return ( + + + + +

{urlParams.serviceName}

+
+
+ + + +
- + - + - ( - - )} - /> -
+ + + ); + }} + /> ); } } diff --git a/x-pack/plugins/apm/public/components/shared/FilterBar/DatePicker.tsx b/x-pack/plugins/apm/public/components/shared/FilterBar/DatePicker.tsx index e08149190a71..32c8436e363a 100644 --- a/x-pack/plugins/apm/public/components/shared/FilterBar/DatePicker.tsx +++ b/x-pack/plugins/apm/public/components/shared/FilterBar/DatePicker.tsx @@ -4,94 +4,53 @@ * 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 { IReduxState } from '../../../store/rootReducer'; import { - TIMEPICKER_DEFAULTS, - toBoolean, - toNumber, - updateTimePicker + getUrlParams, + IUrlParams, + refreshTimeRange } from '../../../store/urlParams'; import { fromQuery, toQuery } from '../Links/url_helpers'; export interface DatePickerProps extends RouteComponentProps { - dispatchUpdateTimePicker: typeof updateTimePicker; + dispatchRefreshTimeRange: typeof refreshTimeRange; + urlParams: IUrlParams; } export 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(); - } - - public componentDidUpdate() { - this.dispatchTimeRangeUpdate(); - } - - 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 updateUrl(nextQuery: { rangeFrom?: string; rangeTo?: string; refreshPaused?: boolean; refreshInterval?: number; }) { - const currentQuery = toQuery(this.props.location.search); - const nextSearch = fromQuery({ ...currentQuery, ...nextQuery }); - - // Compare the encoded versions of current and next search string, and if they're the same, - // use replace instead of push to prevent an unnecessary stack entry which breaks the back button. - const currentSearch = fromQuery(currentQuery); - const { push, replace } = this.props.history; - const update = currentSearch === nextSearch ? replace : push; - - update({ ...this.props.location, search: nextSearch }); + const { history, location } = this.props; + history.push({ + ...location, + search: fromQuery({ ...toQuery(location.search), ...nextQuery }) + }); } - public handleRefreshChange: EuiSuperDatePickerProps['onRefreshChange'] = ({ + public onRefreshChange: EuiSuperDatePickerProps['onRefreshChange'] = ({ isPaused, refreshInterval }) => { - this.updateUrl({ - refreshPaused: isPaused, - refreshInterval - }); + this.updateUrl({ refreshPaused: isPaused, refreshInterval }); }; - public handleTimeChange = (options: { start: string; end: string }) => { - this.updateUrl({ rangeFrom: options.start, rangeTo: options.end }); + public onTimeChange: EuiSuperDatePickerProps['onTimeChange'] = ({ + start, + end + }) => { + this.updateUrl({ rangeFrom: start, rangeTo: end }); + }; + + public onRefresh: EuiSuperDatePickerProps['onRefresh'] = ({ start, end }) => { + this.props.dispatchRefreshTimeRange({ rangeFrom: start, rangeTo: end }); }; public render() { @@ -100,26 +59,31 @@ export class DatePickerComponent extends React.Component { rangeTo, refreshPaused, refreshInterval - } = this.getParamsFromSearch(this.props.location.search); + } = this.props.urlParams; return ( ); } } +const mapStateToProps = (state: IReduxState) => ({ + urlParams: getUrlParams(state) +}); +const mapDispatchToProps = { dispatchRefreshTimeRange: refreshTimeRange }; + const DatePicker = withRouter( connect( - null, - { dispatchUpdateTimePicker: updateTimePicker } + mapStateToProps, + mapDispatchToProps )(DatePickerComponent) ); 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 index c32174aaa1f1..2ff953e1fe53 100644 --- 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 @@ -5,144 +5,120 @@ */ import { mount, shallow } from 'enzyme'; -import { History, Location } from 'history'; import React from 'react'; import { Provider } from 'react-redux'; -import { MemoryRouter, RouteComponentProps } from 'react-router-dom'; +import { MemoryRouter } from 'react-router-dom'; +import { Store } from 'redux'; // @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, DatePickerComponent } from '../DatePicker'; -function mountPicker(search?: string) { - const store = configureStore(); - let path = '/whatever'; - if (search) { - path += `?${search}`; - } - const mounted = mount( +function mountPicker(initialState = {}) { + const store = configureStore(initialState); + const wrapper = mount( - + ); - return { mounted, store }; + return { wrapper, 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('url updates', () => { function setupTest() { - const location = { search: '?rangeFrom=now-15m&rangeTo=now' } as Location; - const history = { - push: jest.fn() as History['push'], - replace: jest.fn() as History['replace'] - } as History; - const routerProps = { location, history } as RouteComponentProps; - const actionMock = jest.fn(); - const props = { ...routerProps, dispatchUpdateTimePicker: actionMock }; + const routerProps = { + location: { search: '' }, + history: { push: jest.fn() } + } as any; + const wrapper = shallow( - + ); - return { history, wrapper }; + return { history: routerProps.history, wrapper }; } it('should push an entry to the stack for each change', () => { const { history, wrapper } = setupTest(); - wrapper.instance().updateUrl({ rangeFrom: 'now-20m', rangeTo: 'now' }); - - expect(history.push).toHaveBeenCalledTimes(1); - expect(history.replace).not.toHaveBeenCalled(); - }); - - it('should replace the last entry in the stack if the URL is the same', () => { - const { history, wrapper } = setupTest(); - - wrapper.instance().updateUrl({ rangeFrom: 'now-15m', rangeTo: 'now' }); - - expect(history.replace).toHaveBeenCalledTimes(1); - expect(history.push).not.toHaveBeenCalled(); + expect(history.push).toHaveBeenCalledWith({ + search: 'rangeFrom=now-20m&rangeTo=now' + }); }); }); + const tick = () => new Promise(resolve => setImmediate(resolve, 0)); + describe('refresh cycle', () => { + let nowSpy: jest.Mock; beforeEach(() => { + nowSpy = mockNow('2010'); jest.useFakeTimers(); }); afterEach(() => { + nowSpy.mockRestore(); 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); + describe('when refresh is not paused', () => { + let listener: jest.Mock; + let store: Store; + beforeEach(async () => { + const obj = mountPicker({ + urlParams: { + rangeFrom: 'now-15m', + rangeTo: 'now', + refreshPaused: false, + refreshInterval: 200 + } + }); + store = obj.store; - jest.advanceTimersByTime(200); - await new Promise(resolve => setImmediate(resolve, 0)); - jest.advanceTimersByTime(200); - await new Promise(resolve => setImmediate(resolve, 0)); - jest.advanceTimersByTime(200); - await new Promise(resolve => setImmediate(resolve, 0)); + listener = jest.fn(); + store.subscribe(listener); - expect(listener).toHaveBeenCalledTimes(3); + jest.advanceTimersByTime(200); + await tick(); + jest.advanceTimersByTime(200); + await tick(); + jest.advanceTimersByTime(200); + await tick(); + }); + + it('should dispatch every refresh interval', async () => { + expect(listener).toHaveBeenCalledTimes(3); + }); + + it('should update the store with the new date range', () => { + expect(store.getState().urlParams).toEqual({ + end: '2010-01-01T00:00:00.000Z', + rangeFrom: 'now-15m', + rangeTo: 'now', + refreshInterval: 200, + refreshPaused: false, + start: '2009-12-31T23:45:00.000Z' + }); + }); }); it('should not refresh when paused', () => { - const { store } = mountPicker( - 'rangeFrom=now-15m&rangeTo=now&refreshPaused=true&refreshInterval=200' - ); + const { store } = mountPicker({ + urlParams: { + rangeFrom: 'now-15m', + rangeTo: 'now', + refreshPaused: true, + refreshInterval: 200 + } + }); + const listener = jest.fn(); store.subscribe(listener); jest.advanceTimersByTime(1100); @@ -151,9 +127,14 @@ describe('DatePicker', () => { }); it('should be paused by default', () => { - const { store } = mountPicker( - 'rangeFrom=now-15m&rangeTo=now&refreshInterval=200' - ); + const { store } = mountPicker({ + urlParams: { + rangeFrom: 'now-15m', + rangeTo: 'now', + refreshInterval: 200 + } + }); + const listener = jest.fn(); store.subscribe(listener); jest.advanceTimersByTime(1100); @@ -162,12 +143,18 @@ describe('DatePicker', () => { }); it('should not attempt refreshes after unmounting', () => { - const { store, mounted } = mountPicker( - 'rangeFrom=now-15m&rangeTo=now&refreshPaused=false&refreshInterval=200' - ); + const { store, wrapper } = mountPicker({ + urlParams: { + rangeFrom: 'now-15m', + rangeTo: 'now', + refreshPaused: false, + refreshInterval: 200 + } + }); + const listener = jest.fn(); store.subscribe(listener); - mounted.unmount(); + wrapper.unmount(); jest.advanceTimersByTime(1100); expect(listener).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/apm/public/components/shared/KueryBar/view.js b/x-pack/plugins/apm/public/components/shared/KueryBar/view.js index a2592968b4a6..f5390b7384d8 100644 --- a/x-pack/plugins/apm/public/components/shared/KueryBar/view.js +++ b/x-pack/plugins/apm/public/components/shared/KueryBar/view.js @@ -39,9 +39,17 @@ class KueryBarView extends Component { isLoadingSuggestions: false }; + willUnmount = false; + + componentWillUnmount() { + this.willUnmount = true; + } + async componentDidMount() { const indexPattern = await getAPMIndexPatternForKuery(); - this.setState({ indexPattern, isLoadingIndexPattern: false }); + if (!this.willUnmount) { + this.setState({ indexPattern, isLoadingIndexPattern: false }); + } } onChange = async (inputValue, selectionStart) => { diff --git a/x-pack/plugins/apm/public/store/__jest__/rootReducer.test.js b/x-pack/plugins/apm/public/store/__jest__/rootReducer.test.ts similarity index 60% rename from x-pack/plugins/apm/public/store/__jest__/rootReducer.test.js rename to x-pack/plugins/apm/public/store/__jest__/rootReducer.test.ts index 2aa4213e69cf..0eb91fc267b4 100644 --- a/x-pack/plugins/apm/public/store/__jest__/rootReducer.test.js +++ b/x-pack/plugins/apm/public/store/__jest__/rootReducer.test.ts @@ -6,17 +6,9 @@ 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', () => { - 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; + const state = rootReducer(undefined, {} as any); expect(state).toEqual({ location: { hash: '', pathname: '', search: '' }, diff --git a/x-pack/plugins/apm/public/store/__jest__/urlParams.test.js b/x-pack/plugins/apm/public/store/__jest__/urlParams.test.js deleted file mode 100644 index c55e8e66724c..000000000000 --- a/x-pack/plugins/apm/public/store/__jest__/urlParams.test.js +++ /dev/null @@ -1,69 +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 { urlParamsReducer, updateTimePicker } from '../urlParams'; -import { LOCATION_UPDATE } from '../location'; - -describe('urlParams', () => { - it('should handle LOCATION_UPDATE for transactions section', () => { - const state = urlParamsReducer( - {}, - { - type: LOCATION_UPDATE, - location: { - pathname: - 'myServiceName/transactions/myTransactionType/myTransactionName/b/c', - search: '?transactionId=myTransactionId&detailTab=request&spanId=10' - } - } - ); - - expect(state).toEqual({ - page: 0, - serviceName: 'myServiceName', - spanId: 10, - processorEvent: 'transaction', - transactionId: 'myTransactionId', - transactionName: 'myTransactionName', - detailTab: 'request', - transactionType: 'myTransactionType' - }); - }); - - it('should handle LOCATION_UPDATE for error section', () => { - const state = urlParamsReducer( - {}, - { - type: LOCATION_UPDATE, - location: { - pathname: 'myServiceName/errors/myErrorGroupId', - search: '?detailTab=request&transactionId=myTransactionId' - } - } - ); - - expect(state).toEqual( - expect.objectContaining({ - serviceName: 'myServiceName', - errorGroupId: 'myErrorGroupId', - detailTab: 'request', - transactionId: 'myTransactionId' - }) - ); - }); - - it('should handle TIMEPICKER_UPDATE', () => { - const state = urlParamsReducer( - {}, - updateTimePicker({ - min: 'minTime', - max: 'maxTime' - }) - ); - - expect(state).toEqual({ end: 'maxTime', start: 'minTime' }); - }); -}); diff --git a/x-pack/plugins/apm/public/store/__jest__/urlParams.test.tsx b/x-pack/plugins/apm/public/store/__jest__/urlParams.test.tsx new file mode 100644 index 000000000000..75c893ec221f --- /dev/null +++ b/x-pack/plugins/apm/public/store/__jest__/urlParams.test.tsx @@ -0,0 +1,113 @@ +/* + * 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 { mockNow } from '../../utils/testHelpers'; +import { updateLocation } from '../location'; +import { APMAction, refreshTimeRange, urlParamsReducer } from '../urlParams'; + +describe('urlParams', () => { + beforeEach(() => { + mockNow('2010'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should parse "last 15 minutes"', () => { + const action = updateLocation({ + pathname: '', + search: '?rangeFrom=now-15m&rangeTo=now' + } as Location) as APMAction; + const { start, end } = urlParamsReducer({}, action); + + expect({ start, end }).toEqual({ + start: '2009-12-31T23:45:00.000Z', + end: '2010-01-01T00:00:00.000Z' + }); + }); + + it('should parse "last 7 days"', () => { + const action = updateLocation({ + pathname: '', + search: '?rangeFrom=now-7d&rangeTo=now' + } as Location) as APMAction; + const { start, end } = urlParamsReducer({}, action); + + expect({ start, end }).toEqual({ + start: '2009-12-25T00:00:00.000Z', + end: '2010-01-01T00:00:00.000Z' + }); + }); + + it('should parse absolute dates', () => { + const action = updateLocation({ + pathname: '', + search: + '?rangeFrom=2019-02-03T10:00:00.000Z&rangeTo=2019-02-10T16:30:00.000Z' + } as Location) as APMAction; + const { start, end } = urlParamsReducer({}, action); + + expect({ start, end }).toEqual({ + start: '2019-02-03T10:00:00.000Z', + end: '2019-02-10T16:30:00.000Z' + }); + }); + + it('should handle LOCATION_UPDATE for transactions section', () => { + const action = updateLocation({ + pathname: + 'myServiceName/transactions/myTransactionType/myTransactionName/b/c', + search: '?transactionId=myTransactionId&detailTab=request&spanId=10' + } as Location) as APMAction; + const state = urlParamsReducer({}, action); + + expect(state).toEqual({ + detailTab: 'request', + end: '2010-01-01T00:00:00.000Z', + page: 0, + processorEvent: 'transaction', + rangeFrom: 'now-24h', + rangeTo: 'now', + refreshInterval: 0, + refreshPaused: true, + serviceName: 'myServiceName', + spanId: 10, + start: '2009-12-31T00:00:00.000Z', + transactionId: 'myTransactionId', + transactionName: 'myTransactionName', + transactionType: 'myTransactionType' + }); + }); + + it('should handle LOCATION_UPDATE for error section', () => { + const action = updateLocation({ + pathname: 'myServiceName/errors/myErrorGroupId', + search: '?detailTab=request&transactionId=myTransactionId' + } as Location) as APMAction; + const state = urlParamsReducer({}, action); + + expect(state).toEqual( + expect.objectContaining({ + serviceName: 'myServiceName', + errorGroupId: 'myErrorGroupId', + detailTab: 'request', + transactionId: 'myTransactionId' + }) + ); + }); + + it('should handle refreshTimeRange action', () => { + const action = refreshTimeRange({ rangeFrom: 'now', rangeTo: 'now-15m' }); + const state = urlParamsReducer({}, action); + + expect(state).toEqual({ + start: '2010-01-01T00:00:00.000Z', + end: '2009-12-31T23:45:00.000Z' + }); + }); +}); diff --git a/x-pack/plugins/apm/public/store/urlParams.ts b/x-pack/plugins/apm/public/store/urlParams.ts index 5fb407340037..270f3c690624 100644 --- a/x-pack/plugins/apm/public/store/urlParams.ts +++ b/x-pack/plugins/apm/public/store/urlParams.ts @@ -18,7 +18,7 @@ import { getDefaultDistributionSample } from './reactReduxRequest/transactionDis import { IReduxState } from './rootReducer'; // ACTION TYPES -export const TIMEPICKER_UPDATE = 'TIMEPICKER_UPDATE'; +export const TIME_RANGE_REFRESH = 'TIME_RANGE_REFRESH'; export const TIMEPICKER_DEFAULTS = { rangeFrom: 'now-24h', rangeTo: 'now', @@ -26,34 +26,43 @@ export const TIMEPICKER_DEFAULTS = { 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; +interface TimeRange { + rangeFrom: string; + rangeTo: string; } -const INITIAL_STATE: IUrlParams = calculateTimePickerDefaults(); - interface LocationAction { type: typeof LOCATION_UPDATE; location: Location; } -interface TimepickerAction { - type: typeof TIMEPICKER_UPDATE; - time: { min: string; max: string }; +interface TimeRangeRefreshAction { + type: typeof TIME_RANGE_REFRESH; + time: TimeRange; +} +export type APMAction = LocationAction | TimeRangeRefreshAction; + +function getParsedDate(rawDate?: string, opts = {}) { + if (rawDate) { + const parsed = datemath.parse(rawDate, opts); + if (parsed) { + return parsed.toISOString(); + } + } +} + +function getStart(prevState: IUrlParams, rangeFrom?: string) { + if (prevState.rangeFrom !== rangeFrom) { + return getParsedDate(rangeFrom); + } + return prevState.start; +} + +function getEnd(prevState: IUrlParams, rangeTo?: string) { + if (prevState.rangeTo !== rangeTo) { + return getParsedDate(rangeTo, { roundUp: true }); + } + return prevState.end; } -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 @@ -63,7 +72,10 @@ export type APMAction = LocationAction | TimepickerAction; // serviceName: opbeans-backend (path param) // transactionType: Brewing%20Bot (path param) // transactionId: 1321 (query param) -export function urlParamsReducer(state = INITIAL_STATE, action: APMAction) { +export function urlParamsReducer( + state: IUrlParams = {}, + action: APMAction +): IUrlParams { switch (action.type) { case LOCATION_UPDATE: { const { @@ -84,12 +96,24 @@ export function urlParamsReducer(state = INITIAL_STATE, action: APMAction) { page, sortDirection, sortField, - kuery + kuery, + refreshPaused = TIMEPICKER_DEFAULTS.refreshPaused, + refreshInterval = TIMEPICKER_DEFAULTS.refreshInterval, + rangeFrom = TIMEPICKER_DEFAULTS.rangeFrom, + rangeTo = TIMEPICKER_DEFAULTS.rangeTo } = toQuery(action.location.search); return removeUndefinedProps({ ...state, + // date params + start: getStart(state, rangeFrom), + end: getEnd(state, rangeTo), + rangeFrom, + rangeTo, + refreshPaused: toBoolean(refreshPaused), + refreshInterval: toNumber(refreshInterval), + // query params sortDirection, sortField, @@ -111,11 +135,11 @@ export function urlParamsReducer(state = INITIAL_STATE, action: APMAction) { }); } - case TIMEPICKER_UPDATE: + case TIME_RANGE_REFRESH: return { ...state, - start: action.time.min, - end: action.time.max + start: getParsedDate(action.time.rangeFrom), + end: getParsedDate(action.time.rangeTo) }; default: @@ -181,14 +205,9 @@ function getPathParams(pathname: string) { } } -interface TimeUpdate { - min: string; - max: string; -} - // ACTION CREATORS -export function updateTimePicker(time: TimeUpdate) { - return { type: TIMEPICKER_UPDATE, time }; +export function refreshTimeRange(time: TimeRange): TimeRangeRefreshAction { + return { type: TIME_RANGE_REFRESH, time }; } // Selectors @@ -216,9 +235,13 @@ export interface IUrlParams { errorGroupId?: string; flyoutDetailTab?: string; kuery?: string; + rangeFrom?: string; + rangeTo?: string; + refreshInterval?: number; + refreshPaused?: boolean; serviceName?: string; - sortField?: string; sortDirection?: string; + sortField?: string; start?: string; traceId?: string; transactionId?: string; diff --git a/x-pack/plugins/apm/public/utils/testHelpers.tsx b/x-pack/plugins/apm/public/utils/testHelpers.tsx index ef34cae67cf8..845dc7b19dd1 100644 --- a/x-pack/plugins/apm/public/utils/testHelpers.tsx +++ b/x-pack/plugins/apm/public/utils/testHelpers.tsx @@ -116,11 +116,5 @@ export async function getRenderedHref( 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; - }; + return jest.spyOn(Date, 'now').mockReturnValue(fakeNow); }