[Security Solution][Alert Details] - push and overlay expandable flyout (#182615)

## Summary

This PR adds a new functionality to the `kbn-expandable-flyout` package
and its usage in the Security Solution application.

The package's flyout now support to be rendered in `overlay` or `push`
mode, following [EUI's
recommendation](https://eui.elastic.co/#/layout/flyout#push-versus-overlay).
A gear icon button is rendered in the top right corner, next to the
close button. When clicked, a menu appears where users can select `push`
or `overlay` values. `overlay` is the default value. If `push` is
selected, a `Reset to default` empty button can be used to reset to
`overlay`.

Overlay option selected (by default)
![Screenshot 2024-09-04 at 12 10
34 PM](https://github.com/user-attachments/assets/87f57238-9b44-4d29-9516-9eb329c49bb2)

Push option selected
![Screenshot 2024-09-04 at 12 10
42 PM](https://github.com/user-attachments/assets/80e7879a-b238-46ba-9c13-2c8e236e138f)

The flyout should be toggled between `overlay` and `push` mode in all
the pages it's been currently used in:
- alerts page
- rule creation page
- explore pages (host, users...)
- case detail page


https://github.com/user-attachments/assets/b4cec138-802c-430d-8f37-01258e6afef3

But the flyout cannot be set to `push` mode when opened from Timeline.
Timeline is a modal (an EUI Portal to be precise), and getting the
portal as well as the overlay mask to correctly resize according to the
flyout's width (which is dynamic, changes with the screen size and also
changes if the flyout is in collapsed or expanded mode) is very
difficult.
A future PR might add this functionality to TImeline. At this time, the
flyout offers the option to disable the `push/overlay` toggle as well as
an icon to show a tooltip to explain why.


https://github.com/user-attachments/assets/e00961c8-cc75-4eb9-b34d-544bc4391d5c

#### Notes

The package also offers a way to hide the gear icon entirely. In the
future, we might need a bit more flexibility if we want to be able to
show the gear icon with options others than the `push/overlay` entry.

Finally the state of the flyout type (`overlay` vs `push`) is saved in
local storage so that users don't have to set the value over and over
again. This state is persisted within the `kbn-expandable-flyout`
package so that developers don't have to worry about setting it up. The
package uses its internal `urlKey` to guarantee that the key used to
save in localStorage is unique. This means that `memory` flyouts cannot
persist the `push` or `overlay` states, this is expected.


500315b5-07d4-4498-aab9-ee2e2be0253b

### Notes

The package's README has been updated.
New Storybook stories have been added to reflect the new push/overlay
functionality.

https://github.com/elastic/kibana/issues/182593
This commit is contained in:
Philippe Oberti 2024-09-06 22:16:47 +02:00 committed by GitHub
parent 832bc99181
commit d968cc0929
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 835 additions and 111 deletions

View file

@ -84,6 +84,21 @@ Then use the [React UI component](https://github.com/elastic/kibana/tree/main/pa
```
_where `myPanels` is a list of all the panels that can be rendered in the flyout_
## Optional properties
The expandable flyout now offers a way for users to change some of the flyout's UI properties. These are done via a gear icon in the top right corner of the flyout, to the left of the close icon.
The gear icon can be hidden by setting the `hideSettings` property to `true` in the flyout's custom props.
The `typeDisabled` property allows to disable the push/overlay toggle.
```typescript
flyoutCustomProps?: {
hideSettings?: boolean;
typeDisabled?: boolean,
};
```
At the moment, clicking on the gear icon opens a popover that allows you to toggle the flyout between `overlay` and `push` modes (see [EUI](https://eui.elastic.co/#/layout/flyout#push-versus-overlay)). The default value is `overlay`. The package remembers the selected value in local storage, only for expandable flyout that have a urlKey. The state of memory flyouts is not persisted.
## Terminology
### Section

View file

@ -7,6 +7,7 @@
*/
import React from 'react';
import type { IStorage } from '@kbn/kibana-utils-plugin/public';
export const useExpandableFlyoutApi = jest.fn(() => ({
openFlyout: jest.fn(),
@ -37,3 +38,22 @@ export const withExpandableFlyoutProvider = <T extends object>(
};
export const ExpandableFlyout = jest.fn();
export const localStorageMock = (): IStorage => {
let store: Record<string, unknown> = {};
return {
getItem: (key: string) => {
return store[key] || null;
},
setItem: (key: string, value: unknown) => {
store[key] = value;
},
clear() {
store = {};
},
removeItem(key: string) {
delete store[key];
},
};
};

View file

@ -7,7 +7,7 @@
*/
import { EuiFlexItem } from '@elastic/eui';
import React, { useMemo } from 'react';
import React, { memo, useMemo } from 'react';
import { LEFT_SECTION_TEST_ID } from './test_ids';
interface LeftSectionProps {
@ -24,16 +24,18 @@ interface LeftSectionProps {
/**
* Left section of the expanded flyout rendering a panel
*/
export const LeftSection: React.FC<LeftSectionProps> = ({ component, width }: LeftSectionProps) => {
const style = useMemo<React.CSSProperties>(
() => ({ height: '100%', width: `${width}px` }),
[width]
);
return (
<EuiFlexItem grow data-test-subj={LEFT_SECTION_TEST_ID} style={style}>
{component}
</EuiFlexItem>
);
};
export const LeftSection: React.FC<LeftSectionProps> = memo(
({ component, width }: LeftSectionProps) => {
const style = useMemo<React.CSSProperties>(
() => ({ height: '100%', width: `${width}px` }),
[width]
);
return (
<EuiFlexItem grow data-test-subj={LEFT_SECTION_TEST_ID} style={style}>
{component}
</EuiFlexItem>
);
}
);
LeftSection.displayName = 'LeftSection';

View file

@ -16,7 +16,7 @@ import {
EuiSplitPanel,
transparentize,
} from '@elastic/eui';
import React from 'react';
import React, { memo } from 'react';
import { css } from '@emotion/react';
import { has } from 'lodash';
import {
@ -79,91 +79,89 @@ interface PreviewSectionProps {
* Preview section of the expanded flyout rendering one or multiple panels.
* Will display a back and close button in the header for the previous and close feature respectively.
*/
export const PreviewSection: React.FC<PreviewSectionProps> = ({
component,
leftPosition,
banner,
}: PreviewSectionProps) => {
const { euiTheme } = useEuiTheme();
const { closePreviewPanel, previousPreviewPanel } = useExpandableFlyoutApi();
export const PreviewSection: React.FC<PreviewSectionProps> = memo(
({ component, leftPosition, banner }: PreviewSectionProps) => {
const { euiTheme } = useEuiTheme();
const { closePreviewPanel, previousPreviewPanel } = useExpandableFlyoutApi();
const left = leftPosition + 4;
const left = leftPosition + 4;
const closeButton = (
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="cross"
onClick={() => closePreviewPanel()}
data-test-subj={PREVIEW_SECTION_CLOSE_BUTTON_TEST_ID}
aria-label={CLOSE_BUTTON}
/>
</EuiFlexItem>
);
const header = (
<EuiFlexGroup justifyContent="spaceBetween" responsive={false}>
const closeButton = (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="xs"
iconType="arrowLeft"
iconSide="left"
onClick={() => previousPreviewPanel()}
data-test-subj={PREVIEW_SECTION_BACK_BUTTON_TEST_ID}
aria-label={BACK_BUTTON}
>
{BACK_BUTTON}
</EuiButtonEmpty>
<EuiButtonIcon
iconType="cross"
onClick={() => closePreviewPanel()}
data-test-subj={PREVIEW_SECTION_CLOSE_BUTTON_TEST_ID}
aria-label={CLOSE_BUTTON}
/>
</EuiFlexItem>
{closeButton}
</EuiFlexGroup>
);
);
const header = (
<EuiFlexGroup justifyContent="spaceBetween" responsive={false}>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="xs"
iconType="arrowLeft"
iconSide="left"
onClick={() => previousPreviewPanel()}
data-test-subj={PREVIEW_SECTION_BACK_BUTTON_TEST_ID}
aria-label={BACK_BUTTON}
>
{BACK_BUTTON}
</EuiButtonEmpty>
</EuiFlexItem>
{closeButton}
</EuiFlexGroup>
);
return (
<div
css={css`
position: absolute;
top: 8px;
bottom: 8px;
right: 4px;
left: ${left}px;
z-index: 1000;
`}
>
<EuiSplitPanel.Outer
return (
<div
css={css`
margin: ${euiTheme.size.xs};
box-shadow: 0 0 16px 0px ${transparentize(euiTheme.colors.mediumShade, 0.5)};
position: absolute;
top: 8px;
bottom: 8px;
right: 4px;
left: ${left}px;
z-index: 1000;
`}
data-test-subj={PREVIEW_SECTION_TEST_ID}
className="eui-fullHeight"
>
{isPreviewBanner(banner) && (
<EuiSplitPanel.Outer
css={css`
margin: ${euiTheme.size.xs};
box-shadow: 0 0 16px 0px ${transparentize(euiTheme.colors.mediumShade, 0.5)};
`}
data-test-subj={PREVIEW_SECTION_TEST_ID}
className="eui-fullHeight"
>
{isPreviewBanner(banner) && (
<EuiSplitPanel.Inner
grow={false}
color={banner.backgroundColor}
paddingSize="xs"
data-test-subj={`${PREVIEW_SECTION_TEST_ID}BannerPanel`}
>
<EuiText
textAlign="center"
color={banner.textColor}
size="xs"
data-test-subj={`${PREVIEW_SECTION_TEST_ID}BannerText`}
>
{banner.title}
</EuiText>
</EuiSplitPanel.Inner>
)}
<EuiSplitPanel.Inner
grow={false}
color={banner.backgroundColor}
paddingSize="xs"
data-test-subj={`${PREVIEW_SECTION_TEST_ID}BannerPanel`}
paddingSize="s"
data-test-subj={PREVIEW_SECTION_HEADER_TEST_ID}
>
<EuiText
textAlign="center"
color={banner.textColor}
size="xs"
data-test-subj={`${PREVIEW_SECTION_TEST_ID}BannerText`}
>
{banner.title}
</EuiText>
{header}
</EuiSplitPanel.Inner>
)}
<EuiSplitPanel.Inner
grow={false}
paddingSize="s"
data-test-subj={PREVIEW_SECTION_HEADER_TEST_ID}
>
{header}
</EuiSplitPanel.Inner>
{component}
</EuiSplitPanel.Outer>
</div>
);
};
{component}
</EuiSplitPanel.Outer>
</div>
);
}
);
PreviewSection.displayName = 'PreviewSection';

View file

@ -7,7 +7,7 @@
*/
import { EuiFlexItem } from '@elastic/eui';
import React, { useMemo } from 'react';
import React, { memo, useMemo } from 'react';
import { RIGHT_SECTION_TEST_ID } from './test_ids';
interface RightSectionProps {
@ -24,20 +24,19 @@ interface RightSectionProps {
/**
* Right section of the expanded flyout rendering a panel
*/
export const RightSection: React.FC<RightSectionProps> = ({
component,
width,
}: RightSectionProps) => {
const style = useMemo<React.CSSProperties>(
() => ({ height: '100%', width: `${width}px` }),
[width]
);
export const RightSection: React.FC<RightSectionProps> = memo(
({ component, width }: RightSectionProps) => {
const style = useMemo<React.CSSProperties>(
() => ({ height: '100%', width: `${width}px` }),
[width]
);
return (
<EuiFlexItem grow={false} style={style} data-test-subj={RIGHT_SECTION_TEST_ID}>
{component}
</EuiFlexItem>
);
};
return (
<EuiFlexItem grow={false} style={style} data-test-subj={RIGHT_SECTION_TEST_ID}>
{component}
</EuiFlexItem>
);
}
);
RightSection.displayName = 'RightSection';

View file

@ -0,0 +1,93 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { SettingsMenu } from './settings_menu';
import { EuiFlyoutProps } from '@elastic/eui';
import {
SETTINGS_MENU_BUTTON_TEST_ID,
SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_PUSH_TEST_ID,
SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_TEST_ID,
SETTINGS_MENU_FLYOUT_TYPE_INFORMATION_ICON_TEST_ID,
SETTINGS_MENU_FLYOUT_TYPE_TITLE_TEST_ID,
} from './test_ids';
describe('SettingsMenu', () => {
it('should render the flyout type button group', () => {
const flyoutTypeProps = {
type: 'overlay' as EuiFlyoutProps['type'],
onChange: jest.fn(),
disabled: false,
tooltip: '',
};
const { getByTestId, queryByTestId } = render(
<SettingsMenu flyoutTypeProps={flyoutTypeProps} />
);
getByTestId(SETTINGS_MENU_BUTTON_TEST_ID).click();
expect(getByTestId(SETTINGS_MENU_FLYOUT_TYPE_TITLE_TEST_ID)).toBeInTheDocument();
expect(
queryByTestId(SETTINGS_MENU_FLYOUT_TYPE_INFORMATION_ICON_TEST_ID)
).not.toBeInTheDocument();
expect(getByTestId(SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_TEST_ID)).toBeInTheDocument();
});
it('should select correct the flyout type', () => {
const onChange = jest.fn();
const flyoutTypeProps = {
type: 'overlay' as EuiFlyoutProps['type'],
onChange,
disabled: false,
tooltip: '',
};
const { getByTestId } = render(<SettingsMenu flyoutTypeProps={flyoutTypeProps} />);
getByTestId(SETTINGS_MENU_BUTTON_TEST_ID).click();
getByTestId(SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_PUSH_TEST_ID).click();
expect(onChange).toHaveBeenCalledWith('push');
});
it('should render the the flyout type button group disabled', () => {
const flyoutTypeProps = {
type: 'overlay' as EuiFlyoutProps['type'],
onChange: jest.fn(),
disabled: true,
tooltip: 'This option is disabled',
};
const { getByTestId } = render(<SettingsMenu flyoutTypeProps={flyoutTypeProps} />);
getByTestId(SETTINGS_MENU_BUTTON_TEST_ID).click();
expect(getByTestId(SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_TEST_ID)).toHaveAttribute('disabled');
expect(getByTestId(SETTINGS_MENU_FLYOUT_TYPE_INFORMATION_ICON_TEST_ID)).toBeInTheDocument();
});
it('should not render the information icon if the tooltip is empty', () => {
const flyoutTypeProps = {
type: 'overlay' as EuiFlyoutProps['type'],
onChange: jest.fn(),
disabled: true,
tooltip: '',
};
const { getByTestId, queryByTestId } = render(
<SettingsMenu flyoutTypeProps={flyoutTypeProps} />
);
getByTestId(SETTINGS_MENU_BUTTON_TEST_ID).click();
expect(
queryByTestId(SETTINGS_MENU_FLYOUT_TYPE_INFORMATION_ICON_TEST_ID)
).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,188 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
EuiButtonGroup,
EuiButtonIcon,
EuiContextMenu,
EuiFlyoutProps,
EuiIcon,
EuiPanel,
EuiPopover,
EuiSpacer,
EuiTitle,
EuiToolTip,
} from '@elastic/eui';
import { css } from '@emotion/css';
import React, { memo, useCallback, useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
SETTINGS_MENU_BUTTON_TEST_ID,
SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_OVERLAY_TEST_ID,
SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_PUSH_TEST_ID,
SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_TEST_ID,
SETTINGS_MENU_FLYOUT_TYPE_INFORMATION_ICON_TEST_ID,
SETTINGS_MENU_FLYOUT_TYPE_TITLE_TEST_ID,
} from './test_ids';
const SETTINGS_MENU_ICON_BUTTON = i18n.translate('expandableFlyout.settingsMenu.popoverButton', {
defaultMessage: 'Open flyout settings menu',
});
const SETTINGS_MENU_ICON_BUTTON_TOOLTIP = i18n.translate(
'expandableFlyout.settingsMenu.popoverButton',
{
defaultMessage: 'Flyout settings',
}
);
const SETTINGS_MENU_TITLE = i18n.translate('expandableFlyout.settingsMenu.popoverTitle', {
defaultMessage: 'Flyout settings',
});
const FLYOUT_TYPE_TITLE = i18n.translate('expandableFlyout.settingsMenu.flyoutTypeTitle', {
defaultMessage: 'Flyout type',
});
const FLYOUT_TYPE_OVERLAY_MODE = i18n.translate('expandableFlyout.settingsMenu.overlayMode', {
defaultMessage: 'Overlay',
});
const FLYOUT_TYPE_PUSH_MODE = i18n.translate('expandableFlyout.settingsMenu.pushMode', {
defaultMessage: 'Push',
});
const FLYOUT_TYPE_OVERLAY_TOOLTIP = i18n.translate('expandableFlyout.settingsMenu.overlayTooltip', {
defaultMessage: 'Displays the flyout over the page',
});
const FLYOUT_TYPE_PUSH_TOOLTIP = i18n.translate('expandableFlyout.settingsMenu.pushTooltip', {
defaultMessage: 'Displays the flyout next to the page',
});
interface SettingsMenuProps {
/**
* Current flyout type
*/
flyoutTypeProps: {
/**
* 'push' or 'overlay'
*/
type: EuiFlyoutProps['type'];
/**
* Callback to change the flyout type
*/
onChange: (type: EuiFlyoutProps['type']) => void;
/**
* Disables the button group for flyout where the option shouldn't be available
*/
disabled: boolean;
/**
* Allows to show a tooltip to explain why the option is disabled
*/
tooltip: string;
};
}
/**
* Renders a menu to allow the user to customize the flyout.
* Current customization are:
* - Flyout type: overlay or push
*/
export const SettingsMenu: React.FC<SettingsMenuProps> = memo(
({ flyoutTypeProps }: SettingsMenuProps) => {
const [isPopoverOpen, setPopover] = useState(false);
const togglePopover = () => {
setPopover(!isPopoverOpen);
};
const pushVsOverlayOnChange = useCallback(
(id: string) => {
flyoutTypeProps.onChange(id as EuiFlyoutProps['type']);
setPopover(false);
},
[flyoutTypeProps]
);
const panels = [
{
id: 0,
title: SETTINGS_MENU_TITLE,
content: (
<EuiPanel paddingSize="s">
<EuiTitle size="xxs" data-test-subj={SETTINGS_MENU_FLYOUT_TYPE_TITLE_TEST_ID}>
<h3>
{FLYOUT_TYPE_TITLE}{' '}
{flyoutTypeProps.tooltip && (
<EuiToolTip position="top" content={flyoutTypeProps.tooltip}>
<EuiIcon
data-test-subj={SETTINGS_MENU_FLYOUT_TYPE_INFORMATION_ICON_TEST_ID}
type="iInCircle"
css={css`
margin-left: 4px;
`}
/>
</EuiToolTip>
)}
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiButtonGroup
legend={FLYOUT_TYPE_TITLE}
options={[
{
id: 'overlay',
label: FLYOUT_TYPE_OVERLAY_MODE,
'data-test-subj': SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_OVERLAY_TEST_ID,
toolTipContent: FLYOUT_TYPE_OVERLAY_TOOLTIP,
},
{
id: 'push',
label: FLYOUT_TYPE_PUSH_MODE,
'data-test-subj': SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_PUSH_TEST_ID,
toolTipContent: FLYOUT_TYPE_PUSH_TOOLTIP,
},
]}
idSelected={flyoutTypeProps.type as string}
onChange={pushVsOverlayOnChange}
isDisabled={flyoutTypeProps.disabled}
data-test-subj={SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_TEST_ID}
/>
</EuiPanel>
),
},
];
const button = (
<EuiToolTip content={SETTINGS_MENU_ICON_BUTTON_TOOLTIP}>
<EuiButtonIcon
aria-label={SETTINGS_MENU_ICON_BUTTON}
iconType="gear"
color="text"
onClick={togglePopover}
data-test-subj={SETTINGS_MENU_BUTTON_TEST_ID}
/>
</EuiToolTip>
);
return (
<div
className={css`
position: absolute;
inset-inline-end: 36px;
inset-block-start: 8px;
`}
>
<EuiPopover
button={button}
isOpen={isPopoverOpen}
closePopover={togglePopover}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenu initialPanelId={0} panels={panels} />
</EuiPopover>
</div>
);
}
);
SettingsMenu.displayName = 'SettingsMenu';

View file

@ -17,3 +17,18 @@ export const PREVIEW_SECTION_CLOSE_BUTTON_TEST_ID = 'previewSectionCloseButton';
export const PREVIEW_SECTION_BACK_BUTTON_TEST_ID = 'previewSectionBackButton';
export const PREVIEW_SECTION_HEADER_TEST_ID = 'previewSectionHeader';
export const SETTINGS_MENU_BUTTON_TEST_ID = 'settingsMenuButton';
export const SETTINGS_MENU_FLYOUT_TYPE_TITLE_TEST_ID = 'settingsMenuFlyoutTypeTitle';
export const SETTINGS_MENU_FLYOUT_TYPE_INFORMATION_ICON_TEST_ID =
'settingsMenuFlyoutTypeInformationIcon';
export const SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_TEST_ID = 'settingsMenuFlyoutTypeButtonGroup';
export const SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_OVERLAY_TEST_ID =
'settingsMenuFlyoutTypeButtonGroupOverlayOption';
export const SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_PUSH_TEST_ID =
'settingsMenuFlyoutTypeButtonGroupPushOption';

View file

@ -0,0 +1,49 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { renderHook } from '@testing-library/react-hooks';
import { useExpandableFlyoutContext } from '../context';
import { useFlyoutType } from './use_flyout_type';
import { localStorageMock } from '../../__mocks__';
jest.mock('../context');
describe('useFlyoutType', () => {
beforeEach(() => {
Object.defineProperty(window, 'localStorage', {
value: localStorageMock(),
});
});
it('should return the value in localStorage if set', () => {
(useExpandableFlyoutContext as jest.Mock).mockReturnValue({ urlKey: 'flyout' });
localStorage.setItem('expandableFlyout.pushVsOverlayMode.flyout', 'push');
const hookResult = renderHook(() => useFlyoutType());
expect(hookResult.result.current.flyoutType).toEqual('push');
});
it('should return overlay if nothing is set in localStorage', () => {
(useExpandableFlyoutContext as jest.Mock).mockReturnValue({ urlKey: 'flyout' });
const hookResult = renderHook(() => useFlyoutType());
expect(hookResult.result.current.flyoutType).toEqual('overlay');
});
it('should set value in localStorage', () => {
(useExpandableFlyoutContext as jest.Mock).mockReturnValue({ urlKey: 'flyout' });
const hookResult = renderHook(() => useFlyoutType());
hookResult.result.current.flyoutTypeChange('push');
expect(localStorage.getItem('expandableFlyout.pushVsOverlayMode.flyout')).toEqual('push');
});
});

View file

@ -0,0 +1,59 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { useCallback, useMemo, useState } from 'react';
import { EuiFlyoutProps } from '@elastic/eui';
import { useExpandableFlyoutContext } from '../context';
const expandableFlyoutLocalStorageKey = 'expandableFlyout.';
const pushVsOverlayModeLocalStorageKey = 'pushVsOverlayMode.';
export interface UseFlyoutTypeResult {
/**
* The current flyout type
*/
flyoutType: EuiFlyoutProps['type'];
/**
* Callback to change the flyout type
*/
flyoutTypeChange: (flyoutType: EuiFlyoutProps['type']) => void;
}
/**
* Hook to store and retrieve the flyout type (push vs overlay) from local storage.
* The key is generated based on the current URL key.
*/
export const useFlyoutType = (): UseFlyoutTypeResult => {
const { urlKey } = useExpandableFlyoutContext();
const pushVsOverlayLocalStorageKey = useMemo(
() => `${expandableFlyoutLocalStorageKey}${pushVsOverlayModeLocalStorageKey}${urlKey}`,
[urlKey]
);
const initialFlyoutType: EuiFlyoutProps['type'] =
(localStorage.getItem(pushVsOverlayLocalStorageKey) as EuiFlyoutProps['type']) || 'overlay';
const [flyoutType, setFlyoutType] = useState<EuiFlyoutProps['type']>(initialFlyoutType);
const flyoutTypeChange = useCallback(
(type: EuiFlyoutProps['type']) => {
// we only save to localStorage the value for flyouts that have a urlKey.
// The state of the memory flyout is not persisted.
if (urlKey && type) {
localStorage.setItem(pushVsOverlayLocalStorageKey, type);
}
setFlyoutType(type);
},
[pushVsOverlayLocalStorageKey, setFlyoutType, urlKey]
);
return {
flyoutType,
flyoutTypeChange,
};
};

View file

@ -115,7 +115,10 @@ export const Right: Story<void> = () => {
return (
<TestProvider state={state}>
<ExpandableFlyout registeredPanels={registeredPanels} />
<ExpandableFlyout
registeredPanels={registeredPanels}
flyoutCustomProps={{ hideSettings: true }}
/>
</TestProvider>
);
};
@ -137,7 +140,10 @@ export const Left: Story<void> = () => {
return (
<TestProvider state={state}>
<ExpandableFlyout registeredPanels={registeredPanels} />
<ExpandableFlyout
registeredPanels={registeredPanels}
flyoutCustomProps={{ hideSettings: true }}
/>
</TestProvider>
);
};
@ -163,7 +169,10 @@ export const Preview: Story<void> = () => {
return (
<TestProvider state={state}>
<ExpandableFlyout registeredPanels={registeredPanels} />
<ExpandableFlyout
registeredPanels={registeredPanels}
flyoutCustomProps={{ hideSettings: true }}
/>
</TestProvider>
);
};
@ -190,9 +199,81 @@ export const MultiplePreviews: Story<void> = () => {
},
};
return (
<TestProvider state={state}>
<ExpandableFlyout
registeredPanels={registeredPanels}
flyoutCustomProps={{ hideSettings: true }}
/>
</TestProvider>
);
};
export const CollapsedPushVsOverlay: Story<void> = () => {
const state: State = {
byId: {
memory: {
right: {
id: 'right',
},
left: undefined,
preview: undefined,
},
},
};
return (
<TestProvider state={state}>
<ExpandableFlyout registeredPanels={registeredPanels} />
</TestProvider>
);
};
export const ExpandedPushVsOverlay: Story<void> = () => {
const state: State = {
byId: {
memory: {
right: {
id: 'right',
},
left: {
id: 'left',
},
preview: undefined,
},
},
};
return (
<TestProvider state={state}>
<ExpandableFlyout registeredPanels={registeredPanels} />
</TestProvider>
);
};
export const DisableTypeSelection: Story<void> = () => {
const state: State = {
byId: {
memory: {
right: {
id: 'right',
},
left: {
id: 'left',
},
preview: undefined,
},
},
};
return (
<TestProvider state={state}>
<ExpandableFlyout
registeredPanels={registeredPanels}
flyoutCustomProps={{
pushVsOverlay: { disabled: true, tooltip: 'This option is disabled' },
}}
/>
</TestProvider>
);
};

View file

@ -14,6 +14,7 @@ import { ExpandableFlyout } from '.';
import {
LEFT_SECTION_TEST_ID,
PREVIEW_SECTION_TEST_ID,
SETTINGS_MENU_BUTTON_TEST_ID,
RIGHT_SECTION_TEST_ID,
} from './components/test_ids';
import { type State } from './state';
@ -133,4 +134,26 @@ describe('ExpandableFlyout', () => {
expect(queryByTestId('my-test-flyout')).toBeNull();
expect(queryByTestId(RIGHT_SECTION_TEST_ID)).toBeNull();
});
it('should render the menu to change display options', () => {
const state = {
byId: {
[id]: {
right: {
id: 'key',
},
left: undefined,
preview: undefined,
},
},
};
const { getByTestId } = render(
<TestProvider state={state}>
<ExpandableFlyout registeredPanels={registeredPanels} />
</TestProvider>
);
expect(getByTestId(SETTINGS_MENU_BUTTON_TEST_ID)).toBeInTheDocument();
});
});

View file

@ -10,6 +10,8 @@ import React, { useMemo } from 'react';
import type { Interpolation, Theme } from '@emotion/react';
import { EuiFlyoutProps } from '@elastic/eui';
import { EuiFlexGroup, EuiFlyout } from '@elastic/eui';
import { useFlyoutType } from './hooks/use_flyout_type';
import { SettingsMenu } from './components/settings_menu';
import { useSectionSizes } from './hooks/use_sections_sizes';
import { useWindowSize } from './hooks/use_window_size';
import { useExpandableFlyoutState } from './hooks/use_expandable_flyout_state';
@ -35,6 +37,22 @@ export interface ExpandableFlyoutProps extends Omit<EuiFlyoutProps, 'onClose'> {
* Callback function to let application's code the flyout is closed
*/
onClose?: EuiFlyoutProps['onClose'];
/**
* Set of properties that drive a settings menu
*/
flyoutCustomProps?: {
/**
* Hide the gear icon and settings menu if true
*/
hideSettings?: boolean;
/**
* Control if the option to render in overlay or push mode is enabled or not
*/
pushVsOverlay?: {
disabled: boolean;
tooltip: string;
};
};
}
/**
@ -47,10 +65,11 @@ export interface ExpandableFlyoutProps extends Omit<EuiFlyoutProps, 'onClose'> {
export const ExpandableFlyout: React.FC<ExpandableFlyoutProps> = ({
customStyles,
registeredPanels,
flyoutCustomProps,
...flyoutProps
}) => {
const windowWidth = useWindowSize();
const { flyoutType, flyoutTypeChange } = useFlyoutType();
const { left, right, preview } = useExpandableFlyoutState();
const { closeFlyout } = useExpandableFlyoutApi();
@ -96,6 +115,7 @@ export const ExpandableFlyout: React.FC<ExpandableFlyoutProps> = ({
<EuiFlyout
{...flyoutProps}
data-panel-id={right?.id ?? ''}
type={flyoutType}
size={flyoutWidth}
ownFocus={false}
onClose={(e) => {
@ -134,6 +154,17 @@ export const ExpandableFlyout: React.FC<ExpandableFlyoutProps> = ({
banner={previewBanner}
/>
) : null}
{!flyoutCustomProps?.hideSettings && (
<SettingsMenu
flyoutTypeProps={{
type: flyoutType,
onChange: flyoutTypeChange,
disabled: flyoutCustomProps?.pushVsOverlay?.disabled || false,
tooltip: flyoutCustomProps?.pushVsOverlay?.tooltip || '',
}}
/>
)}
</EuiFlyout>
);
};

View file

@ -47,10 +47,11 @@ export interface FlyoutNavigationProps {
export const FlyoutNavigation: FC<FlyoutNavigationProps> = memo(
({ flyoutIsExpandable = false, expandDetails, actions }) => {
const { euiTheme } = useEuiTheme();
const { closeLeftPanel } = useExpandableFlyoutApi();
const panels = useExpandableFlyoutState();
const panels = useExpandableFlyoutState();
const isExpanded: boolean = !!panels.left;
const { closeLeftPanel } = useExpandableFlyoutApi();
const collapseDetails = useCallback(() => closeLeftPanel(), [closeLeftPanel]);
const collapseButton = useMemo(
@ -111,7 +112,7 @@ export const FlyoutNavigation: FC<FlyoutNavigationProps> = memo(
responsive={false}
css={css`
padding-left: ${euiTheme.size.s};
padding-right: ${euiTheme.size.xl};
padding-right: ${euiTheme.size.xxxxl};
height: ${euiTheme.size.xxl};
`}
>

View file

@ -140,6 +140,7 @@ export const TIMELINE_ON_CLOSE_EVENT = `expandable-flyout-on-close-${Flyouts.tim
* 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)
* We propagate the onClose callback to the rest of Security Solution using a window event 'expandable-flyout-on-close-SecuritySolution'
* This flyout support push/overlay mode. The value is saved in local storage.
*/
export const SecuritySolutionFlyout = memo(() => {
const onClose = useCallback(
@ -167,6 +168,7 @@ 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)
* We propagate the onClose callback to the rest of Security Solution using a window event 'expandable-flyout-on-close-Timeline'
* This flyout does not support push mode, because timeline being rendered in a modal (EUiPortal), it's very difficult to dynamically change its width.
*/
export const TimelineFlyout = memo(() => {
const { euiTheme } = useEuiTheme();
@ -187,6 +189,12 @@ export const TimelineFlyout = memo(() => {
paddingSize="none"
customStyles={{ 'z-index': (euiTheme.levels.flyout as number) + 2 }}
onClose={onClose}
flyoutCustomProps={{
pushVsOverlay: {
disabled: true,
tooltip: 'Push mode is not supported in Timeline',
},
}}
/>
);
});

View file

@ -0,0 +1,88 @@
/*
* 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 {
expandAlertAtIndexExpandableFlyout,
expandAlertInTimelineAtIndexExpandableFlyout,
} from '../../../../tasks/expandable_flyout/common';
import { deleteAlertsAndRules } from '../../../../tasks/api_calls/common';
import { login } from '../../../../tasks/login';
import { visit } from '../../../../tasks/navigation';
import { createRule } from '../../../../tasks/api_calls/rules';
import { getNewRule } from '../../../../objects/rule';
import { ALERTS_URL } from '../../../../urls/navigation';
import { waitForAlertsToPopulate } from '../../../../tasks/create_new_rule';
import {
clickOnPushModeOption,
openRenderMenu,
} from '../../../../tasks/expandable_flyout/alert_detail_settings_menu';
import {
DOCUMENT_DETAILS_FLYOUT_FLYOUT_TYPE_BUTTON_GROUP,
DOCUMENT_DETAILS_FLYOUT_OVERLAY_OPTION,
DOCUMENT_DETAILS_FLYOUT_PUSH_OPTION,
} from '../../../../screens/expandable_flyout/alert_detail_settings_menu';
import { investigateFirstAlertInTimeline } from '../../../../tasks/alerts';
import { TIMELINE_FLYOUT } from '../../../../screens/timeline';
describe('Alert details expandable flyout', { tags: ['@ess', '@serverless'] }, () => {
const rule = getNewRule();
beforeEach(() => {
deleteAlertsAndRules();
login();
createRule(rule);
visit(ALERTS_URL);
waitForAlertsToPopulate();
});
it('should allow user to switch between push and overlay modes for flyout opened from alerts page', () => {
expandAlertAtIndexExpandableFlyout();
openRenderMenu();
cy.log('should have the overlay option selected by default');
cy.get(DOCUMENT_DETAILS_FLYOUT_OVERLAY_OPTION).should(
'have.class',
'euiButtonGroupButton-isSelected'
);
cy.get(DOCUMENT_DETAILS_FLYOUT_PUSH_OPTION).should(
'not.have.class',
'euiButtonGroupButton-isSelected'
);
cy.log('should persist selection via localstorage');
clickOnPushModeOption();
cy.reload();
openRenderMenu();
cy.get(DOCUMENT_DETAILS_FLYOUT_OVERLAY_OPTION).should(
'not.have.class',
'euiButtonGroupButton-isSelected'
);
cy.get(DOCUMENT_DETAILS_FLYOUT_PUSH_OPTION).should(
'have.class',
'euiButtonGroupButton-isSelected'
);
});
it('should not allow user to switch between push and overlay modes for flyout opened from timeline', () => {
investigateFirstAlertInTimeline();
cy.get(TIMELINE_FLYOUT).within(() => expandAlertInTimelineAtIndexExpandableFlyout());
openRenderMenu();
cy.get(DOCUMENT_DETAILS_FLYOUT_OVERLAY_OPTION).should(
'have.class',
'euiButtonGroupButton-isSelected'
);
cy.get(DOCUMENT_DETAILS_FLYOUT_PUSH_OPTION).should(
'not.have.class',
'euiButtonGroupButton-isSelected'
);
cy.get(DOCUMENT_DETAILS_FLYOUT_FLYOUT_TYPE_BUTTON_GROUP).should('be.disabled');
});
});

View file

@ -0,0 +1,20 @@
/*
* 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 { getDataTestSubjectSelector } from '../../helpers/common';
export const DOCUMENT_DETAILS_FLYOUT_SETTINGS_MENU_BUTTON =
getDataTestSubjectSelector('settingsMenuButton');
export const DOCUMENT_DETAILS_FLYOUT_FLYOUT_TYPE_BUTTON_GROUP = getDataTestSubjectSelector(
'settingsMenuFlyoutTypeButtonGroup'
);
export const DOCUMENT_DETAILS_FLYOUT_OVERLAY_OPTION = getDataTestSubjectSelector(
'settingsMenuFlyoutTypeButtonGroupOverlayOption'
);
export const DOCUMENT_DETAILS_FLYOUT_PUSH_OPTION = getDataTestSubjectSelector(
'settingsMenuFlyoutTypeButtonGroupPushOption'
);

View file

@ -320,3 +320,5 @@ export const SAVE_TIMELINE_TOOLTIP = getDataTestSubjectSelector(
);
export const TOGGLE_DATA_PROVIDER_BTN = getDataTestSubjectSelector('toggle-data-provider');
export const EXPAND_ALERT_BTN = getDataTestSubjectSelector('docTableExpandToggleColumn');

View file

@ -0,0 +1,25 @@
/*
* 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 {
DOCUMENT_DETAILS_FLYOUT_PUSH_OPTION,
DOCUMENT_DETAILS_FLYOUT_SETTINGS_MENU_BUTTON,
} from '../../screens/expandable_flyout/alert_detail_settings_menu';
/**
* Open the render menu in the flyout's header
*/
export const openRenderMenu = () => {
cy.get(DOCUMENT_DETAILS_FLYOUT_SETTINGS_MENU_BUTTON).click();
};
/**
* Switch to push mode in the render menu
*/
export const clickOnPushModeOption = () => {
cy.get(DOCUMENT_DETAILS_FLYOUT_PUSH_OPTION).click();
};

View file

@ -5,7 +5,8 @@
* 2.0.
*/
import { EXPAND_ALERT_BTN } from '../../screens/alerts';
import { EXPAND_ALERT_BTN as TIMELINE_EXPAND_ALERT_BTN } from '../../screens/timeline';
import { EXPAND_ALERT_BTN as ALERTS_TABLE_EXPAND_ALERT_BTN } from '../../screens/alerts';
import { DOCUMENT_DETAILS_FLYOUT_FOOTER_ADD_TO_NEW_CASE } from '../../screens/expandable_flyout/alert_details_right_panel';
import {
DOCUMENT_DETAILS_FLYOUT_FOOTER_ADD_TO_NEW_CASE_CREATE_BUTTON,
@ -20,7 +21,13 @@ import { openTakeActionButtonAndSelectItem } from './alert_details_right_panel';
* Find the alert row at index in the alerts table then click on the expand icon button to open the flyout
*/
export const expandAlertAtIndexExpandableFlyout = (index = 0) => {
cy.get(EXPAND_ALERT_BTN).eq(index).click();
cy.get(ALERTS_TABLE_EXPAND_ALERT_BTN).eq(index).click();
};
/**
* Find the alert row at index in the timeline table then click on the expand icon button to open the flyout
*/
export const expandAlertInTimelineAtIndexExpandableFlyout = (index = 0) => {
cy.get(TIMELINE_EXPAND_ALERT_BTN).eq(index).click();
};
/**