[Security Solution] Make Timeline a modal (#170853)

## Summary

Issue: https://github.com/elastic/kibana/issues/169021

Changes the Timeline layout to look like a modal.
These are only visual changes, all existing behaviors in the Timeline
will stay the same.

The Timeline styles are custom, `EuiOverlayMask` styles have been
applied to the wrapper, and `margin` to the timeline content, to have a
modal-like look and feel, as defined.

Since the Timeline is always rendered and just "hidden" when closed,
using `EuiModal` or `EuiOverlayMask` directly resulted in a more complex
implementation for different reasons, especially because they set
`overflow: hidden` globally to the document `body` when rendered. This
PR does the same thing but only when the timeline is "visible"

### Test

1. Open the Security app (Serverless or ESS)
2. Open Timeline, it should look as in the design specification.
3. All the existing behaviors in the Timeline should keep working the
same way.

## Screenshots

Serverless:

<img width="1717" alt="Captura de pantalla 2023-11-08 a les 13 10 22"
src="a6667c29-f2df-43fc-90f1-716c74468d71">


ESS:

<img width="1717" alt="Captura de pantalla 2023-11-08 a les 13 15 06"
src="e6973cca-111f-4715-b053-732207c5f399">
<img width="1717" alt="Captura de pantalla 2023-11-08 a les 13 15 18"
src="a2956475-cca2-43a8-8f83-6672ad68582b">

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Sergi Massaneda 2023-11-17 11:39:19 +01:00 committed by GitHub
parent d727eae163
commit bf054059c8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 344 additions and 132 deletions

View file

@ -1,65 +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 { render, waitFor } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../../common/mock';
import { TimelineId } from '../../../../../common/types/timeline';
import { Pane } from '.';
import { useGetUserCasesPermissions } from '../../../../common/lib/kibana';
jest.mock('../../../../common/lib/kibana');
const originalKibanaLib = jest.requireActual('../../../../common/lib/kibana');
jest.mock('@kbn/i18n-react', () => {
const originalModule = jest.requireActual('@kbn/i18n-react');
const FormattedRelative = jest.fn().mockImplementation(() => '20 hours ago');
return {
...originalModule,
FormattedRelative,
};
});
// Restore the useGetUserCasesPermissions so the calling functions can receive a valid permissions object
// The returned permissions object will indicate that the user does not have permissions by default
const mockUseGetUserCasesPermissions = useGetUserCasesPermissions as jest.Mock;
mockUseGetUserCasesPermissions.mockImplementation(originalKibanaLib.useGetUserCasesPermissions);
jest.mock('../../../../common/utils/normalize_time_range');
jest.mock('../../../../common/hooks/use_resolve_conflict', () => {
return {
useResolveConflict: jest.fn().mockImplementation(() => null),
};
});
// FLAKY: https://github.com/elastic/kibana/issues/168026
describe.skip('Pane', () => {
test('renders with display block by default', async () => {
const EmptyComponent = render(
<TestProviders>
<Pane timelineId={TimelineId.test} />
</TestProviders>
);
await waitFor(() => {
expect(EmptyComponent.getByTestId('flyout-pane')).toHaveStyle('display: block');
});
});
test.skip('renders with display none when visibility is set to false', async () => {
const EmptyComponent = render(
<TestProviders>
<Pane timelineId={TimelineId.test} visible={false} />
</TestProviders>
);
await waitFor(() => {
expect(EmptyComponent.getByTestId('timeline-flyout')).toHaveStyle('display: none');
});
});
});

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { Pane } from './pane';

View file

@ -0,0 +1,80 @@
/*
* 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 { css } from '@emotion/css';
import { Global } from '@emotion/react';
import {
useEuiTheme,
euiAnimFadeIn,
transparentize,
euiBackgroundColor,
euiCanAnimate,
euiAnimSlideInUp,
} from '@elastic/eui';
export const usePaneStyles = () => {
const EuiTheme = useEuiTheme();
const { euiTheme } = EuiTheme;
return css`
// euiOverlayMask styles
position: fixed;
top: var(--euiFixedHeadersOffset, 0);
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: ${transparentize(euiTheme.colors.ink, 0.5)};
z-index: ${euiTheme.levels.flyout};
${euiCanAnimate} {
animation: ${euiAnimFadeIn} ${euiTheme.animation.fast} ease-in;
}
&.timeline-wrapper--hidden {
display: none;
}
.timeline-flyout {
min-width: 150px;
height: inherit;
position: fixed;
top: var(--euiFixedHeadersOffset, 0);
right: 0;
bottom: 0;
left: 0;
background: ${euiBackgroundColor(EuiTheme, 'plain')};
${euiCanAnimate} {
animation: ${euiAnimSlideInUp(euiTheme.size.xxl)} ${euiTheme.animation.normal}
cubic-bezier(0.39, 0.575, 0.565, 1);
}
.timeline-body {
height: 100%;
display: flex;
flex-direction: column;
}
}
&:not(.timeline-wrapper--full-screen) .timeline-flyout {
margin: ${euiTheme.size.m};
border-radius: ${euiTheme.border.radius.medium};
.timeline-template-badge {
border-radius: ${euiTheme.border.radius.medium} ${euiTheme.border.radius.medium} 0 0; // top corners only
}
.timeline-body {
padding: 0 ${euiTheme.size.s};
}
}
`;
};
export const OverflowHiddenGlobalStyles = () => {
return <Global styles={'body { overflow: hidden }'} />;
};

View file

@ -0,0 +1,62 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../../common/mock';
import { TimelineId } from '../../../../../common/types/timeline';
import { Pane } from '.';
jest.mock('../../timeline', () => ({
StatefulTimeline: () => <div data-test-subj="StatefulTimelineMock" />,
}));
const mockIsFullScreen = jest.fn(() => false);
jest.mock('../../../../common/store/selectors', () => ({
inputsSelectors: { timelineFullScreenSelector: () => mockIsFullScreen() },
}));
describe('Pane', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render the timeline', async () => {
const wrapper = render(
<TestProviders>
<Pane timelineId={TimelineId.test} />
</TestProviders>
);
expect(wrapper.getByTestId('StatefulTimelineMock')).toBeInTheDocument();
});
it('should render without fullscreen className', async () => {
mockIsFullScreen.mockReturnValue(false);
const wrapper = render(
<TestProviders>
<Pane timelineId={TimelineId.test} />
</TestProviders>
);
expect(wrapper.getByTestId('timeline-wrapper')).not.toHaveClass(
'timeline-wrapper--full-screen'
);
});
it('should render with fullscreen className', async () => {
mockIsFullScreen.mockReturnValue(true);
const wrapper = render(
<TestProviders>
<Pane timelineId={TimelineId.test} />
</TestProviders>
);
expect(wrapper.getByTestId('timeline-wrapper')).toHaveClass('timeline-wrapper--full-screen');
});
});

View file

@ -5,16 +5,18 @@
* 2.0.
*/
import React, { useMemo, useRef } from 'react';
import { css } from '@emotion/react';
import { useEuiBackgroundColor, useEuiTheme } from '@elastic/eui';
import React, { useRef } from 'react';
import classNames from 'classnames';
import { StatefulTimeline } from '../../timeline';
import type { TimelineId } from '../../../../../common/types/timeline';
import * as i18n from './translations';
import { defaultRowRenderers } from '../../timeline/body/renderers';
import { DefaultCellRenderer } from '../../timeline/cell_rendering/default_cell_renderer';
import { EuiPortal } from './custom_portal';
import { useShallowEqualSelector } from '../../../../common/hooks/use_selector';
import { inputsSelectors } from '../../../../common/store/selectors';
import { usePaneStyles, OverflowHiddenGlobalStyles } from './pane.styles';
interface FlyoutPaneComponentProps {
timelineId: TimelineId;
visible?: boolean;
@ -24,41 +26,33 @@ const FlyoutPaneComponent: React.FC<FlyoutPaneComponentProps> = ({
timelineId,
visible = true,
}) => {
const { euiTheme } = useEuiTheme();
const ref = useRef<HTMLDivElement>(null);
const timeline = useMemo(
() => (
<StatefulTimeline
renderCellValue={DefaultCellRenderer}
rowRenderers={defaultRowRenderers}
timelineId={timelineId}
/>
),
[timelineId]
);
const isFullScreen = useShallowEqualSelector(inputsSelectors.timelineFullScreenSelector) ?? false;
const styles = usePaneStyles();
const wrapperClassName = classNames('timeline-wrapper', styles, {
'timeline-wrapper--full-screen': isFullScreen,
'timeline-wrapper--hidden': !visible,
});
return (
<div data-test-subj="flyout-pane" ref={ref}>
<EuiPortal insert={{ sibling: !visible ? ref?.current : null, position: 'after' }}>
<div
aria-label={i18n.TIMELINE_DESCRIPTION}
data-test-subj="timeline-flyout"
css={css`
min-width: 150px;
height: inherit;
bottom: 0;
top: var(--euiFixedHeadersOffset, 0);
left: 0;
background: ${useEuiBackgroundColor('plain')};
position: fixed;
width: 100%;
z-index: ${euiTheme.levels.flyout};
display: ${visible ? 'block' : 'none'};
`}
>
{timeline}
<div data-test-subj="timeline-wrapper" className={wrapperClassName}>
<div
aria-label={i18n.TIMELINE_DESCRIPTION}
data-test-subj="timeline-flyout"
className="timeline-flyout"
>
<StatefulTimeline
renderCellValue={DefaultCellRenderer}
rowRenderers={defaultRowRenderers}
timelineId={timelineId}
/>
</div>
</div>
</EuiPortal>
{visible && <OverflowHiddenGlobalStyles />}
</div>
);
};

View file

@ -193,27 +193,31 @@ const StatefulTimelineComponent: React.FC<Props> = ({
>
<TimelineSavingProgress timelineId={timelineId} />
{timelineType === TimelineType.template && (
<TimelineTemplateBadge>{i18n.TIMELINE_TEMPLATE}</TimelineTemplateBadge>
<TimelineTemplateBadge className="timeline-template-badge">
{i18n.TIMELINE_TEMPLATE}
</TimelineTemplateBadge>
)}
{resolveConflictComponent}
<HideShowContainer
$isVisible={!timelineFullScreen}
data-test-subj="timeline-hide-show-container"
>
<FlyoutHeaderPanel timelineId={timelineId} />
<FlyoutHeader timelineId={timelineId} />
</HideShowContainer>
<div className="timeline-body" data-test-subj="timeline-body">
{resolveConflictComponent}
<HideShowContainer
$isVisible={!timelineFullScreen}
data-test-subj="timeline-hide-show-container"
>
<FlyoutHeaderPanel timelineId={timelineId} />
<FlyoutHeader timelineId={timelineId} />
</HideShowContainer>
<TabsContent
graphEventId={graphEventId}
sessionViewConfig={sessionViewConfig}
renderCellValue={renderCellValue}
rowRenderers={rowRenderers}
timelineId={timelineId}
timelineType={timelineType}
timelineDescription={description}
timelineFullScreen={timelineFullScreen}
/>
<TabsContent
graphEventId={graphEventId}
sessionViewConfig={sessionViewConfig}
renderCellValue={renderCellValue}
rowRenderers={rowRenderers}
timelineId={timelineId}
timelineType={timelineType}
timelineDescription={description}
timelineFullScreen={timelineFullScreen}
/>
</div>
</TimelineContainer>
</TimelineContext.Provider>
);

View file

@ -35,6 +35,7 @@ import { kqlSearch } from '../../../tasks/security_header';
import { hostsUrl } from '../../../urls/navigation';
import { resetFields } from '../../../tasks/timeline';
import { DATA_GRID_EMPTY_STATE } from '../../../screens/events_viewer';
const defaultHeadersInDefaultEcsCategory = [
{ id: '@timestamp' },
@ -118,12 +119,10 @@ describe('Events Viewer', { tags: ['@ess', '@serverless'] }, () => {
it('filters the events by applying filter criteria from the search bar at the top of the page', () => {
const filterInput = 'aa7ca589f1b8220002f2fc61c64cfbf1'; // this will never match real data
cy.get(SERVER_SIDE_EVENT_COUNT)
.invoke('text')
.then((initialNumberOfEvents) => {
kqlSearch(`${filterInput}{enter}`);
cy.get(SERVER_SIDE_EVENT_COUNT).should('not.have.text', initialNumberOfEvents);
});
cy.get(SERVER_SIDE_EVENT_COUNT).should('exist');
kqlSearch(`${filterInput}{enter}`);
cy.get(SERVER_SIDE_EVENT_COUNT).should('not.exist');
cy.get(DATA_GRID_EMPTY_STATE).should('exist');
});
});
});

View file

@ -21,16 +21,21 @@ import {
import { login } from '../../../tasks/login';
import { visitWithTimeRange } from '../../../tasks/navigation';
import { closeTimelineUsingToggle } from '../../../tasks/security_main';
import {
navigateToHostsUsingBreadcrumb,
navigateToExploreUsingBreadcrumb,
navigateToAlertsPageInServerless,
navigateToDiscoverPageInServerless,
navigateToExplorePageInServerless,
} from '../../../tasks/serverless/navigation';
import {
addNameToTimelineAndSave,
createNewTimeline,
populateTimeline,
} from '../../../tasks/timeline';
import { hostsUrl, MANAGE_URL } from '../../../urls/navigation';
import { EXPLORE_URL, hostsUrl, MANAGE_URL } from '../../../urls/navigation';
// https://github.com/elastic/kibana/issues/169021
describe('Save Timeline Prompts', { tags: ['@ess', '@serverless', '@brokenInServerless'] }, () => {
describe('Save Timeline Prompts', { tags: ['@ess'] }, () => {
before(() => {
login();
/*
@ -133,3 +138,90 @@ describe('Save Timeline Prompts', { tags: ['@ess', '@serverless', '@brokenInServ
cy.url().should('not.contain', MANAGE_URL);
});
});
// In serverless it is not possible to use the navigation menu without closing the timeline
describe('Save Timeline Prompts', { tags: ['@serverless'] }, () => {
before(() => {
login();
/*
* When timeline changes are pending, chrome would popup with
* a confirm dialog stating that `you can lose unsaved changed.
* Below changes will disable that.
*
* */
cy.window().then((win) => {
win.onbeforeunload = null;
});
});
beforeEach(() => {
login();
visitWithTimeRange(hostsUrl('allHosts'));
createNewTimeline();
});
it('unchanged & unsaved timeline should NOT prompt when it is closed and navigate to any page', () => {
closeTimelineUsingToggle();
navigateToAlertsPageInServerless(); // security page with timelines enabled
cy.get(APP_LEAVE_CONFIRM_MODAL).should('not.exist');
navigateToExplorePageInServerless(); // security page with timelines disabled
cy.get(APP_LEAVE_CONFIRM_MODAL).should('not.exist');
navigateToDiscoverPageInServerless(); // external page
cy.get(APP_LEAVE_CONFIRM_MODAL).should('not.exist');
});
it('Changed & unsaved timeline should prompt when it is closed and navigate to Security page without timeline', () => {
populateTimeline();
closeTimelineUsingToggle();
navigateToAlertsPageInServerless(); // security page with timelines enabled
cy.get(APP_LEAVE_CONFIRM_MODAL).should('not.exist');
navigateToExplorePageInServerless(); // security page with timelines disabled
cy.get(APP_LEAVE_CONFIRM_MODAL).should('be.visible');
cy.get(MODAL_CONFIRMATION_BTN).click();
});
it('Changed & unsaved timeline should prompt when it is closed and navigate to external page', () => {
populateTimeline();
closeTimelineUsingToggle();
navigateToDiscoverPageInServerless();
cy.get(APP_LEAVE_CONFIRM_MODAL).should('be.visible');
cy.get(MODAL_CONFIRMATION_BTN).click();
});
it('Changed & saved timeline should NOT prompt when it is closed', () => {
populateTimeline();
addNameToTimelineAndSave('Test');
closeTimelineUsingToggle();
navigateToAlertsPageInServerless(); // security page with timelines enabled
cy.get(APP_LEAVE_CONFIRM_MODAL).should('not.exist');
navigateToExplorePageInServerless(); // security page with timelines disabled
cy.get(APP_LEAVE_CONFIRM_MODAL).should('not.exist');
navigateToDiscoverPageInServerless(); // external page
cy.get(APP_LEAVE_CONFIRM_MODAL).should('not.exist');
});
it('Changed & unsaved timeline should NOT prompt when navigate to page with timeline using breadcrumbs', () => {
populateTimeline();
navigateToHostsUsingBreadcrumb(); // hosts has timelines enabled
cy.get(APP_LEAVE_CONFIRM_MODAL).should('not.exist');
});
it('Changed & unsaved timeline should NOT prompt when navigate to page without timeline using breadcrumbs', () => {
populateTimeline();
navigateToExploreUsingBreadcrumb(); // explore has timelines disabled
cy.get(APP_LEAVE_CONFIRM_MODAL).should('be.visible');
cy.get(MODAL_CONFIRMATION_BTN).click();
cy.url().should('contain', EXPLORE_URL);
});
it('Changed & saved timeline should NOT prompt when user navigates within security solution where timelines are disabled', () => {
populateTimeline();
addNameToTimelineAndSave('Test');
navigateToExploreUsingBreadcrumb(); // explore has timelines disabled
cy.get(APP_LEAVE_CONFIRM_MODAL).should('not.exist');
});
});

View file

@ -50,7 +50,6 @@ import { hostsUrl } from '../../urls/navigation';
import { ABSOLUTE_DATE_RANGE } from '../../urls/state';
import { getTimeline } from '../../objects/timeline';
import { TIMELINE } from '../../screens/create_new_case';
import {
GLOBAL_SEARCH_BAR_FILTER_ITEM_AT,
GLOBAL_SEARCH_BAR_PINNED_FILTER,
@ -308,7 +307,6 @@ describe('url state', { tags: ['@ess', '@brokenInServerless'] }, () => {
visitWithTimeRange(`/app/security/timelines?timeline=(id:'${timelineId}',isOpen:!t)`);
cy.get(DATE_PICKER_APPLY_BUTTON_TIMELINE).should('exist');
cy.get(DATE_PICKER_APPLY_BUTTON_TIMELINE).should('not.have.text', 'Updating');
cy.get(TIMELINE).should('be.visible');
cy.get(TIMELINE_TITLE).should('be.visible');
cy.get(TIMELINE_TITLE).should('have.text', getTimeline().title);
});

View file

@ -21,6 +21,6 @@ export const SUBMIT_BTN = '[data-test-subj="create-case-submit"]';
export const TAGS_INPUT = '[data-test-subj="caseTags"] [data-test-subj="comboBoxSearchInput"]';
export const TIMELINE = '[data-test-subj="timeline"]';
export const TIMELINE = '[data-test-subj="selectable-input"] [data-test-subj="timeline"]';
export const TITLE_INPUT = '[data-test-subj="caseTitle"] [data-test-subj="input"]';

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export const DATA_GRID_EMPTY_STATE = '[data-test-subj="tGridEmptyState"]';

View file

@ -25,7 +25,8 @@ export const INSPECT_MODAL = '[data-test-subj="modal-inspect-euiModal"]';
export const INSPECT_QUERY =
'[data-test-subj="events-viewer-panel"] [data-test-subj="inspect-icon-button"]';
export const SERVER_SIDE_EVENT_COUNT = '[data-test-subj="server-side-event-count"]';
export const SERVER_SIDE_EVENT_COUNT =
'[data-test-subj="events-viewer-panel"] [data-test-subj="server-side-event-count"]';
export const EVENT_VIEWER_CHECKBOX =
'[data-test-subj="dataGridHeaderCell-checkbox-control-column"]';

View file

@ -0,0 +1,11 @@
/*
* 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.
*/
export const EXPLORE_BREADCRUMB =
'[data-test-subj*="breadcrumb-deepLinkId-securitySolutionUI:explore"]';
export const HOSTS_BREADCRUMB =
'[data-test-subj*="breadcrumb-deepLinkId-securitySolutionUI:hosts"]';

View file

@ -80,7 +80,7 @@ export const attachTimeline = (newCase: TestCase) => {
},
{ interval: 500, timeout: 12000 }
);
cy.get(TIMELINE).eq(1).click();
cy.get(TIMELINE).first().click();
};
export const createCase = () => {

View file

@ -5,7 +5,11 @@
* 2.0.
*/
import { ALERTS } from '../../screens/serverless_security_header';
import { ALERTS, DISCOVER, EXPLORE } from '../../screens/serverless_security_header';
import {
EXPLORE_BREADCRUMB,
HOSTS_BREADCRUMB,
} from '../../screens/serverless_security_breadcrumbs';
const navigateTo = (page: string) => {
cy.get(page).click();
@ -14,3 +18,19 @@ const navigateTo = (page: string) => {
export const navigateToAlertsPageInServerless = () => {
navigateTo(ALERTS);
};
export const navigateToDiscoverPageInServerless = () => {
navigateTo(DISCOVER);
};
export const navigateToExplorePageInServerless = () => {
navigateTo(EXPLORE);
};
export const navigateToHostsUsingBreadcrumb = () => {
cy.get(HOSTS_BREADCRUMB).click();
};
export const navigateToExploreUsingBreadcrumb = () => {
cy.get(EXPLORE_BREADCRUMB).click();
};