diff --git a/packages/kbn-typed-react-router-config/src/create_router.ts b/packages/kbn-typed-react-router-config/src/create_router.ts index 13f09e7546de5..77c2bba14e85a 100644 --- a/packages/kbn-typed-react-router-config/src/create_router.ts +++ b/packages/kbn-typed-react-router-config/src/create_router.ts @@ -20,7 +20,7 @@ import type { deepExactRt as deepExactRtTyped, mergeRt as mergeRtTyped } from '@ import { deepExactRt as deepExactRtNonTyped } from '@kbn/io-ts-utils/target_node/deep_exact_rt'; // @ts-expect-error import { mergeRt as mergeRtNonTyped } from '@kbn/io-ts-utils/target_node/merge_rt'; -import { Route, Router } from './types'; +import { FlattenRoutesOf, Route, Router } from './types'; const deepExactRt: typeof deepExactRtTyped = deepExactRtNonTyped; const mergeRt: typeof mergeRtTyped = mergeRtNonTyped; @@ -51,6 +51,20 @@ export function createRouter(routes: TRoutes): Router { + return routesByReactRouterConfig.get(match.route)!; + }); + + return matchedRoutes; + } + const matchRoutes = (...args: any[]) => { let optional: boolean = false; @@ -142,15 +156,7 @@ export function createRouter(routes: TRoutes): Router { - return routesByReactRouterConfig.get(match.route)!; - }); + const matchedRoutes = getRoutesToMatch(path); const validationType = mergeRt( ...(compact( @@ -200,5 +206,8 @@ export function createRouter(routes: TRoutes): Router { return reactRouterConfigsByRoute.get(route)!.path as string; }, + getRoutesToMatch: (path: string) => { + return getRoutesToMatch(path) as unknown as FlattenRoutesOf; + }, }; } diff --git a/packages/kbn-typed-react-router-config/src/outlet.tsx b/packages/kbn-typed-react-router-config/src/outlet.tsx index 696085489abee..9af7b8bdd6422 100644 --- a/packages/kbn-typed-react-router-config/src/outlet.tsx +++ b/packages/kbn-typed-react-router-config/src/outlet.tsx @@ -5,9 +5,24 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { useCurrentRoute } from './use_current_route'; +import React, { createContext, useContext } from 'react'; + +const OutletContext = createContext<{ element?: React.ReactElement } | undefined>(undefined); + +export function OutletContextProvider({ + element, + children, +}: { + element: React.ReactElement; + children: React.ReactNode; +}) { + return {children}; +} export function Outlet() { - const { element } = useCurrentRoute(); - return element; + const outletContext = useContext(OutletContext); + if (!outletContext) { + throw new Error('Outlet context not available'); + } + return outletContext.element || null; } diff --git a/packages/kbn-typed-react-router-config/src/router_provider.tsx b/packages/kbn-typed-react-router-config/src/router_provider.tsx index d2512ba8fe426..657df9e9fc592 100644 --- a/packages/kbn-typed-react-router-config/src/router_provider.tsx +++ b/packages/kbn-typed-react-router-config/src/router_provider.tsx @@ -18,7 +18,7 @@ export function RouterProvider({ }: { router: Router; history: History; - children: React.ReactElement; + children: React.ReactNode; }) { return ( diff --git a/packages/kbn-typed-react-router-config/src/types/index.ts b/packages/kbn-typed-react-router-config/src/types/index.ts index 9c19c8dca323b..c1ae5afd816ee 100644 --- a/packages/kbn-typed-react-router-config/src/types/index.ts +++ b/packages/kbn-typed-react-router-config/src/types/index.ts @@ -147,6 +147,7 @@ interface PlainRoute { children?: PlainRoute[]; params?: t.Type; defaults?: Record>; + pre?: ReactElement; } interface ReadonlyPlainRoute { @@ -155,6 +156,7 @@ interface ReadonlyPlainRoute { readonly children?: readonly ReadonlyPlainRoute[]; readonly params?: t.Type; readonly defaults?: Record>; + pre?: ReactElement; } export type Route = PlainRoute | ReadonlyPlainRoute; @@ -209,6 +211,10 @@ export type TypeAsArgs = keyof TObject extends never ? [TObject] | [] : [TObject]; +export type FlattenRoutesOf = Array< + Omit>, 'parents'> +>; + export interface Router { matchRoutes>( path: TPath, @@ -245,6 +251,7 @@ export interface Router { ...args: TypeAsArgs> ): string; getRoutePath(route: Route): string; + getRoutesToMatch(path: string): FlattenRoutesOf; } type AppendPath< @@ -256,23 +263,21 @@ type MaybeUnion, U extends Record> = [key in keyof U]: key extends keyof T ? T[key] | U[key] : U[key]; }; -type MapRoute = TRoute extends Route - ? MaybeUnion< - { - [key in TRoute['path']]: TRoute & { parents: TParents }; - }, - TRoute extends { children: Route[] } - ? MaybeUnion< - MapRoutes, - { - [key in AppendPath]: ValuesType< - MapRoutes - >; - } - > - : {} - > - : {}; +type MapRoute = MaybeUnion< + { + [key in TRoute['path']]: TRoute & { parents: TParents }; + }, + TRoute extends { children: Route[] } + ? MaybeUnion< + MapRoutes, + { + [key in AppendPath]: ValuesType< + MapRoutes + >; + } + > + : {} +>; type MapRoutes = TRoutes extends [Route] ? MapRoute diff --git a/packages/kbn-typed-react-router-config/src/use_current_route.tsx b/packages/kbn-typed-react-router-config/src/use_current_route.tsx index 9227b119107b3..a36e6f4ec9c8e 100644 --- a/packages/kbn-typed-react-router-config/src/use_current_route.tsx +++ b/packages/kbn-typed-react-router-config/src/use_current_route.tsx @@ -6,6 +6,7 @@ * Side Public License, v 1. */ import React, { createContext, useContext } from 'react'; +import { OutletContextProvider } from './outlet'; import { RouteMatch } from './types'; const CurrentRouteContext = createContext< @@ -23,7 +24,7 @@ export const CurrentRouteContextProvider = ({ }) => { return ( - {children} + {children} ); }; diff --git a/packages/kbn-typed-react-router-config/src/use_match_routes.ts b/packages/kbn-typed-react-router-config/src/use_match_routes.ts index b818ff06e9ae6..12c5af1f4412d 100644 --- a/packages/kbn-typed-react-router-config/src/use_match_routes.ts +++ b/packages/kbn-typed-react-router-config/src/use_match_routes.ts @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import { useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import { RouteMatch } from './types'; import { useRouter } from './use_router'; @@ -14,7 +14,11 @@ export function useMatchRoutes(path?: string): RouteMatch[] { const router = useRouter(); const location = useLocation(); - return typeof path === 'undefined' - ? router.matchRoutes(location) - : router.matchRoutes(path as never, location); + const routeMatches = useMemo(() => { + return typeof path === 'undefined' + ? router.matchRoutes(location) + : router.matchRoutes(path as never, location); + }, [path, router, location]); + + return routeMatches; } diff --git a/packages/kbn-typed-react-router-config/src/use_router.tsx b/packages/kbn-typed-react-router-config/src/use_router.tsx index b54530ed0fbdb..c78e85650f26d 100644 --- a/packages/kbn-typed-react-router-config/src/use_router.tsx +++ b/packages/kbn-typed-react-router-config/src/use_router.tsx @@ -16,7 +16,7 @@ export const RouterContextProvider = ({ children, }: { router: Router; - children: React.ReactElement; + children: React.ReactNode; }) => {children}; export function useRouter(): Router { diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx index 80c50aac13f0e..2de6f1d063522 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx @@ -11,13 +11,13 @@ import { EuiFlexGroup, EuiTitle, EuiFlexItem } from '@elastic/eui'; import { RumOverview } from '../RumDashboard'; import { CsmSharedContextProvider } from './CsmSharedContext'; import { WebApplicationSelect } from './Panels/WebApplicationSelect'; -import { DatePicker } from '../../shared/DatePicker'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; import { UxEnvironmentFilter } from '../../shared/EnvironmentFilter'; import { UserPercentile } from './UserPercentile'; import { useBreakpoints } from '../../../hooks/use_breakpoints'; import { KibanaPageTemplateProps } from '../../../../../../../src/plugins/kibana_react/public'; import { useHasRumData } from './hooks/useHasRumData'; +import { RumDatePicker } from './rum_datepicker'; import { EmptyStateLoading } from './empty_state_loading'; export const DASHBOARD_LABEL = i18n.translate('xpack.apm.ux.title', { @@ -88,7 +88,7 @@ function PageHeader() { - + diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/rum_datepicker/index.test.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/rum_datepicker/index.test.tsx new file mode 100644 index 0000000000000..afb0e9ef37d51 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/rum_datepicker/index.test.tsx @@ -0,0 +1,206 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiSuperDatePicker } from '@elastic/eui'; +import { waitFor } from '@testing-library/react'; +import { mount } from 'enzyme'; +import { createMemoryHistory, MemoryHistory } from 'history'; +import React, { ReactNode } from 'react'; +import qs from 'query-string'; +import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context'; +import { UrlParamsContext } from '../../../../context/url_params_context/url_params_context'; +import { RumDatePicker } from './'; +import { useLocation } from 'react-router-dom'; + +let history: MemoryHistory; +let mockHistoryPush: jest.SpyInstance; +let mockHistoryReplace: jest.SpyInstance; + +const mockRefreshTimeRange = jest.fn(); + +function MockUrlParamsProvider({ children }: { children: ReactNode }) { + const location = useLocation(); + + const urlParams = qs.parse(location.search, { + parseBooleans: true, + parseNumbers: true, + }); + + return ( + + ); +} + +function mountDatePicker( + params: { + rangeFrom?: string; + rangeTo?: string; + refreshPaused?: boolean; + refreshInterval?: number; + } = {} +) { + const setTimeSpy = jest.fn(); + const getTimeSpy = jest.fn().mockReturnValue({}); + + history = createMemoryHistory({ + initialEntries: [`/?${qs.stringify(params)}`], + }); + + jest.spyOn(console, 'error').mockImplementation(() => null); + mockHistoryPush = jest.spyOn(history, 'push'); + mockHistoryReplace = jest.spyOn(history, 'replace'); + + const wrapper = mount( + + + + + + ); + + return { wrapper, setTimeSpy, getTimeSpy }; +} + +describe('RumDatePicker', () => { + afterAll(() => { + jest.restoreAllMocks(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('sets default query params in the URL', () => { + mountDatePicker(); + expect(mockHistoryReplace).toHaveBeenCalledTimes(1); + expect(mockHistoryReplace).toHaveBeenCalledWith( + expect.objectContaining({ + search: 'rangeFrom=now-15m&rangeTo=now', + }) + ); + }); + + it('adds missing `rangeFrom` to url', () => { + mountDatePicker({ rangeTo: 'now', refreshInterval: 5000 }); + expect(mockHistoryReplace).toHaveBeenCalledTimes(1); + expect(mockHistoryReplace).toHaveBeenCalledWith( + expect.objectContaining({ + search: 'rangeFrom=now-15m&rangeTo=now&refreshInterval=5000', + }) + ); + }); + + it('does not set default query params in the URL when values already defined', () => { + mountDatePicker({ + rangeFrom: 'now-1d', + rangeTo: 'now', + refreshPaused: false, + refreshInterval: 5000, + }); + expect(mockHistoryReplace).toHaveBeenCalledTimes(0); + }); + + it('updates the URL when the date range changes', () => { + const { wrapper } = mountDatePicker(); + + expect(mockHistoryReplace).toHaveBeenCalledTimes(1); + + wrapper.find(EuiSuperDatePicker).props().onTimeChange({ + start: 'now-90m', + end: 'now-60m', + isInvalid: false, + isQuickSelection: true, + }); + expect(mockHistoryPush).toHaveBeenCalledTimes(1); + expect(mockHistoryPush).toHaveBeenLastCalledWith( + expect.objectContaining({ + search: 'rangeFrom=now-90m&rangeTo=now-60m', + }) + ); + }); + + it('enables auto-refresh when refreshPaused is false', async () => { + jest.useFakeTimers(); + const { wrapper } = mountDatePicker({ + refreshPaused: false, + refreshInterval: 1000, + }); + expect(mockRefreshTimeRange).not.toHaveBeenCalled(); + jest.advanceTimersByTime(2500); + await waitFor(() => {}); + expect(mockRefreshTimeRange).toHaveBeenCalled(); + wrapper.unmount(); + }); + + it('disables auto-refresh when refreshPaused is true', async () => { + jest.useFakeTimers(); + mountDatePicker({ refreshPaused: true, refreshInterval: 1000 }); + expect(mockRefreshTimeRange).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1000); + await waitFor(() => {}); + expect(mockRefreshTimeRange).not.toHaveBeenCalled(); + }); + + describe('if both `rangeTo` and `rangeFrom` is set', () => { + it('calls setTime ', async () => { + const { setTimeSpy } = mountDatePicker({ + rangeTo: 'now-20m', + rangeFrom: 'now-22m', + }); + expect(setTimeSpy).toHaveBeenCalledWith({ + to: 'now-20m', + from: 'now-22m', + }); + }); + + it('does not update the url', () => { + expect(mockHistoryReplace).toHaveBeenCalledTimes(0); + }); + }); + + describe('if `rangeFrom` is missing from the urlParams', () => { + beforeEach(() => { + mountDatePicker({ rangeTo: 'now-5m' }); + }); + + it('updates the url with the default `rangeFrom` ', async () => { + expect(mockHistoryReplace).toHaveBeenCalledTimes(1); + expect(mockHistoryReplace.mock.calls[0][0].search).toContain( + 'rangeFrom=now-15m' + ); + }); + + it('preserves `rangeTo`', () => { + expect(mockHistoryReplace.mock.calls[0][0].search).toContain( + 'rangeTo=now-5m' + ); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/rum_datepicker/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/rum_datepicker/index.tsx new file mode 100644 index 0000000000000..9bc18d772a4a1 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/rum_datepicker/index.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { useUxUrlParams } from '../../../../context/url_params_context/use_ux_url_params'; +import { useDateRangeRedirect } from '../../../../hooks/use_date_range_redirect'; +import { DatePicker } from '../../../shared/DatePicker'; + +export function RumDatePicker() { + const { + urlParams: { rangeFrom, rangeTo, refreshPaused, refreshInterval }, + refreshTimeRange, + } = useUxUrlParams(); + + const { redirect, isDateRangeSet } = useDateRangeRedirect(); + + if (!isDateRangeSet) { + redirect(); + } + + return ( + { + refreshTimeRange({ rangeFrom: start, rangeTo: end }); + }} + /> + ); +} diff --git a/x-pack/plugins/apm/public/components/app/backend_detail_overview/index.tsx b/x-pack/plugins/apm/public/components/app/backend_detail_overview/index.tsx index 16120a6f5b429..3b4deac794df0 100644 --- a/x-pack/plugins/apm/public/components/app/backend_detail_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/backend_detail_overview/index.tsx @@ -32,7 +32,14 @@ import { useBreakpoints } from '../../../hooks/use_breakpoints'; export function BackendDetailOverview() { const { path: { backendName }, - query: { rangeFrom, rangeTo, environment, kuery }, + query: { + rangeFrom, + rangeTo, + refreshInterval, + refreshPaused, + environment, + kuery, + }, } = useApmParams('/backends/{backendName}/overview'); const apmRouter = useApmRouter(); @@ -41,7 +48,14 @@ export function BackendDetailOverview() { { title: DependenciesInventoryTitle, href: apmRouter.link('/backends', { - query: { rangeFrom, rangeTo, environment, kuery }, + query: { + rangeFrom, + rangeTo, + refreshInterval, + refreshPaused, + environment, + kuery, + }, }), }, { @@ -51,6 +65,8 @@ export function BackendDetailOverview() { query: { rangeFrom, rangeTo, + refreshInterval, + refreshPaused, environment, kuery, }, diff --git a/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.test.tsx b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.test.tsx index 523d8b1840fc8..9956452c565b3 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.test.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.test.tsx @@ -58,7 +58,11 @@ function Wrapper({ history.replace({ pathname: '/services/the-service-name/transactions/view', - search: fromQuery({ transactionName: 'the-transaction-name' }), + search: fromQuery({ + transactionName: 'the-transaction-name', + rangeFrom: 'now-15m', + rangeTo: 'now', + }), }); const mockPluginContext = merge({}, mockApmPluginContextValue, { @@ -73,14 +77,7 @@ function Wrapper({ history={history} value={mockPluginContext} > - + {children} diff --git a/x-pack/plugins/apm/public/components/app/service_map/Popover/Popover.stories.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/Popover.stories.tsx index d6300b7c80f1c..212489bf12cb4 100644 --- a/x-pack/plugins/apm/public/components/app/service_map/Popover/Popover.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/Popover/Popover.stories.tsx @@ -51,7 +51,9 @@ const stories: Meta = { createCallApmApi(coreMock); return ( - + diff --git a/x-pack/plugins/apm/public/components/app/service_map/index.test.tsx b/x-pack/plugins/apm/public/components/app/service_map/index.test.tsx index 9e3abb7cfd935..796be0659e617 100644 --- a/x-pack/plugins/apm/public/components/app/service_map/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/index.test.tsx @@ -16,8 +16,6 @@ import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_ap import { LicenseContext } from '../../../context/license/license_context'; import * as useFetcherModule from '../../../hooks/use_fetcher'; import { ServiceMap } from '.'; -import { UrlParamsProvider } from '../../../context/url_params_context/url_params_context'; -import { Router } from 'react-router-dom'; import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; const history = createMemoryHistory(); @@ -49,15 +47,15 @@ const expiredLicense = new License({ }); function createWrapper(license: License | null) { + history.replace('/service-map?rangeFrom=now-15m&rangeTo=now'); + return ({ children }: { children?: ReactNode }) => { return ( - - - {children} - + + {children} diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.test.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.test.tsx index 9057d4c6667b8..bd0ff4c87c3be 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/distribution/index.test.tsx @@ -56,7 +56,11 @@ function Wrapper({ history.replace({ pathname: '/services/the-service-name/transactions/view', - search: fromQuery({ transactionName: 'the-transaction-name' }), + search: fromQuery({ + transactionName: 'the-transaction-name', + rangeFrom: 'now-15m', + rangeTo: 'now', + }), }); const mockPluginContext = merge({}, mockApmPluginContextValue, { diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx index e031af6464187..a1b24fc516664 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx @@ -94,11 +94,14 @@ describe('TransactionOverview', () => { it('should redirect to first type', () => { setup({ serviceTransactionTypes: ['firstType', 'secondType'], - urlParams: {}, + urlParams: { + rangeFrom: 'now-15m', + rangeTo: 'now', + }, }); expect(history.replace).toHaveBeenCalledWith( expect.objectContaining({ - search: 'transactionType=firstType', + search: 'rangeFrom=now-15m&rangeTo=now&transactionType=firstType', }) ); }); @@ -112,6 +115,8 @@ describe('TransactionOverview', () => { serviceTransactionTypes: ['firstType'], urlParams: { transactionType: 'firstType', + rangeFrom: 'now-15m', + rangeTo: 'now', }, }); diff --git a/x-pack/plugins/apm/public/components/routing/app_root.tsx b/x-pack/plugins/apm/public/components/routing/app_root.tsx index c32828eca2f69..bae41b055874a 100644 --- a/x-pack/plugins/apm/public/components/routing/app_root.tsx +++ b/x-pack/plugins/apm/public/components/routing/app_root.tsx @@ -32,6 +32,7 @@ import { TimeRangeIdContextProvider } from '../../context/time_range_id/time_ran import { UrlParamsProvider } from '../../context/url_params_context/url_params_context'; import { ApmPluginStartDeps } from '../../plugin'; import { ApmHeaderActionMenu } from '../shared/apm_header_action_menu'; +import { RedirectWithDefaultDateRange } from '../shared/redirect_with_default_date_range'; import { apmRouter } from './apm_route_config'; import { TrackPageview } from './track_pageview'; @@ -58,24 +59,26 @@ export function ApmAppRoot({ - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + + diff --git a/x-pack/plugins/apm/public/components/routing/home/index.tsx b/x-pack/plugins/apm/public/components/routing/home/index.tsx index 1736a22e9b540..30e641f142b25 100644 --- a/x-pack/plugins/apm/public/components/routing/home/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/home/index.tsx @@ -63,12 +63,14 @@ export const home = { rangeTo: t.string, kuery: t.string, }), + t.partial({ + refreshPaused: t.union([t.literal('true'), t.literal('false')]), + refreshInterval: t.string, + }), ]), }), defaults: { query: { - rangeFrom: 'now-15m', - rangeTo: 'now', environment: ENVIRONMENT_ALL.value, kuery: '', }, diff --git a/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx b/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx index 9b87cc338bb9b..16cba23da6423 100644 --- a/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx @@ -83,14 +83,14 @@ export const serviceDetail = { comparisonType: t.string, latencyAggregationType: t.string, transactionType: t.string, + refreshPaused: t.union([t.literal('true'), t.literal('false')]), + refreshInterval: t.string, }), ]), }), ]), defaults: { query: { - rangeFrom: 'now-15m', - rangeTo: 'now', kuery: '', environment: ENVIRONMENT_ALL.value, }, diff --git a/x-pack/plugins/apm/public/components/routing/track_pageview.tsx b/x-pack/plugins/apm/public/components/routing/track_pageview.tsx index 7f4a03cae90be..af0682a56ec2b 100644 --- a/x-pack/plugins/apm/public/components/routing/track_pageview.tsx +++ b/x-pack/plugins/apm/public/components/routing/track_pageview.tsx @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React from 'react'; import { useRoutePath } from '@kbn/typed-react-router-config'; import { useTrackPageview } from '../../../../observability/public'; diff --git a/x-pack/plugins/apm/public/components/shared/DatePicker/date_picker.test.tsx b/x-pack/plugins/apm/public/components/shared/DatePicker/date_picker.test.tsx index ada93ff3a0344..737c1a54a2f09 100644 --- a/x-pack/plugins/apm/public/components/shared/DatePicker/date_picker.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/DatePicker/date_picker.test.tsx @@ -8,40 +8,62 @@ import { EuiSuperDatePicker } from '@elastic/eui'; import { waitFor } from '@testing-library/react'; import { mount } from 'enzyme'; -import { createMemoryHistory } from 'history'; -import React, { ReactNode } from 'react'; -import { Router } from 'react-router-dom'; +import { createMemoryHistory, MemoryHistory } from 'history'; +import React from 'react'; +import { useLocation } from 'react-router-dom'; +import qs from 'query-string'; import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_apm_plugin_context'; -import { UrlParamsContext } from '../../../context/url_params_context/url_params_context'; -import { ApmUrlParams } from '../../../context/url_params_context/types'; import { DatePicker } from './'; -const history = createMemoryHistory(); +let history: MemoryHistory; const mockRefreshTimeRange = jest.fn(); -function MockUrlParamsProvider({ - urlParams = {}, - children, -}: { - children: ReactNode; - urlParams?: ApmUrlParams; -}) { +let mockHistoryPush: jest.SpyInstance; +let mockHistoryReplace: jest.SpyInstance; + +function DatePickerWrapper() { + const location = useLocation(); + + const { rangeFrom, rangeTo, refreshInterval, refreshPaused } = qs.parse( + location.search, + { + parseNumbers: true, + parseBooleans: true, + } + ) as { + rangeFrom?: string; + rangeTo?: string; + refreshInterval?: number; + refreshPaused?: boolean; + }; + return ( - ); } -function mountDatePicker(urlParams?: ApmUrlParams) { +function mountDatePicker(initialParams: { + rangeFrom?: string; + rangeTo?: string; + refreshInterval?: number; + refreshPaused?: boolean; +}) { const setTimeSpy = jest.fn(); const getTimeSpy = jest.fn().mockReturnValue({}); + + history = createMemoryHistory({ + initialEntries: [`/?${qs.stringify(initialParams)}`], + }); + + mockHistoryPush = jest.spyOn(history, 'push'); + mockHistoryReplace = jest.spyOn(history, 'replace'); + const wrapper = mount( - - - - - + ); @@ -70,12 +89,8 @@ function mountDatePicker(urlParams?: ApmUrlParams) { } describe('DatePicker', () => { - let mockHistoryPush: jest.SpyInstance; - let mockHistoryReplace: jest.SpyInstance; beforeAll(() => { jest.spyOn(console, 'error').mockImplementation(() => null); - mockHistoryPush = jest.spyOn(history, 'push'); - mockHistoryReplace = jest.spyOn(history, 'replace'); }); afterAll(() => { @@ -86,47 +101,24 @@ describe('DatePicker', () => { jest.resetAllMocks(); }); - it('sets default query params in the URL', () => { - mountDatePicker(); - expect(mockHistoryReplace).toHaveBeenCalledTimes(1); - expect(mockHistoryReplace).toHaveBeenCalledWith( - expect.objectContaining({ - search: 'rangeFrom=now-15m&rangeTo=now', - }) - ); - }); - - it('adds missing `rangeFrom` to url', () => { - mountDatePicker({ rangeTo: 'now', refreshInterval: 5000 }); - expect(mockHistoryReplace).toHaveBeenCalledTimes(1); - expect(mockHistoryReplace).toHaveBeenCalledWith( - expect.objectContaining({ search: 'rangeFrom=now-15m&rangeTo=now' }) - ); - }); - - it('does not set default query params in the URL when values already defined', () => { - mountDatePicker({ - rangeFrom: 'now-1d', + it('updates the URL when the date range changes', () => { + const { wrapper } = mountDatePicker({ + rangeFrom: 'now-15m', rangeTo: 'now', - refreshPaused: false, - refreshInterval: 5000, }); + expect(mockHistoryReplace).toHaveBeenCalledTimes(0); - }); - it('updates the URL when the date range changes', () => { - const { wrapper } = mountDatePicker(); - expect(mockHistoryReplace).toHaveBeenCalledTimes(1); wrapper.find(EuiSuperDatePicker).props().onTimeChange({ - start: 'updated-start', - end: 'updated-end', + start: 'now-90m', + end: 'now-60m', isInvalid: false, isQuickSelection: true, }); expect(mockHistoryPush).toHaveBeenCalledTimes(1); expect(mockHistoryPush).toHaveBeenLastCalledWith( expect.objectContaining({ - search: 'rangeFrom=updated-start&rangeTo=updated-end', + search: 'rangeFrom=now-90m&rangeTo=now-60m', }) ); }); @@ -134,6 +126,8 @@ describe('DatePicker', () => { it('enables auto-refresh when refreshPaused is false', async () => { jest.useFakeTimers(); const { wrapper } = mountDatePicker({ + rangeFrom: 'now-15m', + rangeTo: 'now', refreshPaused: false, refreshInterval: 1000, }); @@ -146,7 +140,12 @@ describe('DatePicker', () => { it('disables auto-refresh when refreshPaused is true', async () => { jest.useFakeTimers(); - mountDatePicker({ refreshPaused: true, refreshInterval: 1000 }); + mountDatePicker({ + rangeFrom: 'now-15m', + rangeTo: 'now', + refreshPaused: true, + refreshInterval: 1000, + }); expect(mockRefreshTimeRange).not.toHaveBeenCalled(); jest.advanceTimersByTime(1000); await waitFor(() => {}); @@ -169,29 +168,4 @@ describe('DatePicker', () => { expect(mockHistoryReplace).toHaveBeenCalledTimes(0); }); }); - - describe('if `rangeFrom` is missing from the urlParams', () => { - let setTimeSpy: jest.Mock; - beforeEach(() => { - const res = mountDatePicker({ rangeTo: 'now-5m' }); - setTimeSpy = res.setTimeSpy; - }); - - it('does not call setTime', async () => { - expect(setTimeSpy).toHaveBeenCalledTimes(0); - }); - - it('updates the url with the default `rangeFrom` ', async () => { - expect(mockHistoryReplace).toHaveBeenCalledTimes(1); - expect(mockHistoryReplace.mock.calls[0][0].search).toContain( - 'rangeFrom=now-15m' - ); - }); - - it('preserves `rangeTo`', () => { - expect(mockHistoryReplace.mock.calls[0][0].search).toContain( - 'rangeTo=now-5m' - ); - }); - }); }); diff --git a/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx b/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx index 6772438fed01b..12cc137d62142 100644 --- a/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx @@ -10,12 +10,23 @@ import React, { useEffect } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import { UI_SETTINGS } from '../../../../../../../src/plugins/data/common'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; -import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { clearCache } from '../../../services/rest/callApi'; import { fromQuery, toQuery } from '../Links/url_helpers'; -import { TimePickerQuickRange, TimePickerTimeDefaults } from './typings'; +import { TimePickerQuickRange } from './typings'; -export function DatePicker() { +export function DatePicker({ + rangeFrom, + rangeTo, + refreshPaused, + refreshInterval, + onTimeRangeRefresh, +}: { + rangeFrom?: string; + rangeTo?: string; + refreshPaused?: boolean; + refreshInterval?: number; + onTimeRangeRefresh: (range: { start: string; end: string }) => void; +}) { const history = useHistory(); const location = useLocation(); const { core, plugins } = useApmPluginContext(); @@ -24,10 +35,6 @@ export function DatePicker() { UI_SETTINGS.TIMEPICKER_QUICK_RANGES ); - const timePickerTimeDefaults = core.uiSettings.get( - UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS - ); - const commonlyUsedRanges = timePickerQuickRanges.map( ({ from, to, display }) => ({ start: from, @@ -36,8 +43,6 @@ export function DatePicker() { }) ); - const { urlParams, refreshTimeRange } = useUrlParams(); - function updateUrl(nextQuery: { rangeFrom?: string; rangeTo?: string; @@ -54,13 +59,16 @@ export function DatePicker() { } function onRefreshChange({ - isPaused, - refreshInterval, + nextRefreshPaused, + nextRefreshInterval, }: { - isPaused: boolean; - refreshInterval: number; + nextRefreshPaused: boolean; + nextRefreshInterval: number; }) { - updateUrl({ refreshPaused: isPaused, refreshInterval }); + updateUrl({ + refreshPaused: nextRefreshPaused, + refreshInterval: nextRefreshInterval, + }); } function onTimeChange({ start, end }: { start: string; end: string }) { @@ -69,53 +77,32 @@ export function DatePicker() { useEffect(() => { // set time if both to and from are given in the url - if (urlParams.rangeFrom && urlParams.rangeTo) { + if (rangeFrom && rangeTo) { plugins.data.query.timefilter.timefilter.setTime({ - from: urlParams.rangeFrom, - to: urlParams.rangeTo, + from: rangeFrom, + to: rangeTo, }); return; } - - // read time from state and update the url - const timePickerSharedState = - plugins.data.query.timefilter.timefilter.getTime(); - - history.replace({ - ...location, - search: fromQuery({ - ...toQuery(location.search), - rangeFrom: - urlParams.rangeFrom ?? - timePickerSharedState.from ?? - timePickerTimeDefaults.from, - rangeTo: - urlParams.rangeTo ?? - timePickerSharedState.to ?? - timePickerTimeDefaults.to, - }), - }); - }, [ - urlParams.rangeFrom, - urlParams.rangeTo, - plugins, - history, - location, - timePickerTimeDefaults, - ]); + }, [rangeFrom, rangeTo, plugins]); return ( { clearCache(); - refreshTimeRange({ rangeFrom: start, rangeTo: end }); + onTimeRangeRefresh({ start, end }); + }} + onRefreshChange={({ + isPaused: nextRefreshPaused, + refreshInterval: nextRefreshInterval, + }) => { + onRefreshChange({ nextRefreshPaused, nextRefreshInterval }); }} - onRefreshChange={onRefreshChange} showUpdateButton={true} commonlyUsedRanges={commonlyUsedRanges} /> diff --git a/x-pack/plugins/apm/public/components/shared/redirect_with_default_date_range/index.tsx b/x-pack/plugins/apm/public/components/shared/redirect_with_default_date_range/index.tsx new file mode 100644 index 0000000000000..368125d7a6fd6 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/redirect_with_default_date_range/index.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { ReactElement } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useApmRouter } from '../../../hooks/use_apm_router'; +import { useDateRangeRedirect } from '../../../hooks/use_date_range_redirect'; + +// This is a top-level component that blocks rendering of the routes +// if there is no valid date range, and redirects to one if needed. +// If we don't do this, routes down the tree will fail because they +// expect the rangeFrom/rangeTo parameters to be set in the URL. +// +// This should be considered a temporary workaround until we have a +// more comprehensive solution for redirects that require context. + +export function RedirectWithDefaultDateRange({ + children, +}: { + children: ReactElement; +}) { + const { isDateRangeSet, redirect } = useDateRangeRedirect(); + + const apmRouter = useApmRouter(); + const location = useLocation(); + + const matchingRoutes = apmRouter.getRoutesToMatch(location.pathname); + + if ( + !isDateRangeSet && + matchingRoutes.some((route) => { + return ( + route.path === '/services' || + route.path === '/traces' || + route.path === '/service-map' || + route.path === '/backends' || + route.path === '/services/{serviceName}' || + location.pathname === '/' || + location.pathname === '' + ); + }) + ) { + redirect(); + return null; + } + + return children; +} diff --git a/x-pack/plugins/apm/public/components/shared/search_bar.test.tsx b/x-pack/plugins/apm/public/components/shared/search_bar.test.tsx index 58e0e7465925a..db30e73c86dc7 100644 --- a/x-pack/plugins/apm/public/components/shared/search_bar.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/search_bar.test.tsx @@ -75,6 +75,8 @@ describe('when transactionType is selected and multiple transaction types are gi serviceTransactionTypes: ['firstType', 'secondType'], urlParams: { transactionType: 'secondType', + rangeFrom: 'now-15m', + rangeTo: 'now', }, }); @@ -95,6 +97,8 @@ describe('when transactionType is selected and multiple transaction types are gi serviceTransactionTypes: ['firstType', 'secondType'], urlParams: { transactionType: 'secondType', + rangeFrom: 'now-15m', + rangeTo: 'now', }, }); diff --git a/x-pack/plugins/apm/public/components/shared/search_bar.tsx b/x-pack/plugins/apm/public/components/shared/search_bar.tsx index 035635908e56c..5f5a25393c7d1 100644 --- a/x-pack/plugins/apm/public/components/shared/search_bar.tsx +++ b/x-pack/plugins/apm/public/components/shared/search_bar.tsx @@ -13,6 +13,9 @@ import { EuiSpacer, } from '@elastic/eui'; import React from 'react'; +import { useTimeRangeId } from '../../context/time_range_id/use_time_range_id'; +import { toBoolean, toNumber } from '../../context/url_params_context/helpers'; +import { useApmParams } from '../../hooks/use_apm_params'; import { useBreakpoints } from '../../hooks/use_breakpoints'; import { DatePicker } from './DatePicker'; import { KueryBar } from './kuery_bar'; @@ -28,6 +31,39 @@ interface Props { kueryBarBoolFilter?: QueryDslQueryContainer[]; } +function ApmDatePicker() { + const { query } = useApmParams('/*'); + + if (!('rangeFrom' in query)) { + throw new Error('range not available in route parameters'); + } + + const { + rangeFrom, + rangeTo, + refreshPaused: refreshPausedFromUrl = 'true', + refreshInterval: refreshIntervalFromUrl = '0', + } = query; + + const refreshPaused = toBoolean(refreshPausedFromUrl); + + const refreshInterval = toNumber(refreshIntervalFromUrl); + + const { incrementTimeRangeId } = useTimeRangeId(); + + return ( + { + incrementTimeRangeId(); + }} + /> + ); +} + export function SearchBar({ hidden = false, showKueryBar = true, @@ -87,7 +123,7 @@ export function SearchBar({ )} - + diff --git a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx index eb0087d180146..617af6dae484d 100644 --- a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx +++ b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx @@ -10,6 +10,7 @@ import { Observable, of } from 'rxjs'; import { RouterProvider } from '@kbn/typed-react-router-config'; import { useHistory } from 'react-router-dom'; import { createMemoryHistory, History } from 'history'; +import { merge } from 'lodash'; import { UrlService } from '../../../../../../src/plugins/share/common/url_service'; import { createObservabilityRuleTypeRegistryMock } from '../../../../observability/public'; import { ApmPluginContext, ApmPluginContextValue } from './apm_plugin_context'; @@ -138,25 +139,28 @@ export function MockApmPluginContextWrapper({ value?: ApmPluginContextValue; history?: History; }) { - if (value.core) { - createCallApmApi(value.core); + const contextValue = merge({}, mockApmPluginContextValue, value); + + if (contextValue.core) { + createCallApmApi(contextValue.core); } const contextHistory = useHistory(); const usedHistory = useMemo(() => { - return history || contextHistory || createMemoryHistory(); + return ( + history || + contextHistory || + createMemoryHistory({ + initialEntries: ['/services/?rangeFrom=now-15m&rangeTo=now'], + }) + ); }, [history, contextHistory]); return ( - - + + {children} - - + + ); } diff --git a/x-pack/plugins/apm/public/hooks/use_date_range_redirect.ts b/x-pack/plugins/apm/public/hooks/use_date_range_redirect.ts new file mode 100644 index 0000000000000..0446b35872045 --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_date_range_redirect.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import qs from 'query-string'; +import { useHistory, useLocation } from 'react-router-dom'; +import { UI_SETTINGS } from '../../../../../src/plugins/data/public'; +import { TimePickerTimeDefaults } from '../components/shared/DatePicker/typings'; +import { useApmPluginContext } from '../context/apm_plugin/use_apm_plugin_context'; + +export function useDateRangeRedirect() { + const history = useHistory(); + const location = useLocation(); + const query = qs.parse(location.search); + + const { core, plugins } = useApmPluginContext(); + + const timePickerTimeDefaults = core.uiSettings.get( + UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS + ); + + const timePickerSharedState = + plugins.data.query.timefilter.timefilter.getTime(); + + const isDateRangeSet = 'rangeFrom' in query && 'rangeTo' in query; + + const redirect = () => { + const nextQuery = { + rangeFrom: timePickerSharedState.from ?? timePickerTimeDefaults.from, + rangeTo: timePickerSharedState.to ?? timePickerTimeDefaults.to, + ...query, + }; + + history.replace({ + ...location, + search: qs.stringify(nextQuery), + }); + }; + + return { + isDateRangeSet, + redirect, + }; +} diff --git a/x-pack/plugins/apm/public/hooks/use_time_range.ts b/x-pack/plugins/apm/public/hooks/use_time_range.ts index 5c6a78ba5c46a..79ca70130a442 100644 --- a/x-pack/plugins/apm/public/hooks/use_time_range.ts +++ b/x-pack/plugins/apm/public/hooks/use_time_range.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { useRef } from 'react'; +import { useMemo } from 'react'; import { useTimeRangeId } from '../context/time_range_id/use_time_range_id'; import { getDateRange } from '../context/url_params_context/helpers'; @@ -41,29 +41,16 @@ export function useTimeRange({ rangeTo?: string; optional?: boolean; }): TimeRange | PartialTimeRange { - const rangeRef = useRef({ rangeFrom, rangeTo }); + const { incrementTimeRangeId, timeRangeId } = useTimeRangeId(); - const { timeRangeId, incrementTimeRangeId } = useTimeRangeId(); - - const timeRangeIdRef = useRef(timeRangeId); - - const stateRef = useRef(getDateRange({ state: {}, rangeFrom, rangeTo })); - - const updateParsedTime = () => { - stateRef.current = getDateRange({ state: {}, rangeFrom, rangeTo }); - }; - - if ( - timeRangeIdRef.current !== timeRangeId || - rangeRef.current.rangeFrom !== rangeFrom || - rangeRef.current.rangeTo !== rangeTo - ) { - updateParsedTime(); - } - - rangeRef.current = { rangeFrom, rangeTo }; - - const { start, end, exactStart, exactEnd } = stateRef.current; + const { start, end, exactStart, exactEnd } = useMemo(() => { + return getDateRange({ + state: {}, + rangeFrom, + rangeTo, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rangeFrom, rangeTo, timeRangeId]); if ((!start || !end || !exactStart || !exactEnd) && !optional) { throw new Error('start and/or end were unexpectedly not set'); diff --git a/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json b/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json index 31f756eeabde3..8254ec67eb3c5 100644 --- a/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json +++ b/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json @@ -7,7 +7,10 @@ ], "exclude": [ "**/__fixtures__/**/*", - "./x-pack/plugins/apm/ftr_e2e" + "./x-pack/plugins/apm/ftr_e2e", + "./x-pack/plugins/apm/e2e", + "**/target/**", + "**/node_modules/**" ], "compilerOptions": { "noErrorTruncation": true diff --git a/x-pack/plugins/observability/public/components/shared/date_picker/index.tsx b/x-pack/plugins/observability/public/components/shared/date_picker/index.tsx index abbbee9e2deaa..c64d4353e613b 100644 --- a/x-pack/plugins/observability/public/components/shared/date_picker/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/date_picker/index.tsx @@ -84,7 +84,6 @@ export function DatePicker({ rangeFrom, rangeTo, refreshPaused, refreshInterval function onTimeChange({ start, end }: { start: string; end: string }) { updateUrl({ rangeFrom: start, rangeTo: end }); - onRefreshTimeRange(); } return ( @@ -96,7 +95,7 @@ export function DatePicker({ rangeFrom, rangeTo, refreshPaused, refreshInterval refreshInterval={refreshInterval} onRefreshChange={onRefreshChange} commonlyUsedRanges={commonlyUsedRanges} - onRefresh={onTimeChange} + onRefresh={onRefreshTimeRange} /> ); }