mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Security Solution] - use expandable flyout in timeline (#177087)
## Summary This PR enables Timeline to have its own expandable flyout, independent from the Security Solution app level flyout. This previous [PR](https://github.com/elastic/kibana/pull/176457) added support to the `kbn-expandable-flyout` package to be able to show multiple flyout at the same time. This PR focuses on: - changing the location where we use the `<ExpandableFlyoutProvider>` to wrap only the Security Solution pages. instead of the entire Security Solution application - wrapping `Timeline` with its own `<ExpandableFlyoutProvider>` - opening the new expandable flyout from timeline for alerts, users and host!! ### Notes - the new expandable flyout opened from `Timeline` is saved in the url under a new `timelineFlyout` parameter. The other Security Solution flyout stays saved under `eventFlyout` so both can be displayed at the same time - introduced a new feature flag called `expandableTimelineFlyoutEnabled`, enabled to `false` by default. The code uses this flag in combination with the already existing `securitySolution:enableExpandableFlyout` advanced settings to toggle on/off the expandable flyout in `Timeline` I had to make a small change to the timeline `z-index` value, from its hardcoded value of `1000` to now `1001`. At the same time I'm setting the `z-index` of the `eventFlyout` to `1000` and the `z-index` of the `timelineFlyout` to `1002`. Finally, I changed the `z-index` of all the other flyouts opened from the `Take action` button in the footer (like _Add to new case_, _Add rule exception_...) to `1003`. This was the only way to get the order correct after page refresh, and also work in Serverless which has a different top level layout (for the top bar and the left navigation panel). ### Testing Make sure you have the flag turned on in your `kibana.yml` file: `xpack.securitySolution.enableExperimental: ['expandableTimelineFlyoutEnabled']` While the code changes are pretty minimal, emphasize should be done on testing: - verify that the non-timeline expandable flyout behavior hasn't changed (in the Explore, Rule Preview, Alerts pages...) - verify the advanced settings correctly toggles on and off the expandable flyout for both the Security Solution app and Timeline - verify the expandable flyout correctly shows up in Timeline on all the pages where Timeline is present - verify both flyout can be shown at the same time - verify refreshing the page reloads correctly all flyouts open - verify copying the url or using the Share Alert button at the top can be correctly reopened in another tab ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### TODO - [x] fix unit tests - [ ] fix e2e tests - [x] figure out how to fix the UI in serverless (the flyout is currently behind the left navigation) Before change:3b89f87f
-d1fa-4635-8ff7-855795eb3796 After change:6b341f11
-054c-4885-b496-006f37b8d572 Serverlessdf414140
-31df-4941-869e-c177cfedd805 https://github.com/elastic/security-team/issues/7464 Details of file changed in [the comment below](https://github.com/elastic/kibana/pull/177087#issuecomment-1988642537)
This commit is contained in:
parent
2aa5d968e8
commit
f8a6324444
25 changed files with 614 additions and 288 deletions
|
@ -7,6 +7,7 @@
|
|||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import type { Interpolation, Theme } from '@emotion/react';
|
||||
import { EuiFlyoutProps } from '@elastic/eui';
|
||||
import { EuiFlexGroup, EuiFlyout } from '@elastic/eui';
|
||||
import { useSectionSizes } from './hooks/use_sections_sizes';
|
||||
|
@ -26,6 +27,10 @@ export interface ExpandableFlyoutProps extends Omit<EuiFlyoutProps, 'onClose'> {
|
|||
* List of all registered panels available for render
|
||||
*/
|
||||
registeredPanels: Panel[];
|
||||
/**
|
||||
* Allows for custom styles to be passed to the EuiFlyout component
|
||||
*/
|
||||
customStyles?: Interpolation<Theme>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -36,6 +41,7 @@ export interface ExpandableFlyoutProps extends Omit<EuiFlyoutProps, 'onClose'> {
|
|||
* is already rendered.
|
||||
*/
|
||||
export const ExpandableFlyout: React.FC<ExpandableFlyoutProps> = ({
|
||||
customStyles,
|
||||
registeredPanels,
|
||||
...flyoutProps
|
||||
}) => {
|
||||
|
@ -83,7 +89,13 @@ export const ExpandableFlyout: React.FC<ExpandableFlyoutProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<EuiFlyout {...flyoutProps} size={flyoutWidth} ownFocus={false} onClose={closeFlyout}>
|
||||
<EuiFlyout
|
||||
{...flyoutProps}
|
||||
size={flyoutWidth}
|
||||
ownFocus={false}
|
||||
onClose={closeFlyout}
|
||||
css={customStyles}
|
||||
>
|
||||
<EuiFlexGroup
|
||||
direction={leftSection ? 'row' : 'column'}
|
||||
wrap={false}
|
||||
|
|
|
@ -226,6 +226,8 @@ export const getFieldEditorOpener =
|
|||
},
|
||||
maskProps: {
|
||||
className: 'indexPatternFieldEditorMaskOverlay',
|
||||
// // EUI TODO: This z-index override of EuiOverlayMask is a workaround, and ideally should be resolved with a cleaner UI/UX flow long-term
|
||||
style: 'z-index: 1003', // we need this flyout to be above the timeline flyout (which has a z-index of 1002)
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
|
@ -40,12 +40,11 @@ export const CreateCaseFlyout = React.memo<CreateCaseFlyoutProps>(
|
|||
<>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
<EuiFlyout
|
||||
css={css`
|
||||
z-index: ${euiTheme.levels.flyout};
|
||||
`}
|
||||
onClose={handleCancel}
|
||||
tour-step="create-case-flyout"
|
||||
data-test-subj="create-case-flyout"
|
||||
// EUI TODO: This z-index override of EuiOverlayMask is a workaround, and ideally should be resolved with a cleaner UI/UX flow long-term
|
||||
maskProps={{ style: `z-index: ${(euiTheme.levels.flyout as number) + 3}` }} // we need this flyout to be above the timeline flyout (which has a z-index of 1002)
|
||||
>
|
||||
<EuiFlyoutHeader data-test-subj="create-case-flyout-header" hasBorder>
|
||||
<EuiTitle size="m">
|
||||
|
|
|
@ -111,6 +111,12 @@ export const allowedExperimentalValues = Object.freeze({
|
|||
*/
|
||||
expandableEventFlyoutEnabled: false,
|
||||
|
||||
/**
|
||||
* Enables expandable flyout in timeline
|
||||
*/
|
||||
expandableTimelineFlyoutEnabled: false,
|
||||
/*
|
||||
|
||||
/**
|
||||
* Enables new Set of filters on the Alerts page.
|
||||
*/
|
||||
|
|
|
@ -12,8 +12,8 @@ import { IS_DRAGGING_CLASS_NAME } from '@kbn/securitysolution-t-grid';
|
|||
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
|
||||
import type { KibanaPageTemplateProps } from '@kbn/shared-ux-page-kibana-template';
|
||||
import { ExpandableFlyoutProvider } from '@kbn/expandable-flyout';
|
||||
import { EXPANDABLE_FLYOUT_URL_KEY } from '../../../common/hooks/use_url_state';
|
||||
import { SecuritySolutionFlyout } from '../../../flyout';
|
||||
import { URL_PARAM_KEY } from '../../../common/hooks/use_url_state';
|
||||
import { SecuritySolutionFlyout, TimelineFlyout } from '../../../flyout';
|
||||
import { useSecuritySolutionNavigation } from '../../../common/components/navigation/use_security_solution_navigation';
|
||||
import { TimelineId } from '../../../../common/types/timeline';
|
||||
import { getTimelineShowStatusByIdSelector } from '../../../timelines/store/selectors';
|
||||
|
@ -68,36 +68,39 @@ export const SecuritySolutionTemplateWrapper: React.FC<Omit<KibanaPageTemplatePr
|
|||
* between EuiPageTemplate and the security solution pages.
|
||||
*/
|
||||
return (
|
||||
<ExpandableFlyoutProvider urlKey={isPreview ? undefined : EXPANDABLE_FLYOUT_URL_KEY}>
|
||||
<StyledKibanaPageTemplate
|
||||
theme={euiTheme}
|
||||
$isShowingTimelineOverlay={isShowingTimelineOverlay}
|
||||
paddingSize="none"
|
||||
solutionNav={solutionNavProps}
|
||||
restrictWidth={false}
|
||||
{...rest}
|
||||
<StyledKibanaPageTemplate
|
||||
theme={euiTheme}
|
||||
$isShowingTimelineOverlay={isShowingTimelineOverlay}
|
||||
paddingSize="none"
|
||||
solutionNav={solutionNavProps}
|
||||
restrictWidth={false}
|
||||
{...rest}
|
||||
>
|
||||
<GlobalKQLHeader />
|
||||
<KibanaPageTemplate.Section
|
||||
className="securityPageWrapper"
|
||||
data-test-subj="pageContainer"
|
||||
paddingSize={rest.paddingSize ?? 'l'}
|
||||
alignment="top"
|
||||
component="div"
|
||||
grow={true}
|
||||
>
|
||||
<GlobalKQLHeader />
|
||||
<KibanaPageTemplate.Section
|
||||
className="securityPageWrapper"
|
||||
data-test-subj="pageContainer"
|
||||
paddingSize={rest.paddingSize ?? 'l'}
|
||||
alignment="top"
|
||||
component="div"
|
||||
grow={true}
|
||||
>
|
||||
<ExpandableFlyoutProvider urlKey={isPreview ? undefined : URL_PARAM_KEY.eventFlyout}>
|
||||
{children}
|
||||
</KibanaPageTemplate.Section>
|
||||
{isTimelineBottomBarVisible && (
|
||||
<KibanaPageTemplate.BottomBar data-test-subj="timeline-bottom-bar-container">
|
||||
<EuiThemeProvider colorMode={globalColorMode}>
|
||||
<SecuritySolutionFlyout />
|
||||
</ExpandableFlyoutProvider>
|
||||
</KibanaPageTemplate.Section>
|
||||
{isTimelineBottomBarVisible && (
|
||||
<KibanaPageTemplate.BottomBar data-test-subj="timeline-bottom-bar-container">
|
||||
<EuiThemeProvider colorMode={globalColorMode}>
|
||||
<ExpandableFlyoutProvider urlKey={URL_PARAM_KEY.timelineFlyout}>
|
||||
<Timeline />
|
||||
</EuiThemeProvider>
|
||||
</KibanaPageTemplate.BottomBar>
|
||||
)}
|
||||
<SecuritySolutionFlyout />
|
||||
</StyledKibanaPageTemplate>
|
||||
</ExpandableFlyoutProvider>
|
||||
<TimelineFlyout />
|
||||
</ExpandableFlyoutProvider>
|
||||
</EuiThemeProvider>
|
||||
</KibanaPageTemplate.BottomBar>
|
||||
)}
|
||||
</StyledKibanaPageTemplate>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -15,8 +15,6 @@ import { useQueryTimelineByIdOnUrlChange } from './timeline/use_query_timeline_b
|
|||
import { useInitFlyoutFromUrlParam } from './flyout/use_init_flyout_url_param';
|
||||
import { useSyncFlyoutUrlParam } from './flyout/use_sync_flyout_url_param';
|
||||
|
||||
export const EXPANDABLE_FLYOUT_URL_KEY = 'eventFlyout' as const;
|
||||
|
||||
export const useUrlState = () => {
|
||||
useSyncGlobalQueryString();
|
||||
useInitSearchBarFromUrlParams();
|
||||
|
@ -31,7 +29,8 @@ export const useUrlState = () => {
|
|||
|
||||
export const URL_PARAM_KEY = {
|
||||
appQuery: 'query',
|
||||
eventFlyout: EXPANDABLE_FLYOUT_URL_KEY,
|
||||
eventFlyout: 'eventFlyout',
|
||||
timelineFlyout: 'timelineFlyout',
|
||||
filters: 'filters',
|
||||
savedQuery: 'savedQuery',
|
||||
sourcerer: 'sourcerer',
|
||||
|
|
|
@ -23,6 +23,7 @@ import {
|
|||
EuiSkeletonText,
|
||||
EuiCallOut,
|
||||
EuiText,
|
||||
useEuiTheme,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { ENDPOINT_LIST_ID } from '@kbn/securitysolution-list-constants';
|
||||
|
@ -114,6 +115,7 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({
|
|||
onCancel,
|
||||
onConfirm,
|
||||
}: AddExceptionFlyoutProps) {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const { isLoading, indexPatterns, getExtendedFields } = useFetchIndexPatterns(rules);
|
||||
const [isSubmitting, submitNewExceptionItems] = useAddNewExceptionItems();
|
||||
const [isClosingAlerts, closeAlerts] = useCloseAlertsFromExceptions();
|
||||
|
@ -493,7 +495,13 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({
|
|||
}, [listType]);
|
||||
|
||||
return (
|
||||
<EuiFlyout size="l" onClose={handleCloseFlyout} data-test-subj="addExceptionFlyout">
|
||||
<EuiFlyout
|
||||
size="l"
|
||||
onClose={handleCloseFlyout}
|
||||
data-test-subj="addExceptionFlyout"
|
||||
// EUI TODO: This z-index override of EuiOverlayMask is a workaround, and ideally should be resolved with a cleaner UI/UX flow long-term
|
||||
maskProps={{ style: `z-index: ${(euiTheme.levels.flyout as number) + 3}` }} // we need this flyout to be above the timeline flyout (which has a z-index of 1002)
|
||||
>
|
||||
<FlyoutHeader>
|
||||
<EuiTitle>
|
||||
<h2 data-test-subj="exceptionFlyoutTitle">{addExceptionMessage}</h2>
|
||||
|
|
|
@ -6,8 +6,7 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { copyToClipboard } from '@elastic/eui';
|
||||
import { render } from '@testing-library/react';
|
||||
import { RightPanelContext } from '../context';
|
||||
import { SHARE_BUTTON_TEST_ID, CHAT_BUTTON_TEST_ID } from './test_ids';
|
||||
import { HeaderActions } from './header_actions';
|
||||
|
@ -16,7 +15,6 @@ import { mockGetFieldsData } from '../../shared/mocks/mock_get_fields_data';
|
|||
import { mockDataFormattedForFieldBrowser } from '../../shared/mocks/mock_data_formatted_for_field_browser';
|
||||
import { TestProvidersComponent } from '../../../../common/mock';
|
||||
import { useGetAlertDetailsFlyoutLink } from '../../../../timelines/components/side_panel/event_details/use_get_alert_details_flyout_link';
|
||||
import { URL_PARAM_KEY } from '../../../../common/hooks/use_url_state';
|
||||
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
jest.mock('../hooks/use_assistant');
|
||||
|
@ -26,7 +24,6 @@ jest.mock(
|
|||
|
||||
jest.mock('@elastic/eui', () => ({
|
||||
...jest.requireActual('@elastic/eui'),
|
||||
copyToClipboard: jest.fn(),
|
||||
EuiCopy: jest.fn(({ children: functionAsChild }) => functionAsChild(jest.fn())),
|
||||
}));
|
||||
|
||||
|
@ -47,30 +44,16 @@ const renderHeaderActions = (contextValue: RightPanelContext) =>
|
|||
|
||||
describe('<HeaderAction />', () => {
|
||||
beforeEach(() => {
|
||||
window.location.search = '?';
|
||||
jest.mocked(useGetAlertDetailsFlyoutLink).mockReturnValue(alertUrl);
|
||||
jest.mocked(useAssistant).mockReturnValue({ showAssistant: true, promptContextId: '' });
|
||||
});
|
||||
|
||||
describe('Share alert url action', () => {
|
||||
it('should render share button in the title and copy the the value to clipboard if document is an alert', () => {
|
||||
const syncedFlyoutState = 'flyoutState';
|
||||
const query = `?${URL_PARAM_KEY.eventFlyout}=${syncedFlyoutState}`;
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
search: query,
|
||||
},
|
||||
});
|
||||
|
||||
it('should render share button in the title', () => {
|
||||
const { getByTestId } = renderHeaderActions(mockContextValue);
|
||||
const shareButton = getByTestId(SHARE_BUTTON_TEST_ID);
|
||||
expect(shareButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(shareButton);
|
||||
|
||||
expect(copyToClipboard).toHaveBeenCalledWith(
|
||||
`${alertUrl}&${URL_PARAM_KEY.eventFlyout}=${syncedFlyoutState}`
|
||||
);
|
||||
});
|
||||
|
||||
it('should not render share button in the title if alert is missing url info', () => {
|
||||
|
|
|
@ -10,8 +10,6 @@ import React, { memo } from 'react';
|
|||
import { EuiButtonIcon, EuiCopy, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { NewChatByTitle } from '@kbn/elastic-assistant';
|
||||
import { URL_PARAM_KEY } from '../../../../common/hooks/use_url_state';
|
||||
import { copyFunction } from '../../../shared/utils/copy_to_clipboard';
|
||||
import { useGetAlertDetailsFlyoutLink } from '../../../../timelines/components/side_panel/event_details/use_get_alert_details_flyout_link';
|
||||
import { useBasicDataFromDetailsData } from '../../../../timelines/components/side_panel/event_details/helpers';
|
||||
import { useAssistant } from '../hooks/use_assistant';
|
||||
|
@ -37,11 +35,6 @@ export const HeaderActions: VFC = memo(() => {
|
|||
|
||||
const showShareAlertButton = isAlert && alertDetailsLink;
|
||||
|
||||
const modifier = (value: string) => {
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
return `${value}&${URL_PARAM_KEY.eventFlyout}=${query.get(URL_PARAM_KEY.eventFlyout)}`;
|
||||
};
|
||||
|
||||
const { showAssistant, promptContextId } = useAssistant({
|
||||
dataFormattedForFieldBrowser,
|
||||
isAlert,
|
||||
|
@ -84,8 +77,8 @@ export const HeaderActions: VFC = memo(() => {
|
|||
{ defaultMessage: 'Share alert' }
|
||||
)}
|
||||
data-test-subj={SHARE_BUTTON_TEST_ID}
|
||||
onClick={() => copyFunction(copy, alertDetailsLink, modifier)}
|
||||
onKeyDown={() => copyFunction(copy, alertDetailsLink, modifier)}
|
||||
onClick={copy}
|
||||
onKeyDown={copy}
|
||||
/>
|
||||
)}
|
||||
</EuiCopy>
|
||||
|
|
|
@ -6,16 +6,14 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
|
||||
import { copyToClipboard } from '@elastic/eui';
|
||||
import { RightPanelContext } from '../context';
|
||||
import { JsonTab } from './json_tab';
|
||||
import { JSON_TAB_CONTENT_TEST_ID, JSON_TAB_COPY_TO_CLIPBOARD_BUTTON_TEST_ID } from './test_ids';
|
||||
|
||||
jest.mock('@elastic/eui', () => ({
|
||||
...jest.requireActual('@elastic/eui'),
|
||||
copyToClipboard: jest.fn(),
|
||||
EuiCopy: jest.fn(({ children: functionAsChild }) => functionAsChild(jest.fn())),
|
||||
}));
|
||||
|
||||
|
@ -47,9 +45,5 @@ describe('<JsonTab />', () => {
|
|||
|
||||
const copyToClipboardButton = getByTestId(JSON_TAB_COPY_TO_CLIPBOARD_BUTTON_TEST_ID);
|
||||
expect(copyToClipboardButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(copyToClipboardButton);
|
||||
|
||||
expect(copyToClipboard).toHaveBeenCalledWith(JSON.stringify(searchHit, null, 2));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -11,7 +11,6 @@ import { JsonCodeEditor } from '@kbn/unified-doc-viewer-plugin/public';
|
|||
import { EuiButtonEmpty, EuiCopy, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { copyFunction } from '../../../shared/utils/copy_to_clipboard';
|
||||
import { JSON_TAB_CONTENT_TEST_ID, JSON_TAB_COPY_TO_CLIPBOARD_BUTTON_TEST_ID } from './test_ids';
|
||||
import { useRightPanelContext } from '../context';
|
||||
|
||||
|
@ -68,8 +67,8 @@ export const JsonTab: FC = memo(() => {
|
|||
}
|
||||
)}
|
||||
data-test-subj={JSON_TAB_COPY_TO_CLIPBOARD_BUTTON_TEST_ID}
|
||||
onClick={() => copyFunction(copy, jsonValue)}
|
||||
onKeyDown={() => copyFunction(copy, jsonValue)}
|
||||
onClick={copy}
|
||||
onKeyDown={copy}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.right.jsonTab.copyToClipboardButtonLabel"
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import React, { memo } from 'react';
|
||||
import { ExpandableFlyout, type ExpandableFlyoutProps } from '@kbn/expandable-flyout';
|
||||
import { useEuiTheme } from '@elastic/eui';
|
||||
import type { IsolateHostPanelProps } from './document_details/isolate_host';
|
||||
import {
|
||||
IsolateHostPanel,
|
||||
|
@ -90,8 +91,30 @@ const expandableFlyoutDocumentsPanels: ExpandableFlyoutProps['registeredPanels']
|
|||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Flyout used for the Security Solution application
|
||||
* We keep the default EUI 1000 z-index to ensure it is always rendered behind Timeline (which has a z-index of 1001)
|
||||
*/
|
||||
export const SecuritySolutionFlyout = memo(() => (
|
||||
<ExpandableFlyout registeredPanels={expandableFlyoutDocumentsPanels} paddingSize="none" />
|
||||
));
|
||||
|
||||
SecuritySolutionFlyout.displayName = 'SecuritySolutionFlyout';
|
||||
|
||||
/**
|
||||
* Flyout used in Timeline
|
||||
* We set the z-index to 1002 to ensure it is always rendered above Timeline (which has a z-index of 1001)
|
||||
*/
|
||||
export const TimelineFlyout = memo(() => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
return (
|
||||
<ExpandableFlyout
|
||||
registeredPanels={expandableFlyoutDocumentsPanels}
|
||||
paddingSize="none"
|
||||
customStyles={{ 'z-index': (euiTheme.levels.flyout as number) + 2 }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TimelineFlyout.displayName = 'TimelineFlyout';
|
||||
|
|
|
@ -1,39 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { copyFunction } from './copy_to_clipboard';
|
||||
|
||||
jest.mock('@elastic/eui', () => ({
|
||||
...jest.requireActual('@elastic/eui'),
|
||||
copyToClipboard: jest.fn(),
|
||||
EuiCopy: jest.fn(({ children: functionAsChild }) => functionAsChild(jest.fn())),
|
||||
}));
|
||||
|
||||
describe('copyFunction', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const rawValue = 'rawValue';
|
||||
|
||||
it('should call copy function', () => {
|
||||
const euiCopy = jest.fn();
|
||||
|
||||
copyFunction(euiCopy, rawValue);
|
||||
|
||||
expect(euiCopy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call modifier function if passed', () => {
|
||||
const euiCopy = jest.fn();
|
||||
const modifiedFc = jest.fn();
|
||||
|
||||
copyFunction(euiCopy, rawValue, modifiedFc);
|
||||
|
||||
expect(modifiedFc).toHaveBeenCalledWith(rawValue);
|
||||
});
|
||||
});
|
|
@ -1,31 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { copyToClipboard } from '@elastic/eui';
|
||||
|
||||
/**
|
||||
* Copy to clipboard wrapper component. It allows adding a copy to clipboard functionality to any element.
|
||||
* It expects the value to be copied with an optional function to modify the value if necessary.
|
||||
*
|
||||
* @param copy the copy method from EuiCopy
|
||||
* @param rawValue the value to save to the clipboard
|
||||
* @param modifier a function to modify the raw value before saving to the clipboard
|
||||
*/
|
||||
export const copyFunction = (
|
||||
copy: Function,
|
||||
rawValue: string,
|
||||
modifier?: (rawValue: string) => string
|
||||
) => {
|
||||
copy();
|
||||
|
||||
if (modifier) {
|
||||
const modifiedCopyValue = modifier(rawValue);
|
||||
copyToClipboard(modifiedCopyValue);
|
||||
} else {
|
||||
copyToClipboard(rawValue);
|
||||
}
|
||||
};
|
|
@ -6,13 +6,21 @@
|
|||
*/
|
||||
|
||||
import React, { memo, useCallback, useState } from 'react';
|
||||
import { EuiButton, EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiFlyout,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutHeader,
|
||||
EuiTitle,
|
||||
useEuiTheme,
|
||||
} from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import type { EndpointResponderExtensionComponentProps } from '../types';
|
||||
import { ResponseActionsLog } from '../../endpoint_response_actions_list/response_actions_log';
|
||||
import { UX_MESSAGES } from '../../endpoint_response_actions_list/translations';
|
||||
|
||||
export const ActionLogButton = memo<EndpointResponderExtensionComponentProps>((props) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const [showActionLogFlyout, setShowActionLogFlyout] = useState<boolean>(false);
|
||||
const toggleActionLog = useCallback(() => {
|
||||
setShowActionLogFlyout((prevState) => {
|
||||
|
@ -39,6 +47,8 @@ export const ActionLogButton = memo<EndpointResponderExtensionComponentProps>((p
|
|||
size="m"
|
||||
paddingSize="l"
|
||||
data-test-subj="responderActionLogFlyout"
|
||||
// EUI TODO: This z-index override of EuiOverlayMask is a workaround, and ideally should be resolved with a cleaner UI/UX flow long-term
|
||||
maskProps={{ style: `z-index: ${(euiTheme.levels.flyout as number) + 3}` }} // we need this flyout to be above the timeline flyout (which has a z-index of 1002)
|
||||
>
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiTitle size="m">
|
||||
|
|
|
@ -31,7 +31,9 @@ const OverlayRootContainer = styled.div`
|
|||
height: calc(100% - var(--euiFixedHeadersOffset, 0));
|
||||
width: 100%;
|
||||
|
||||
z-index: ${({ theme: { eui } }) => eui.euiZFlyout};
|
||||
z-index: ${({ theme: { eui } }) =>
|
||||
eui.euiZFlyout +
|
||||
3}; // we need to have this response div rendered above the timeline flyout (with z-index at 1002)
|
||||
|
||||
background-color: ${({ theme: { eui } }) => eui.euiColorEmptyShade};
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import { closeAllToasts } from '../../tasks/toasts';
|
|||
import {
|
||||
getAlertsTableRows,
|
||||
openAlertDetailsView,
|
||||
openAlertDetailsViewFromTimeline,
|
||||
openInvestigateInTimelineView,
|
||||
openResponderFromEndpointAlertDetails,
|
||||
} from '../../screens/alerts';
|
||||
|
@ -104,9 +105,7 @@ describe(
|
|||
|
||||
getAlertsTableRows().should('have.length.greaterThan', 0);
|
||||
openInvestigateInTimelineView();
|
||||
cy.getByTestSubj('timeline-container').within(() => {
|
||||
openAlertDetailsView();
|
||||
});
|
||||
openAlertDetailsViewFromTimeline();
|
||||
openResponderFromEndpointAlertDetails();
|
||||
ensureOnResponder();
|
||||
});
|
||||
|
|
|
@ -47,6 +47,13 @@ export const openAlertDetailsView = (rowIndex: number = 0): void => {
|
|||
cy.getByTestSubj('take-action-dropdown-btn').click();
|
||||
};
|
||||
|
||||
export const openAlertDetailsViewFromTimeline = (rowIndex: number = 0): void => {
|
||||
cy.getByTestSubj('timeline-container').within(() => {
|
||||
cy.getByTestSubj('expand-event').eq(rowIndex).click();
|
||||
});
|
||||
cy.getByTestSubj('take-action-dropdown-btn').click();
|
||||
};
|
||||
|
||||
export const openInvestigateInTimelineView = (): void => {
|
||||
cy.getByTestSubj('send-alert-to-timeline-button').first().click();
|
||||
};
|
||||
|
|
|
@ -28,7 +28,8 @@ export const usePaneStyles = () => {
|
|||
right: 0;
|
||||
bottom: 0;
|
||||
background: ${transparentize(euiTheme.colors.ink, 0.5)};
|
||||
z-index: ${euiTheme.levels.flyout};
|
||||
z-index: ${(euiTheme.levels.flyout as number) +
|
||||
1}; // this z-index needs to be between the eventFlyout (set at 1000) and the timelineFlyout (set at 1002)
|
||||
|
||||
${euiCanAnimate} {
|
||||
animation: ${euiAnimFadeIn} ${euiTheme.animation.fast} ease-in;
|
||||
|
|
|
@ -9,6 +9,11 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
|||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { isEventBuildingBlockType } from '@kbn/securitysolution-data-table';
|
||||
import { useUiSetting$ } from '@kbn/kibana-react-plugin/public';
|
||||
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
|
||||
import { DocumentDetailsRightPanelKey } from '../../../../../flyout/document_details/right';
|
||||
import { ENABLE_EXPANDABLE_FLYOUT_SETTING } from '../../../../../../common/constants';
|
||||
import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector';
|
||||
import type {
|
||||
ColumnHeaderOptions,
|
||||
|
@ -102,6 +107,14 @@ const StatefulEventComponent: React.FC<Props> = ({
|
|||
}) => {
|
||||
const trGroupRef = useRef<HTMLDivElement | null>(null);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const expandableTimelineFlyoutEnabled = useIsExperimentalFeatureEnabled(
|
||||
'expandableTimelineFlyoutEnabled'
|
||||
);
|
||||
|
||||
const { openFlyout } = useExpandableFlyoutApi();
|
||||
const [isSecurityFlyoutEnabled] = useUiSetting$<boolean>(ENABLE_EXPANDABLE_FLYOUT_SETTING);
|
||||
|
||||
// Store context in state rather than creating object in provider value={} to prevent re-renders caused by a new object being created
|
||||
const [activeStatefulEventContext] = useState({
|
||||
timelineID: timelineId,
|
||||
|
@ -199,14 +212,38 @@ const StatefulEventComponent: React.FC<Props> = ({
|
|||
},
|
||||
};
|
||||
|
||||
dispatch(
|
||||
timelineActions.toggleDetailPanel({
|
||||
...updatedExpandedDetail,
|
||||
tabType,
|
||||
id: timelineId,
|
||||
})
|
||||
);
|
||||
}, [dispatch, event._id, event._index, refetch, tabType, timelineId]);
|
||||
if (isSecurityFlyoutEnabled && expandableTimelineFlyoutEnabled) {
|
||||
openFlyout({
|
||||
right: {
|
||||
id: DocumentDetailsRightPanelKey,
|
||||
params: {
|
||||
id: eventId,
|
||||
indexName,
|
||||
scopeId: timelineId,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// opens the panel when clicking on the table row action
|
||||
dispatch(
|
||||
timelineActions.toggleDetailPanel({
|
||||
...updatedExpandedDetail,
|
||||
tabType,
|
||||
id: timelineId,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [
|
||||
dispatch,
|
||||
event._id,
|
||||
event._index,
|
||||
expandableTimelineFlyoutEnabled,
|
||||
isSecurityFlyoutEnabled,
|
||||
openFlyout,
|
||||
refetch,
|
||||
tabType,
|
||||
timelineId,
|
||||
]);
|
||||
|
||||
const associateNote = useCallback(
|
||||
(noteId: string) => {
|
||||
|
|
|
@ -14,14 +14,13 @@ import { TimelineId, TimelineTabs } from '../../../../../../common/types/timelin
|
|||
import { timelineActions } from '../../../../store';
|
||||
import { StatefulEventContext } from '../../../../../common/components/events_viewer/stateful_event_context';
|
||||
import { createTelemetryServiceMock } from '../../../../../common/lib/telemetry/telemetry_service.mock';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
|
||||
import { dataTableActions, TableId } from '@kbn/securitysolution-data-table';
|
||||
|
||||
const mockedTelemetry = createTelemetryServiceMock();
|
||||
const mockUseIsExperimentalFeatureEnabled = jest.fn();
|
||||
const mockOpenRightPanel = jest.fn();
|
||||
|
||||
jest.mock('../../../../../common/hooks/use_experimental_features', () => ({
|
||||
useIsExperimentalFeatureEnabled: () => mockUseIsExperimentalFeatureEnabled,
|
||||
}));
|
||||
jest.mock('../../../../../common/hooks/use_experimental_features');
|
||||
|
||||
jest.mock('@kbn/expandable-flyout', () => {
|
||||
return {
|
||||
|
@ -69,7 +68,22 @@ jest.mock('../../../../store', () => {
|
|||
};
|
||||
});
|
||||
|
||||
jest.mock('@kbn/securitysolution-data-table', () => {
|
||||
const original = jest.requireActual('@kbn/securitysolution-data-table');
|
||||
return {
|
||||
...original,
|
||||
dataTableActions: {
|
||||
...original.dataTableActions,
|
||||
toggleDetailPanel: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('HostName', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const props = {
|
||||
fieldName: 'host.name',
|
||||
contextId: 'test-context-id',
|
||||
|
@ -106,7 +120,7 @@ describe('HostName', () => {
|
|||
expect(wrapper.find('[data-test-subj="DefaultDraggable"]').exists()).toEqual(true);
|
||||
});
|
||||
|
||||
test('if not enableHostDetailsFlyout, should go to hostdetails page', async () => {
|
||||
test('should not open any flyout or panels if context in not defined', async () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<HostName {...props} />
|
||||
|
@ -115,11 +129,99 @@ describe('HostName', () => {
|
|||
|
||||
wrapper.find('[data-test-subj="host-details-button"]').last().simulate('click');
|
||||
await waitFor(() => {
|
||||
expect(dataTableActions.toggleDetailPanel).not.toHaveBeenCalled();
|
||||
expect(timelineActions.toggleDetailPanel).not.toHaveBeenCalled();
|
||||
expect(mockOpenRightPanel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('if enableHostDetailsFlyout, should open HostDetailsSidePanel', async () => {
|
||||
test('should not open any flyout or panels if enableHostDetailsFlyout is false', async () => {
|
||||
const context = {
|
||||
enableHostDetailsFlyout: false,
|
||||
enableIpDetailsFlyout: true,
|
||||
timelineID: TimelineId.active,
|
||||
tabType: TimelineTabs.query,
|
||||
};
|
||||
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<StatefulEventContext.Provider value={context}>
|
||||
<HostName {...props} />
|
||||
</StatefulEventContext.Provider>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
wrapper.find('[data-test-subj="host-details-button"]').last().simulate('click');
|
||||
await waitFor(() => {
|
||||
expect(dataTableActions.toggleDetailPanel).not.toHaveBeenCalled();
|
||||
expect(timelineActions.toggleDetailPanel).not.toHaveBeenCalled();
|
||||
expect(mockOpenRightPanel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should not open any flyout or panels if timelineID is not defined', async () => {
|
||||
const context = {
|
||||
enableHostDetailsFlyout: true,
|
||||
enableIpDetailsFlyout: true,
|
||||
timelineID: '',
|
||||
tabType: TimelineTabs.query,
|
||||
};
|
||||
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<StatefulEventContext.Provider value={context}>
|
||||
<HostName {...props} />
|
||||
</StatefulEventContext.Provider>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
wrapper.find('[data-test-subj="host-details-button"]').last().simulate('click');
|
||||
await waitFor(() => {
|
||||
expect(dataTableActions.toggleDetailPanel).not.toHaveBeenCalled();
|
||||
expect(timelineActions.toggleDetailPanel).not.toHaveBeenCalled();
|
||||
expect(mockOpenRightPanel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should open old flyout on table', async () => {
|
||||
(useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation((feature: string) => {
|
||||
if (feature === 'newHostDetailsFlyout') return false;
|
||||
if (feature === 'expandableTimelineFlyoutEnabled') return false;
|
||||
});
|
||||
const context = {
|
||||
enableHostDetailsFlyout: true,
|
||||
enableIpDetailsFlyout: true,
|
||||
timelineID: TableId.alertsOnAlertsPage,
|
||||
tabType: TimelineTabs.query,
|
||||
};
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<StatefulEventContext.Provider value={context}>
|
||||
<HostName {...props} />
|
||||
</StatefulEventContext.Provider>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
wrapper.find('[data-test-subj="host-details-button"]').last().simulate('click');
|
||||
await waitFor(() => {
|
||||
expect(dataTableActions.toggleDetailPanel).toHaveBeenCalledWith({
|
||||
id: context.timelineID,
|
||||
panelView: 'hostDetail',
|
||||
params: {
|
||||
hostName: props.value,
|
||||
},
|
||||
tabType: context.tabType,
|
||||
});
|
||||
expect(timelineActions.toggleDetailPanel).not.toHaveBeenCalled();
|
||||
expect(mockOpenRightPanel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should open old flyout in timeline', async () => {
|
||||
(useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation((feature: string) => {
|
||||
if (feature === 'newHostDetailsFlyout') return false;
|
||||
if (feature === 'expandableTimelineFlyoutEnabled') return false;
|
||||
});
|
||||
const context = {
|
||||
enableHostDetailsFlyout: true,
|
||||
enableIpDetailsFlyout: true,
|
||||
|
@ -144,15 +246,20 @@ describe('HostName', () => {
|
|||
},
|
||||
tabType: context.tabType,
|
||||
});
|
||||
expect(dataTableActions.toggleDetailPanel).not.toHaveBeenCalled();
|
||||
expect(mockOpenRightPanel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('it should open expandable flyout if timeline is not in context and experimental flag is enabled', async () => {
|
||||
mockUseIsExperimentalFeatureEnabled.mockReturnValue(true);
|
||||
test('should open expandable flyout on table', async () => {
|
||||
(useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation((feature: string) => {
|
||||
if (feature === 'newHostDetailsFlyout') return true;
|
||||
if (feature === 'expandableTimelineFlyoutEnabled') return false;
|
||||
});
|
||||
const context = {
|
||||
enableHostDetailsFlyout: true,
|
||||
enableIpDetailsFlyout: true,
|
||||
timelineID: 'fake-timeline',
|
||||
timelineID: TableId.alertsOnAlertsPage,
|
||||
tabType: TimelineTabs.query,
|
||||
};
|
||||
const wrapper = mount(
|
||||
|
@ -165,7 +272,52 @@ describe('HostName', () => {
|
|||
|
||||
wrapper.find('[data-test-subj="host-details-button"]').last().simulate('click');
|
||||
await waitFor(() => {
|
||||
expect(mockOpenRightPanel).toHaveBeenCalled();
|
||||
expect(mockOpenRightPanel).toHaveBeenCalledWith({
|
||||
id: 'host-panel',
|
||||
params: {
|
||||
hostName: props.value,
|
||||
contextID: props.contextId,
|
||||
scopeId: TableId.alertsOnAlertsPage,
|
||||
isDraggable: false,
|
||||
},
|
||||
});
|
||||
expect(timelineActions.toggleDetailPanel).not.toHaveBeenCalled();
|
||||
expect(dataTableActions.toggleDetailPanel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should open expandable flyout in timeline', async () => {
|
||||
(useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation((feature: string) => {
|
||||
if (feature === 'newHostDetailsFlyout') return true;
|
||||
if (feature === 'expandableTimelineFlyoutEnabled') return true;
|
||||
});
|
||||
const context = {
|
||||
enableHostDetailsFlyout: true,
|
||||
enableIpDetailsFlyout: true,
|
||||
timelineID: TimelineId.active,
|
||||
tabType: TimelineTabs.query,
|
||||
};
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<StatefulEventContext.Provider value={context}>
|
||||
<HostName {...props} />
|
||||
</StatefulEventContext.Provider>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
wrapper.find('[data-test-subj="host-details-button"]').last().simulate('click');
|
||||
await waitFor(() => {
|
||||
expect(mockOpenRightPanel).toHaveBeenCalledWith({
|
||||
id: 'host-panel',
|
||||
params: {
|
||||
hostName: props.value,
|
||||
contextID: props.contextId,
|
||||
scopeId: TableId.alertsOnAlertsPage,
|
||||
isDraggable: false,
|
||||
},
|
||||
});
|
||||
expect(timelineActions.toggleDetailPanel).not.toHaveBeenCalled();
|
||||
expect(dataTableActions.toggleDetailPanel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -50,6 +50,9 @@ const HostNameComponent: React.FC<Props> = ({
|
|||
value,
|
||||
}) => {
|
||||
const isNewHostDetailsFlyoutEnabled = useIsExperimentalFeatureEnabled('newHostDetailsFlyout');
|
||||
const expandableTimelineFlyoutEnabled = useIsExperimentalFeatureEnabled(
|
||||
'expandableTimelineFlyoutEnabled'
|
||||
);
|
||||
const { openRightPanel } = useExpandableFlyoutApi();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
@ -57,6 +60,7 @@ const HostNameComponent: React.FC<Props> = ({
|
|||
const hostName = `${value}`;
|
||||
const isInTimelineContext =
|
||||
hostName && eventContext?.enableHostDetailsFlyout && eventContext?.timelineID;
|
||||
|
||||
const openHostDetailsSidePanel = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
|
@ -65,49 +69,63 @@ const HostNameComponent: React.FC<Props> = ({
|
|||
onClick();
|
||||
}
|
||||
|
||||
if (eventContext && isInTimelineContext) {
|
||||
const { timelineID, tabType } = eventContext;
|
||||
if (!eventContext || !isInTimelineContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNewHostDetailsFlyoutEnabled && !isTimelineScope(timelineID)) {
|
||||
openRightPanel({
|
||||
id: HostPanelKey,
|
||||
params: {
|
||||
hostName,
|
||||
contextID: contextId,
|
||||
scopeId: TableId.alertsOnAlertsPage,
|
||||
isDraggable,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const updatedExpandedDetail: ExpandedDetailType = {
|
||||
panelView: 'hostDetail',
|
||||
params: {
|
||||
hostName,
|
||||
},
|
||||
};
|
||||
const scopedActions = getScopedActions(timelineID);
|
||||
if (scopedActions) {
|
||||
dispatch(
|
||||
scopedActions.toggleDetailPanel({
|
||||
...updatedExpandedDetail,
|
||||
id: timelineID,
|
||||
tabType: tabType as TimelineTabs,
|
||||
})
|
||||
);
|
||||
}
|
||||
const { timelineID, tabType } = eventContext;
|
||||
|
||||
const openNewFlyout = () =>
|
||||
openRightPanel({
|
||||
id: HostPanelKey,
|
||||
params: {
|
||||
hostName,
|
||||
contextID: contextId,
|
||||
scopeId: TableId.alertsOnAlertsPage,
|
||||
isDraggable,
|
||||
},
|
||||
});
|
||||
const openOldFlyout = () => {
|
||||
const updatedExpandedDetail: ExpandedDetailType = {
|
||||
panelView: 'hostDetail',
|
||||
params: {
|
||||
hostName,
|
||||
},
|
||||
};
|
||||
const scopedActions = getScopedActions(timelineID);
|
||||
if (scopedActions) {
|
||||
dispatch(
|
||||
scopedActions.toggleDetailPanel({
|
||||
...updatedExpandedDetail,
|
||||
id: timelineID,
|
||||
tabType: tabType as TimelineTabs,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
(isTimelineScope(timelineID) &&
|
||||
isNewHostDetailsFlyoutEnabled &&
|
||||
expandableTimelineFlyoutEnabled) ||
|
||||
isNewHostDetailsFlyoutEnabled
|
||||
) {
|
||||
openNewFlyout();
|
||||
} else {
|
||||
openOldFlyout();
|
||||
}
|
||||
},
|
||||
[
|
||||
onClick,
|
||||
contextId,
|
||||
dispatch,
|
||||
eventContext,
|
||||
expandableTimelineFlyoutEnabled,
|
||||
hostName,
|
||||
isDraggable,
|
||||
isInTimelineContext,
|
||||
isNewHostDetailsFlyoutEnabled,
|
||||
onClick,
|
||||
openRightPanel,
|
||||
hostName,
|
||||
contextId,
|
||||
isDraggable,
|
||||
dispatch,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import React from 'react';
|
||||
import React, { type PropsWithChildren } from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
|
||||
|
@ -14,8 +14,22 @@ import { timelineActions } from '../../../../store';
|
|||
import { UserName } from './user_name';
|
||||
import { StatefulEventContext } from '../../../../../common/components/events_viewer/stateful_event_context';
|
||||
import { createTelemetryServiceMock } from '../../../../../common/lib/telemetry/telemetry_service.mock';
|
||||
import { dataTableActions, TableId } from '@kbn/securitysolution-data-table';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
|
||||
|
||||
const mockedTelemetry = createTelemetryServiceMock();
|
||||
const mockOpenRightPanel = jest.fn();
|
||||
|
||||
jest.mock('../../../../../common/hooks/use_experimental_features');
|
||||
|
||||
jest.mock('@kbn/expandable-flyout', () => {
|
||||
return {
|
||||
useExpandableFlyoutApi: () => ({
|
||||
openRightPanel: mockOpenRightPanel,
|
||||
}),
|
||||
TestProvider: ({ children }: PropsWithChildren<{}>) => <>{children}</>,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('react-redux', () => {
|
||||
const origin = jest.requireActual('react-redux');
|
||||
|
@ -54,7 +68,22 @@ jest.mock('../../../../store', () => {
|
|||
};
|
||||
});
|
||||
|
||||
jest.mock('@kbn/securitysolution-data-table', () => {
|
||||
const original = jest.requireActual('@kbn/securitysolution-data-table');
|
||||
return {
|
||||
...original,
|
||||
dataTableActions: {
|
||||
...original.dataTableActions,
|
||||
toggleDetailPanel: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('UserName', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const props = {
|
||||
fieldName: 'user.name',
|
||||
fieldType: 'keyword',
|
||||
|
@ -89,11 +118,88 @@ describe('UserName', () => {
|
|||
expect(wrapper.find('[data-test-subj="DefaultDraggable"]').exists()).toEqual(true);
|
||||
});
|
||||
|
||||
test('if newUserDetailsFlyout, should open UserDetailsSidePanel', async () => {
|
||||
test('should not open any flyout or panels if context in not defined', async () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<UserName {...props} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
wrapper.find('[data-test-subj="users-link-anchor"]').last().simulate('click');
|
||||
await waitFor(() => {
|
||||
expect(dataTableActions.toggleDetailPanel).not.toHaveBeenCalled();
|
||||
expect(timelineActions.toggleDetailPanel).not.toHaveBeenCalled();
|
||||
expect(mockOpenRightPanel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should not open any flyout or panels if timelineID is not defined', async () => {
|
||||
const context = {
|
||||
enableHostDetailsFlyout: true,
|
||||
enableIpDetailsFlyout: true,
|
||||
timelineID: TimelineId.test,
|
||||
timelineID: '',
|
||||
tabType: TimelineTabs.query,
|
||||
};
|
||||
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<StatefulEventContext.Provider value={context}>
|
||||
<UserName {...props} />
|
||||
</StatefulEventContext.Provider>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
wrapper.find('[data-test-subj="users-link-anchor"]').last().simulate('click');
|
||||
await waitFor(() => {
|
||||
expect(dataTableActions.toggleDetailPanel).not.toHaveBeenCalled();
|
||||
expect(timelineActions.toggleDetailPanel).not.toHaveBeenCalled();
|
||||
expect(mockOpenRightPanel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should open old flyout on table', async () => {
|
||||
(useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation((feature: string) => {
|
||||
if (feature === 'newUserDetailsFlyout') return false;
|
||||
if (feature === 'expandableTimelineFlyoutEnabled') return false;
|
||||
});
|
||||
const context = {
|
||||
enableHostDetailsFlyout: true,
|
||||
enableIpDetailsFlyout: true,
|
||||
timelineID: TableId.alertsOnAlertsPage,
|
||||
tabType: TimelineTabs.query,
|
||||
};
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<StatefulEventContext.Provider value={context}>
|
||||
<UserName {...props} />
|
||||
</StatefulEventContext.Provider>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
wrapper.find('[data-test-subj="users-link-anchor"]').last().simulate('click');
|
||||
await waitFor(() => {
|
||||
expect(dataTableActions.toggleDetailPanel).toHaveBeenCalledWith({
|
||||
id: context.timelineID,
|
||||
panelView: 'userDetail',
|
||||
params: {
|
||||
userName: props.value,
|
||||
},
|
||||
tabType: context.tabType,
|
||||
});
|
||||
expect(timelineActions.toggleDetailPanel).not.toHaveBeenCalled();
|
||||
expect(mockOpenRightPanel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should open old flyout in timeline', async () => {
|
||||
(useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation((feature: string) => {
|
||||
if (feature === 'newUserDetailsFlyout') return false;
|
||||
if (feature === 'expandableTimelineFlyoutEnabled') return false;
|
||||
});
|
||||
const context = {
|
||||
enableHostDetailsFlyout: true,
|
||||
enableIpDetailsFlyout: true,
|
||||
timelineID: TimelineId.active,
|
||||
tabType: TimelineTabs.query,
|
||||
};
|
||||
const wrapper = mount(
|
||||
|
@ -114,6 +220,78 @@ describe('UserName', () => {
|
|||
},
|
||||
tabType: context.tabType,
|
||||
});
|
||||
expect(dataTableActions.toggleDetailPanel).not.toHaveBeenCalled();
|
||||
expect(mockOpenRightPanel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should open expandable flyout on table', async () => {
|
||||
(useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation((feature: string) => {
|
||||
if (feature === 'newUserDetailsFlyout') return true;
|
||||
if (feature === 'expandableTimelineFlyoutEnabled') return false;
|
||||
});
|
||||
const context = {
|
||||
enableHostDetailsFlyout: true,
|
||||
enableIpDetailsFlyout: true,
|
||||
timelineID: TableId.alertsOnAlertsPage,
|
||||
tabType: TimelineTabs.query,
|
||||
};
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<StatefulEventContext.Provider value={context}>
|
||||
<UserName {...props} />
|
||||
</StatefulEventContext.Provider>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
wrapper.find('[data-test-subj="users-link-anchor"]').last().simulate('click');
|
||||
await waitFor(() => {
|
||||
expect(mockOpenRightPanel).toHaveBeenCalledWith({
|
||||
id: 'user-panel',
|
||||
params: {
|
||||
userName: props.value,
|
||||
contextID: props.contextId,
|
||||
scopeId: TableId.alertsOnAlertsPage,
|
||||
isDraggable: false,
|
||||
},
|
||||
});
|
||||
expect(timelineActions.toggleDetailPanel).not.toHaveBeenCalled();
|
||||
expect(dataTableActions.toggleDetailPanel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should open expandable flyout in timeline', async () => {
|
||||
(useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation((feature: string) => {
|
||||
if (feature === 'newUserDetailsFlyout') return true;
|
||||
if (feature === 'expandableTimelineFlyoutEnabled') return true;
|
||||
});
|
||||
const context = {
|
||||
enableHostDetailsFlyout: true,
|
||||
enableIpDetailsFlyout: true,
|
||||
timelineID: TimelineId.active,
|
||||
tabType: TimelineTabs.query,
|
||||
};
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<StatefulEventContext.Provider value={context}>
|
||||
<UserName {...props} />
|
||||
</StatefulEventContext.Provider>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
wrapper.find('[data-test-subj="users-link-anchor"]').last().simulate('click');
|
||||
await waitFor(() => {
|
||||
expect(mockOpenRightPanel).toHaveBeenCalledWith({
|
||||
id: 'user-panel',
|
||||
params: {
|
||||
userName: props.value,
|
||||
contextID: props.contextId,
|
||||
scopeId: TableId.alertsOnAlertsPage,
|
||||
isDraggable: false,
|
||||
},
|
||||
});
|
||||
expect(timelineActions.toggleDetailPanel).not.toHaveBeenCalled();
|
||||
expect(dataTableActions.toggleDetailPanel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -52,6 +52,9 @@ const UserNameComponent: React.FC<Props> = ({
|
|||
const dispatch = useDispatch();
|
||||
const eventContext = useContext(StatefulEventContext);
|
||||
const isNewUserDetailsFlyoutEnable = useIsExperimentalFeatureEnabled('newUserDetailsFlyout');
|
||||
const expandableTimelineFlyoutEnabled = useIsExperimentalFeatureEnabled(
|
||||
'expandableTimelineFlyoutEnabled'
|
||||
);
|
||||
const userName = `${value}`;
|
||||
const isInTimelineContext = userName && eventContext?.timelineID;
|
||||
const { openRightPanel } = useExpandableFlyoutApi();
|
||||
|
@ -64,49 +67,64 @@ const UserNameComponent: React.FC<Props> = ({
|
|||
onClick();
|
||||
}
|
||||
|
||||
if (eventContext && isInTimelineContext) {
|
||||
const { timelineID, tabType } = eventContext;
|
||||
if (!eventContext || !isInTimelineContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNewUserDetailsFlyoutEnable && !isTimelineScope(timelineID)) {
|
||||
openRightPanel({
|
||||
id: UserPanelKey,
|
||||
params: {
|
||||
userName,
|
||||
contextID: contextId,
|
||||
scopeId: TableId.alertsOnAlertsPage,
|
||||
isDraggable,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const updatedExpandedDetail: ExpandedDetailType = {
|
||||
panelView: 'userDetail',
|
||||
params: {
|
||||
userName,
|
||||
},
|
||||
};
|
||||
const scopedActions = getScopedActions(timelineID);
|
||||
if (scopedActions) {
|
||||
dispatch(
|
||||
scopedActions.toggleDetailPanel({
|
||||
...updatedExpandedDetail,
|
||||
id: timelineID,
|
||||
tabType: tabType as TimelineTabs,
|
||||
})
|
||||
);
|
||||
}
|
||||
const { timelineID, tabType } = eventContext;
|
||||
|
||||
const openNewFlyout = () =>
|
||||
openRightPanel({
|
||||
id: UserPanelKey,
|
||||
params: {
|
||||
userName,
|
||||
contextID: contextId,
|
||||
scopeId: TableId.alertsOnAlertsPage,
|
||||
isDraggable,
|
||||
},
|
||||
});
|
||||
|
||||
const openOldFlyout = () => {
|
||||
const updatedExpandedDetail: ExpandedDetailType = {
|
||||
panelView: 'userDetail',
|
||||
params: {
|
||||
userName,
|
||||
},
|
||||
};
|
||||
const scopedActions = getScopedActions(timelineID);
|
||||
if (scopedActions) {
|
||||
dispatch(
|
||||
scopedActions.toggleDetailPanel({
|
||||
...updatedExpandedDetail,
|
||||
id: timelineID,
|
||||
tabType: tabType as TimelineTabs,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
(isTimelineScope(timelineID) &&
|
||||
isNewUserDetailsFlyoutEnable &&
|
||||
expandableTimelineFlyoutEnabled) ||
|
||||
isNewUserDetailsFlyoutEnable
|
||||
) {
|
||||
openNewFlyout();
|
||||
} else {
|
||||
openOldFlyout();
|
||||
}
|
||||
},
|
||||
[
|
||||
onClick,
|
||||
contextId,
|
||||
dispatch,
|
||||
eventContext,
|
||||
isNewUserDetailsFlyoutEnable,
|
||||
expandableTimelineFlyoutEnabled,
|
||||
isDraggable,
|
||||
isInTimelineContext,
|
||||
isNewUserDetailsFlyoutEnable,
|
||||
onClick,
|
||||
openRightPanel,
|
||||
userName,
|
||||
contextId,
|
||||
isDraggable,
|
||||
dispatch,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -1,47 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { ID_HEADER_FIELD, TIMESTAMP_HEADER_FIELD } from '../../../screens/timeline';
|
||||
|
||||
import { login } from '../../../tasks/login';
|
||||
import { visitWithTimeRange } from '../../../tasks/navigation';
|
||||
import { openTimelineUsingToggle } from '../../../tasks/security_main';
|
||||
import {
|
||||
clickIdToggleField,
|
||||
expandFirstTimelineEventDetails,
|
||||
populateTimeline,
|
||||
clickTimestampToggleField,
|
||||
} from '../../../tasks/timeline';
|
||||
|
||||
import { hostsUrl } from '../../../urls/navigation';
|
||||
|
||||
describe('toggle column in timeline', { tags: ['@ess', '@serverless'] }, () => {
|
||||
before(() => {
|
||||
cy.intercept('POST', '/api/timeline/_export?file_name=timelines_export.ndjson').as('export');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
login();
|
||||
visitWithTimeRange(hostsUrl('allHosts'));
|
||||
openTimelineUsingToggle();
|
||||
populateTimeline();
|
||||
});
|
||||
|
||||
it('removes the @timestamp field from the timeline when the user un-checks the toggle', () => {
|
||||
expandFirstTimelineEventDetails();
|
||||
clickTimestampToggleField();
|
||||
|
||||
cy.get(TIMESTAMP_HEADER_FIELD).should('not.exist');
|
||||
});
|
||||
|
||||
it('adds the _id field to the timeline when the user checks the field', () => {
|
||||
expandFirstTimelineEventDetails();
|
||||
clickIdToggleField();
|
||||
|
||||
cy.get(ID_HEADER_FIELD).should('exist');
|
||||
});
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue