[Security Solution][Timeline] - Store flyout information in the url (#148800)

## Summary
The purpose of this PR is to give users the ability to share a given
alert on the alert's page. This is possible via two changes. First, the
simple state of the details flyout is now stored in a url query param
`eventFlyout=(...flyoutState)`, when opened. Secondly, the addition of a
`Share Alert` button which allows users to share a link directly to the
alert page filtered for the given alert and the flyout opened.

### Caveats

1. **The share button is much more reliable than copying the url from
the browser url bar.**

Ideally storing the url state in the url should have been enough, but
because of the potential for relative time ranges in the global kql
query bar (which are also stored in the url), it it is possible to share
a url by copying the browser url that doesn't actually open the given
alert.

As an example: A user with a relative time range of `last 24 hours`
opens an alert that was created this morning with a colleague. The
colleague doesn't actually visit the link till the following afternoon.
When the user visits the link, they _may_ see the flyout open, but may
not actually see the associated alert in the alert table. This is
because the relative time range of `last 24 hours` doesn't contain the
alert that was opened the previous morning. The flyout _may_ open
because it is not constrained by the relative time range, but the
primary alert table may easily be out of sync. Given this, the `Share
Alert` button creates a custom url
`alerts/alertId?index='blah'&timestamp='...'` which redirects the user
to the specific alert and time range of their given alert.

2. **Storing of the alert flyout url state only works on the alerts
page. The url state is not stored on the cases or rules pages.**

Although this flyout is used in multiple locations, we only want to
preserve it on this singular page to keep the user flow simple, and also
allow us to more smoothly transition to the future flyout experience.

## Demo

### Sharing via the browser url


https://user-images.githubusercontent.com/17211684/227567405-37589def-b1be-406e-802e-764b49bee5f6.mov


### Sharing via the `Share alert` button


https://user-images.githubusercontent.com/17211684/230382767-0e6bf3d0-6ed1-442f-921a-db5eeec7592f.mov


## Follow up work

Revert/Delete the changes of the old Alerts Page POC:
https://github.com/elastic/kibana/issues/154477
This commit is contained in:
Michael Olorunnisola 2023-04-06 15:06:16 -04:00 committed by GitHub
parent c4908c09fc
commit 56566ffc89
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 402 additions and 92 deletions

View file

@ -8,25 +8,26 @@
import {
ALERT_FLYOUT,
CELL_TEXT,
COPY_ALERT_FLYOUT_LINK,
JSON_TEXT,
OVERVIEW_RULE,
TABLE_CONTAINER,
TABLE_ROWS,
} from '../../screens/alerts_details';
import { expandFirstAlert } from '../../tasks/alerts';
import { openJsonView, openTable } from '../../tasks/alerts_details';
import { closeAlertFlyout, expandFirstAlert } from '../../tasks/alerts';
import { filterBy, openJsonView, openTable } from '../../tasks/alerts_details';
import { createRule } from '../../tasks/api_calls/rules';
import { cleanKibana } from '../../tasks/common';
import { waitForAlertsToPopulate } from '../../tasks/create_new_rule';
import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver';
import { login, visitWithoutDateRange } from '../../tasks/login';
import { getUnmappedRule } from '../../objects/rule';
import { login, visit, visitWithoutDateRange } from '../../tasks/login';
import { getUnmappedRule, getNewRule } from '../../objects/rule';
import { ALERTS_URL } from '../../urls/navigation';
import { tablePageSelector } from '../../screens/table_pagination';
import { ALERTS_COUNT } from '../../screens/alerts';
describe('Alert details with unmapped fields', { testIsolation: false }, () => {
describe('Alert details flyout', () => {
describe('With unmapped fields', { testIsolation: false }, () => {
before(() => {
cleanKibana();
esArchiverLoad('unmapped_fields');
@ -87,3 +88,57 @@ describe('Alert details with unmapped fields', { testIsolation: false }, () => {
});
});
});
describe('Url state management', { testIsolation: false }, () => {
const testRule = getNewRule();
before(() => {
cleanKibana();
login();
createRule(testRule);
visit(ALERTS_URL);
waitForAlertsToPopulate();
});
it('should store the flyout state in the url when it is opened', () => {
expandFirstAlert();
cy.get(OVERVIEW_RULE).should('be.visible');
cy.url().should('include', 'eventFlyout=');
});
it('should remove the flyout state from the url when it is closed', () => {
expandFirstAlert();
cy.get(OVERVIEW_RULE).should('be.visible');
closeAlertFlyout();
cy.url().should('not.include', 'eventFlyout=');
});
it('should open the alert flyout when the page is refreshed', () => {
expandFirstAlert();
cy.get(OVERVIEW_RULE).should('be.visible');
cy.reload();
cy.get(OVERVIEW_RULE).should('be.visible');
cy.get(OVERVIEW_RULE).then((field) => {
expect(field).to.contain(testRule.name);
});
});
it('should show the copy link button for the flyout', () => {
expandFirstAlert();
cy.get(COPY_ALERT_FLYOUT_LINK).should('be.visible');
});
it('should open the flyout given the custom url', () => {
expandFirstAlert();
openTable();
filterBy('_id');
cy.get('[data-test-subj="formatted-field-_id"]')
.invoke('text')
.then((alertId) => {
cy.visit(`http://localhost:5620/app/security/alerts/${alertId}`);
cy.get('[data-test-subj="unifiedQueryInput"]').should('have.text', `_id: ${alertId}`);
cy.get(ALERTS_COUNT).should('have.text', '1 alert');
cy.get(OVERVIEW_RULE).should('be.visible');
});
});
});
});

View file

@ -21,7 +21,9 @@ import {
import { PAGE_TITLE } from '../../screens/common/page';
import { OPEN_ALERT_DETAILS_PAGE } from '../../screens/alerts_details';
describe('Alert Details Page Navigation', () => {
// This is skipped as the details page POC will be removed in favor of the expanded alert flyout
// https://github.com/elastic/kibana/issues/154477
describe.skip('Alert Details Page Navigation', () => {
describe('navigating to alert details page', () => {
const rule = getNewRule();
before(() => {

View file

@ -83,3 +83,5 @@ export const INSIGHTS_INVESTIGATE_ANCESTRY_ALERTS_IN_TIMELINE_BUTTON = `[data-te
export const ENRICHED_DATA_ROW = `[data-test-subj='EnrichedDataRow']`;
export const OPEN_ALERT_DETAILS_PAGE = `[data-test-subj="open-alert-details-page"]`;
export const COPY_ALERT_FLYOUT_LINK = `[data-test-subj="copy-alert-flyout-link"]`;

View file

@ -0,0 +1,10 @@
/*
* 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 type { ExpandedDetailType } from '../../../../common/types';
export type FlyoutUrlState = ExpandedDetailType;

View file

@ -0,0 +1,61 @@
/*
* 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 { useCallback, useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { TableId } from '../../../../common/types';
import { useInitializeUrlParam } from '../../utils/global_query_string';
import { URL_PARAM_KEY } from '../use_url_state';
import type { FlyoutUrlState } from './types';
import { dataTableActions, dataTableSelectors } from '../../store/data_table';
import { useShallowEqualSelector } from '../use_selector';
import { tableDefaults } from '../../store/data_table/defaults';
export const useInitFlyoutFromUrlParam = () => {
const [urlDetails, setUrlDetails] = useState<FlyoutUrlState | null>(null);
const [hasLoadedUrlDetails, updateHasLoadedUrlDetails] = useState(false);
const dispatch = useDispatch();
const getDataTable = dataTableSelectors.getTableByIdSelector();
// Only allow the alerts page for now to be saved in the url state.
// Allowing only one makes the transition to the expanded flyout much easier as well
const dataTableCurrent = useShallowEqualSelector(
(state) => getDataTable(state, TableId.alertsOnAlertsPage) ?? tableDefaults
);
const onInitialize = useCallback((initialState: FlyoutUrlState | null) => {
if (initialState != null && initialState.panelView) {
setUrlDetails(initialState);
}
}, []);
const loadExpandedDetailFromUrl = useCallback(() => {
const { initialized, isLoading, totalCount } = dataTableCurrent;
const isTableLoaded = initialized && !isLoading && totalCount > 0;
if (urlDetails && isTableLoaded) {
updateHasLoadedUrlDetails(true);
dispatch(
dataTableActions.toggleDetailPanel({
id: TableId.alertsOnAlertsPage,
...urlDetails,
})
);
}
}, [dataTableCurrent, dispatch, urlDetails]);
// The alert page creates a default dataTable slice in redux initially that is later overriden when data is retrieved
// We use the below to store the urlDetails on app load, and then set it when the table is done loading and has data
useEffect(() => {
if (!hasLoadedUrlDetails) {
loadExpandedDetailFromUrl();
}
}, [hasLoadedUrlDetails, loadExpandedDetailFromUrl]);
useInitializeUrlParam(URL_PARAM_KEY.eventFlyout, onInitialize);
};

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 { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { ALERTS_PATH } from '../../../../common/constants';
import { useUpdateUrlParam } from '../../utils/global_query_string';
import { TableId } from '../../../../common/types';
import { useShallowEqualSelector } from '../use_selector';
import { URL_PARAM_KEY } from '../use_url_state';
import { dataTableActions, dataTableSelectors } from '../../store/data_table';
import { tableDefaults } from '../../store/data_table/defaults';
import type { FlyoutUrlState } from './types';
export const useSyncFlyoutUrlParam = () => {
const updateUrlParam = useUpdateUrlParam<FlyoutUrlState>(URL_PARAM_KEY.eventFlyout);
const { pathname } = useLocation();
const dispatch = useDispatch();
const getDataTable = dataTableSelectors.getTableByIdSelector();
// Only allow the alerts page for now to be saved in the url state
const { expandedDetail } = useShallowEqualSelector(
(state) => getDataTable(state, TableId.alertsOnAlertsPage) ?? tableDefaults
);
useEffect(() => {
const isOnAlertsPage = pathname === ALERTS_PATH;
if (isOnAlertsPage && expandedDetail != null && expandedDetail?.query) {
updateUrlParam(expandedDetail.query.panelView ? expandedDetail.query : null);
} else if (!isOnAlertsPage && expandedDetail?.query?.panelView) {
// Close the detail panel as it's stored in a top level redux store maintained across views
dispatch(
dataTableActions.toggleDetailPanel({
id: TableId.alertsOnAlertsPage,
})
);
// Clear the reference from the url
updateUrlParam(null);
}
}, [dispatch, expandedDetail, pathname, updateUrlParam]);
};

View file

@ -12,6 +12,8 @@ import { useUpdateTimerangeOnPageChange } from './search_bar/use_update_timerang
import { useInitTimelineFromUrlParam } from './timeline/use_init_timeline_url_param';
import { useSyncTimelineUrlParam } from './timeline/use_sync_timeline_url_param';
import { useQueryTimelineByIdOnUrlChange } from './timeline/use_query_timeline_by_id_on_url_change';
import { useInitFlyoutFromUrlParam } from './flyout/use_init_flyout_url_param';
import { useSyncFlyoutUrlParam } from './flyout/use_sync_flyout_url_param';
export const useUrlState = () => {
useSyncGlobalQueryString();
@ -21,10 +23,13 @@ export const useUrlState = () => {
useInitTimelineFromUrlParam();
useSyncTimelineUrlParam();
useQueryTimelineByIdOnUrlChange();
useInitFlyoutFromUrlParam();
useSyncFlyoutUrlParam();
};
export enum URL_PARAM_KEY {
appQuery = 'query',
eventFlyout = 'eventFlyout',
filters = 'filters',
savedQuery = 'savedQuery',
sourcerer = 'sourcerer',

View file

@ -0,0 +1,67 @@
/*
* 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, { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { Redirect, useLocation, useParams } from 'react-router-dom';
import moment from 'moment';
import { encode } from '@kbn/rison';
import { ALERTS_PATH, DEFAULT_ALERTS_INDEX } from '../../../../common/constants';
import { URL_PARAM_KEY } from '../../../common/hooks/use_url_state';
import { inputsSelectors } from '../../../common/store';
export const AlertDetailsRedirect = () => {
const { alertId } = useParams<{ alertId: string }>();
const { search } = useLocation();
const searchParams = new URLSearchParams(search);
const timestamp = searchParams.get('timestamp');
// Although we use the 'default' space here when an index isn't provided or accidentally deleted
// It's a safe catch all as we reset the '.internal.alerts-*` indices with the correct space in the flyout
// Here: x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/helpers.tsx
const index = searchParams.get('index') ?? `.internal${DEFAULT_ALERTS_INDEX}-default`;
const getInputSelector = useMemo(() => inputsSelectors.inputsSelector(), []);
const inputState = useSelector(getInputSelector);
const { linkTo: globalLinkTo, timerange: globalTimerange } = inputState.global;
const { linkTo: timelineLinkTo, timerange: timelineTimerange } = inputState.timeline;
// Default to the existing global timerange if we don't get this query param for whatever reason
const fromTime = timestamp ?? globalTimerange.from;
// Add 1 millisecond to the alert timestamp as the alert table is non-inclusive of the end time
// So we have to extend slightly beyond the range of the timestamp of the given alert
const toTime = moment(timestamp ?? globalTimerange.to).add('1', 'millisecond');
const timerange = encode({
global: {
[URL_PARAM_KEY.timerange]: {
kind: 'absolute',
from: fromTime,
to: toTime,
},
linkTo: globalLinkTo,
},
timeline: {
[URL_PARAM_KEY.timerange]: timelineTimerange,
linkTo: timelineLinkTo,
},
});
const flyoutString = encode({
panelView: 'eventDetail',
params: {
eventId: alertId,
indexName: index,
},
});
const kqlAppQuery = encode({ language: 'kuery', query: `_id: ${alertId}` });
const url = `${ALERTS_PATH}?${URL_PARAM_KEY.appQuery}=${kqlAppQuery}&${URL_PARAM_KEY.timerange}=${timerange}&${URL_PARAM_KEY.eventFlyout}=${flyoutString}`;
return <Redirect to={url} />;
};

View file

@ -6,20 +6,17 @@
*/
import React from 'react';
import { Redirect, Switch } from 'react-router-dom';
import { Switch } from 'react-router-dom';
import { Route } from '@kbn/shared-ux-router';
import { TrackApplicationView } from '@kbn/usage-collection-plugin/public';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { ALERTS_PATH, SecurityPageName } from '../../../../common/constants';
import { NotFoundPage } from '../../../app/404';
import * as i18n from './translations';
import { DetectionEnginePage } from '../detection_engine/detection_engine';
import { SpyRoute } from '../../../common/utils/route/spy_routes';
import { useReadonlyHeader } from '../../../use_readonly_header';
import { AlertDetailsPage } from '../alert_details';
import { AlertDetailRouteType } from '../alert_details/types';
import { getAlertDetailsTabUrl } from '../alert_details/utils/navigation';
import { AlertDetailsRedirect } from './alert_details_redirect';
const AlertsRoute = () => (
<TrackApplicationView viewId={SecurityPageName.alerts}>
@ -28,40 +25,13 @@ const AlertsRoute = () => (
</TrackApplicationView>
);
const AlertDetailsRoute = () => (
<TrackApplicationView viewId={SecurityPageName.alerts}>
<AlertDetailsPage />
</TrackApplicationView>
);
const AlertsContainerComponent: React.FC = () => {
useReadonlyHeader(i18n.READ_ONLY_BADGE_TOOLTIP);
const isAlertDetailsPageEnabled = useIsExperimentalFeatureEnabled('alertDetailsPageEnabled');
return (
<Switch>
<Route path={ALERTS_PATH} exact component={AlertsRoute} />
{isAlertDetailsPageEnabled && (
<>
{/* Redirect to the summary page if only the detail name is provided */}
<Route
path={`${ALERTS_PATH}/:detailName`}
render={({
match: {
params: { detailName },
},
location: { search = '' },
}) => (
<Redirect
to={{
pathname: getAlertDetailsTabUrl(detailName, AlertDetailRouteType.summary),
search,
}}
/>
)}
/>
<Route path={`${ALERTS_PATH}/:detailName/:tabName`} component={AlertDetailsRoute} />
</>
)}
{/* Redirect to the alerts page filtered for the given alert id */}
<Route path={`${ALERTS_PATH}/:alertId`} component={AlertDetailsRedirect} />
<Route component={NotFoundPage} />
</Switch>
);

View file

@ -8,12 +8,14 @@
import { isEmpty } from 'lodash/fp';
import {
EuiButtonIcon,
EuiButtonEmpty,
EuiTextColor,
EuiLoadingContent,
EuiTitle,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiCopy,
} from '@elastic/eui';
import React from 'react';
import styled from 'styled-components';
@ -32,6 +34,7 @@ import type { TimelineEventsDetailsItem } from '../../../../../common/search_str
import * as i18n from './translations';
import { PreferenceFormattedDate } from '../../../../common/components/formatted_date';
import { SecurityPageName } from '../../../../../common/constants';
import { useGetAlertDetailsFlyoutLink } from './use_get_alert_details_flyout_link';
export type HandleOnEventClosed = () => void;
interface Props {
@ -52,10 +55,11 @@ interface Props {
interface ExpandableEventTitleProps {
eventId: string;
eventIndex: string;
isAlert: boolean;
loading: boolean;
ruleName?: string;
timestamp?: string;
timestamp: string;
handleOnEventClosed?: HandleOnEventClosed;
}
@ -76,12 +80,19 @@ const StyledEuiFlexItem = styled(EuiFlexItem)`
`;
export const ExpandableEventTitle = React.memo<ExpandableEventTitleProps>(
({ eventId, isAlert, loading, handleOnEventClosed, ruleName, timestamp }) => {
({ eventId, eventIndex, isAlert, loading, handleOnEventClosed, ruleName, timestamp }) => {
const isAlertDetailsPageEnabled = useIsExperimentalFeatureEnabled('alertDetailsPageEnabled');
const { onClick } = useGetSecuritySolutionLinkProps()({
deepLinkId: SecurityPageName.alerts,
path: eventId && isAlert ? getAlertDetailsUrl(eventId) : '',
});
const alertDetailsLink = useGetAlertDetailsFlyoutLink({
_id: eventId,
_index: eventIndex,
timestamp,
});
return (
<StyledEuiFlexGroup gutterSize="none" justifyContent="spaceBetween" wrap={true}>
<EuiFlexItem grow={false}>
@ -117,6 +128,19 @@ export const ExpandableEventTitle = React.memo<ExpandableEventTitleProps>(
<EuiButtonIcon iconType="cross" aria-label={i18n.CLOSE} onClick={handleOnEventClosed} />
</EuiFlexItem>
)}
{isAlert && (
<EuiCopy textToCopy={alertDetailsLink}>
{(copy) => (
<EuiButtonEmpty
onClick={copy}
iconType="share"
data-test-subj="copy-alert-flyout-link"
>
{i18n.SHARE_ALERT}
</EuiButtonEmpty>
)}
</EuiCopy>
)}
</StyledEuiFlexGroup>
);
}

View file

@ -13,6 +13,7 @@ import { BackToAlertDetailsLink } from './back_to_alert_details_link';
interface FlyoutHeaderComponentProps {
eventId: string;
eventIndex: string;
isAlert: boolean;
isHostIsolationPanelOpen: boolean;
isolateAction: 'isolateHost' | 'unisolateHost';
@ -24,6 +25,7 @@ interface FlyoutHeaderComponentProps {
const FlyoutHeaderContentComponent = ({
eventId,
eventIndex,
isAlert,
isHostIsolationPanelOpen,
isolateAction,
@ -39,6 +41,7 @@ const FlyoutHeaderContentComponent = ({
) : (
<ExpandableEventTitle
eventId={eventId}
eventIndex={eventIndex}
isAlert={isAlert}
loading={loading}
ruleName={ruleName}
@ -52,6 +55,7 @@ const FlyoutHeaderContent = React.memo(FlyoutHeaderContentComponent);
const FlyoutHeaderComponent = ({
eventId,
eventIndex,
isAlert,
isHostIsolationPanelOpen,
isolateAction,
@ -64,6 +68,7 @@ const FlyoutHeaderComponent = ({
<EuiFlyoutHeader hasBorder={isHostIsolationPanelOpen}>
<FlyoutHeaderContentComponent
eventId={eventId}
eventIndex={eventIndex}
isAlert={isAlert}
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
isolateAction={isolateAction}

View file

@ -107,6 +107,7 @@ export const useToGetInternalFlyout = () => {
<FlyoutHeaderContent
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
isAlert={isAlert}
eventIndex={alert.indexName ?? ''}
eventId={alertId}
isolateAction={isolateAction}
loading={isLoading || loading}
@ -117,6 +118,7 @@ export const useToGetInternalFlyout = () => {
);
},
[
alert.indexName,
isAlert,
alertId,
isHostIsolationPanelOpen,

View file

@ -37,6 +37,15 @@ const ecsData: Ecs = {
},
};
const mockUseLocation = jest.fn().mockReturnValue({ pathname: '/test', search: '?' });
jest.mock('react-router-dom', () => {
const original = jest.requireActual('react-router-dom');
return {
...original,
useLocation: () => mockUseLocation(),
};
});
jest.mock('../../../../../common/endpoint/service/host_isolation/utils', () => {
return {
isIsolationSupported: jest.fn().mockReturnValue(true),

View file

@ -81,6 +81,7 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
isFlyoutView || isHostIsolationPanelOpen ? (
<FlyoutHeader
eventId={expandedEvent.eventId}
eventIndex={eventIndex}
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
isAlert={isAlert}
isolateAction={isolateAction}
@ -92,14 +93,17 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
) : (
<ExpandableEventTitle
eventId={expandedEvent.eventId}
eventIndex={eventIndex}
isAlert={isAlert}
loading={loading}
ruleName={ruleName}
timestamp={timestamp}
handleOnEventClosed={handleOnEventClosed}
/>
),
[
expandedEvent.eventId,
eventIndex,
handleOnEventClosed,
isAlert,
isFlyoutView,

View file

@ -48,3 +48,10 @@ export const ALERT_DETAILS = i18n.translate(
defaultMessage: 'Alert details',
}
);
export const SHARE_ALERT = i18n.translate(
'xpack.securitySolution.timeline.expandableEvent.shareAlert',
{
defaultMessage: 'Share alert',
}
);

View file

@ -0,0 +1,32 @@
/*
* 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 { useMemo } from 'react';
import { useAppUrl } from '../../../../common/lib/kibana/hooks';
import { ALERTS_PATH } from '../../../../../common/constants';
export const useGetAlertDetailsFlyoutLink = ({
_id,
_index,
timestamp,
}: {
_id: string;
_index: string;
timestamp: string;
}) => {
const { getAppUrl } = useAppUrl();
// getAppUrl accounts for the users selected space
const alertDetailsLink = useMemo(() => {
const url = getAppUrl({
path: `${ALERTS_PATH}/${_id}?index=${_index}&timestamp=${timestamp}`,
});
return `${window.location.origin}${url}`;
}, [_id, _index, getAppUrl, timestamp]);
return alertDetailsLink;
};

View file

@ -29,6 +29,15 @@ jest.mock('../../../common/containers/use_search_strategy', () => ({
useSearchStrategy: jest.fn(),
}));
const mockUseLocation = jest.fn().mockReturnValue({ pathname: '/test', search: '?' });
jest.mock('react-router-dom', () => {
const original = jest.requireActual('react-router-dom');
return {
...original,
useLocation: () => mockUseLocation(),
};
});
describe('Details Panel Component', () => {
const state: State = {
...mockGlobalState,