[APM] Ensure refresh button works (#112652)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Dario Gieselaar 2021-10-06 13:03:09 +02:00 committed by GitHub
parent 90bccc4d09
commit 6aade8f0eb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 653 additions and 261 deletions

View file

@ -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<TRoutes extends Route[]>(routes: TRoutes): Router<T
return reactRouterConfig;
}
function getRoutesToMatch(path: string) {
const matches = matchRoutesConfig(reactRouterConfigs, toReactRouterPath(path));
if (!matches.length) {
throw new Error(`No matching route found for ${path}`);
}
const matchedRoutes = matches.map((match) => {
return routesByReactRouterConfig.get(match.route)!;
});
return matchedRoutes;
}
const matchRoutes = (...args: any[]) => {
let optional: boolean = false;
@ -142,15 +156,7 @@ export function createRouter<TRoutes extends Route[]>(routes: TRoutes): Router<T
})
.join('/');
const matches = matchRoutesConfig(reactRouterConfigs, toReactRouterPath(path));
if (!matches.length) {
throw new Error(`No matching route found for ${path}`);
}
const matchedRoutes = matches.map((match) => {
return routesByReactRouterConfig.get(match.route)!;
});
const matchedRoutes = getRoutesToMatch(path);
const validationType = mergeRt(
...(compact(
@ -200,5 +206,8 @@ export function createRouter<TRoutes extends Route[]>(routes: TRoutes): Router<T
getRoutePath: (route) => {
return reactRouterConfigsByRoute.get(route)!.path as string;
},
getRoutesToMatch: (path: string) => {
return getRoutesToMatch(path) as unknown as FlattenRoutesOf<TRoutes>;
},
};
}

View file

@ -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 <OutletContext.Provider value={{ element }}>{children}</OutletContext.Provider>;
}
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;
}

View file

@ -18,7 +18,7 @@ export function RouterProvider({
}: {
router: Router<Route[]>;
history: History;
children: React.ReactElement;
children: React.ReactNode;
}) {
return (
<ReactRouter history={history}>

View file

@ -147,6 +147,7 @@ interface PlainRoute {
children?: PlainRoute[];
params?: t.Type<any>;
defaults?: Record<string, Record<string, string>>;
pre?: ReactElement;
}
interface ReadonlyPlainRoute {
@ -155,6 +156,7 @@ interface ReadonlyPlainRoute {
readonly children?: readonly ReadonlyPlainRoute[];
readonly params?: t.Type<any>;
readonly defaults?: Record<string, Record<string, string>>;
pre?: ReactElement;
}
export type Route = PlainRoute | ReadonlyPlainRoute;
@ -209,6 +211,10 @@ export type TypeAsArgs<TObject> = keyof TObject extends never
? [TObject] | []
: [TObject];
export type FlattenRoutesOf<TRoutes extends Route[]> = Array<
Omit<ValuesType<MapRoutes<TRoutes>>, 'parents'>
>;
export interface Router<TRoutes extends Route[]> {
matchRoutes<TPath extends PathsOf<TRoutes>>(
path: TPath,
@ -245,6 +251,7 @@ export interface Router<TRoutes extends Route[]> {
...args: TypeAsArgs<TypeOf<TRoutes, TPath, false>>
): string;
getRoutePath(route: Route): string;
getRoutesToMatch(path: string): FlattenRoutesOf<TRoutes>;
}
type AppendPath<
@ -256,23 +263,21 @@ type MaybeUnion<T extends Record<string, any>, U extends Record<string, any>> =
[key in keyof U]: key extends keyof T ? T[key] | U[key] : U[key];
};
type MapRoute<TRoute extends Route, TParents extends Route[] = []> = TRoute extends Route
? MaybeUnion<
{
[key in TRoute['path']]: TRoute & { parents: TParents };
},
TRoute extends { children: Route[] }
? MaybeUnion<
MapRoutes<TRoute['children'], [...TParents, TRoute]>,
{
[key in AppendPath<TRoute['path'], '*'>]: ValuesType<
MapRoutes<TRoute['children'], [...TParents, TRoute]>
>;
}
>
: {}
>
: {};
type MapRoute<TRoute extends Route, TParents extends Route[] = []> = MaybeUnion<
{
[key in TRoute['path']]: TRoute & { parents: TParents };
},
TRoute extends { children: Route[] }
? MaybeUnion<
MapRoutes<TRoute['children'], [...TParents, TRoute]>,
{
[key in AppendPath<TRoute['path'], '*'>]: ValuesType<
MapRoutes<TRoute['children'], [...TParents, TRoute]>
>;
}
>
: {}
>;
type MapRoutes<TRoutes, TParents extends Route[] = []> = TRoutes extends [Route]
? MapRoute<TRoutes[0], TParents>

View file

@ -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 (
<CurrentRouteContext.Provider value={{ match, element }}>
{children}
<OutletContextProvider element={element}>{children}</OutletContextProvider>
</CurrentRouteContext.Provider>
);
};

View file

@ -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;
}

View file

@ -16,7 +16,7 @@ export const RouterContextProvider = ({
children,
}: {
router: Router<Route[]>;
children: React.ReactElement;
children: React.ReactNode;
}) => <RouterContext.Provider value={router}>{children}</RouterContext.Provider>;
export function useRouter(): Router<Route[]> {

View file

@ -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() {
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem style={{ alignItems: 'flex-end', ...datePickerStyle }}>
<DatePicker />
<RumDatePicker />
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup wrap>

View file

@ -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 (
<UrlParamsContext.Provider
value={{
rangeId: 0,
refreshTimeRange: mockRefreshTimeRange,
urlParams,
uxUiFilters: {},
}}
children={children}
/>
);
}
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(
<MockApmPluginContextWrapper
history={history}
value={
{
plugins: {
data: {
query: {
timefilter: {
timefilter: { setTime: setTimeSpy, getTime: getTimeSpy },
},
},
},
},
} as any
}
>
<MockUrlParamsProvider>
<RumDatePicker />
</MockUrlParamsProvider>
</MockApmPluginContextWrapper>
);
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'
);
});
});
});

View file

@ -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 (
<DatePicker
rangeFrom={rangeFrom}
rangeTo={rangeTo}
refreshPaused={refreshPaused}
refreshInterval={refreshInterval}
onTimeRangeRefresh={({ start, end }) => {
refreshTimeRange({ rangeFrom: start, rangeTo: end });
}}
/>
);
}

View file

@ -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,
},

View file

@ -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}
>
<MockUrlParamsContextProvider
params={{
rangeFrom: 'now-15m',
rangeTo: 'now',
start: 'mystart',
end: 'myend',
}}
>
<MockUrlParamsContextProvider>
{children}
</MockUrlParamsContextProvider>
</MockApmPluginContextWrapper>

View file

@ -51,7 +51,9 @@ const stories: Meta<Args> = {
createCallApmApi(coreMock);
return (
<MemoryRouter initialEntries={['/service-map']}>
<MemoryRouter
initialEntries={['/service-map?rangeFrom=now-15m&rangeTo=now']}
>
<KibanaReactContext.Provider>
<MockUrlParamsContextProvider>
<MockApmPluginContextWrapper>

View file

@ -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 (
<EuiThemeProvider>
<KibanaReactContext.Provider>
<LicenseContext.Provider value={license || undefined}>
<MockApmPluginContextWrapper>
<Router history={history}>
<UrlParamsProvider>{children}</UrlParamsProvider>
</Router>
<MockApmPluginContextWrapper history={history}>
{children}
</MockApmPluginContextWrapper>
</LicenseContext.Provider>
</KibanaReactContext.Provider>

View file

@ -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, {

View file

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

View file

@ -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({
<i18nCore.Context>
<TimeRangeIdContextProvider>
<RouterProvider history={history} router={apmRouter as any}>
<TrackPageview>
<BreadcrumbsContextProvider>
<UrlParamsProvider>
<LicenseProvider>
<AnomalyDetectionJobsContextProvider>
<InspectorContextProvider>
<ApmThemeProvider>
<MountApmHeaderActionMenu />
<RedirectWithDefaultDateRange>
<TrackPageview>
<BreadcrumbsContextProvider>
<UrlParamsProvider>
<LicenseProvider>
<AnomalyDetectionJobsContextProvider>
<InspectorContextProvider>
<ApmThemeProvider>
<MountApmHeaderActionMenu />
<Route component={ScrollToTopOnPathChange} />
<RouteRenderer />
</ApmThemeProvider>
</InspectorContextProvider>
</AnomalyDetectionJobsContextProvider>
</LicenseProvider>
</UrlParamsProvider>
</BreadcrumbsContextProvider>
</TrackPageview>
<Route component={ScrollToTopOnPathChange} />
<RouteRenderer />
</ApmThemeProvider>
</InspectorContextProvider>
</AnomalyDetectionJobsContextProvider>
</LicenseProvider>
</UrlParamsProvider>
</BreadcrumbsContextProvider>
</TrackPageview>
</RedirectWithDefaultDateRange>
</RouterProvider>
</TimeRangeIdContextProvider>
</i18nCore.Context>

View file

@ -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: '',
},

View file

@ -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,
},

View file

@ -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';

View file

@ -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 (
<UrlParamsContext.Provider
value={{
rangeId: 0,
refreshTimeRange: mockRefreshTimeRange,
urlParams,
uxUiFilters: {},
}}
children={children}
<DatePicker
rangeFrom={rangeFrom}
rangeTo={rangeTo}
refreshInterval={refreshInterval}
refreshPaused={refreshPaused}
onTimeRangeRefresh={mockRefreshTimeRange}
/>
);
}
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(
<MockApmPluginContextWrapper
value={
@ -57,12 +79,9 @@ function mountDatePicker(urlParams?: ApmUrlParams) {
},
} as any
}
history={history}
>
<Router history={history}>
<MockUrlParamsProvider urlParams={urlParams}>
<DatePicker />
</MockUrlParamsProvider>
</Router>
<DatePickerWrapper />
</MockApmPluginContextWrapper>
);
@ -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',
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);
const { wrapper } = mountDatePicker({
rangeFrom: 'now-15m',
rangeTo: 'now',
});
expect(mockHistoryReplace).toHaveBeenCalledTimes(0);
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'
);
});
});
});

View file

@ -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<TimePickerTimeDefaults>(
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 (
<EuiSuperDatePicker
start={urlParams.rangeFrom}
end={urlParams.rangeTo}
isPaused={urlParams.refreshPaused}
refreshInterval={urlParams.refreshInterval}
start={rangeFrom}
end={rangeTo}
isPaused={refreshPaused}
refreshInterval={refreshInterval}
onTimeChange={onTimeChange}
onRefresh={({ start, end }) => {
clearCache();
refreshTimeRange({ rangeFrom: start, rangeTo: end });
onTimeRangeRefresh({ start, end });
}}
onRefreshChange={({
isPaused: nextRefreshPaused,
refreshInterval: nextRefreshInterval,
}) => {
onRefreshChange({ nextRefreshPaused, nextRefreshInterval });
}}
onRefreshChange={onRefreshChange}
showUpdateButton={true}
commonlyUsedRanges={commonlyUsedRanges}
/>

View file

@ -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;
}

View file

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

View file

@ -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 (
<DatePicker
rangeFrom={rangeFrom}
rangeTo={rangeTo}
refreshPaused={refreshPaused}
refreshInterval={refreshInterval}
onTimeRangeRefresh={() => {
incrementTimeRangeId();
}}
/>
);
}
export function SearchBar({
hidden = false,
showKueryBar = true,
@ -87,7 +123,7 @@ export function SearchBar({
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<DatePicker />
<ApmDatePicker />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>

View file

@ -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 (
<RouterProvider router={apmRouter as any} history={usedHistory}>
<ApmPluginContext.Provider
value={{
...mockApmPluginContextValue,
...value,
}}
>
<ApmPluginContext.Provider value={contextValue}>
<RouterProvider router={apmRouter as any} history={usedHistory}>
{children}
</ApmPluginContext.Provider>
</RouterProvider>
</RouterProvider>
</ApmPluginContext.Provider>
);
}

View file

@ -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<TimePickerTimeDefaults>(
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,
};
}

View file

@ -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');

View file

@ -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

View file

@ -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}
/>
);
}