[Security Solution] expanded flyout (#150240)

This commit is contained in:
Philippe Oberti 2023-02-27 21:02:03 -06:00 committed by GitHub
parent 8b3d2f7c17
commit 4aa0961613
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1504 additions and 42 deletions

1
.github/CODEOWNERS vendored
View file

@ -337,6 +337,7 @@ x-pack/test/encrypted_saved_objects_api_integration/plugins/api_consumer_plugin
src/plugins/event_annotation @elastic/kibana-visualizations
x-pack/test/plugin_api_integration/plugins/event_log @elastic/response-ops
x-pack/plugins/event_log @elastic/response-ops
packages/kbn-expandable-flyout @elastic/security-threat-hunting-investigations
packages/kbn-expect @elastic/kibana-operations
x-pack/examples/exploratory_view_example @elastic/uptime
src/plugins/expression_error @elastic/kibana-presentation

View file

@ -25,6 +25,7 @@
"embeddableExamples": "examples/embeddable_examples",
"esQuery": "packages/kbn-es-query/src",
"esUi": "src/plugins/es_ui_shared",
"expandableFlyout": "packages/kbn-expandable-flyout",
"expressionError": "src/plugins/expression_error",
"expressionGauge": "src/plugins/chart_expressions/expression_gauge",
"expressionHeatmap": "src/plugins/chart_expressions/expression_heatmap",

View file

@ -370,6 +370,7 @@
"@kbn/event-annotation-plugin": "link:src/plugins/event_annotation",
"@kbn/event-log-fixture-plugin": "link:x-pack/test/plugin_api_integration/plugins/event_log",
"@kbn/event-log-plugin": "link:x-pack/plugins/event_log",
"@kbn/expandable-flyout": "link:packages/kbn-expandable-flyout",
"@kbn/exploratory-view-example-plugin": "link:x-pack/examples/exploratory_view_example",
"@kbn/expression-error-plugin": "link:src/plugins/expression_error",
"@kbn/expression-gauge-plugin": "link:src/plugins/chart_expressions/expression_gauge",

View file

@ -0,0 +1,66 @@
# @kbn/expandable-flyout
## Purpose
This package offers an expandable flyout UI component and a way to manage the data displayed in it. The component leverages the [EuiFlyout](https://github.com/elastic/eui/tree/main/src/components/flyout) from the EUI library.
The flyout is composed of 3 sections:
- a right section (primary section) that opens first
- a left wider section to show more details
- a preview section, that overlays the right section. This preview section can display multiple panels one after the other and displays a `Back` button
At the moment, displaying more than one flyout within the same plugin might be complicated, unless there are in difference areas in the codebase and the contexts don't conflict with each other.
## What the package offers
The ExpandableFlyout [React component](https://github.com/elastic/kibana/tree/main/packages/kbn-expandable-flyout/src/components/index) that renders the UI.
The ExpandableFlyout [React context](https://github.com/elastic/kibana/tree/main/packages/kbn-expandable-flyout/src/components/context) that exposes the following api:
- **openFlyout**: open the flyout with a set of panels
- **openFlyoutRightPanel**: open a right panel
- **openFlyoutLeftPanel**: open a left panel
- **openFlyoutPreviewPanel**: open a preview panel
- **closeFlyoutRightPanel**: close the right panel
- **closeFlyoutLeftPanel**: close the left panel
- **closeFlyoutPreviewPanel**: close the preview panels
- **previousFlyoutPreviewPanel**: navigate to the previous preview panel
- **closeFlyout**: close the flyout
To retrieve the flyout's layout (left, right and preview panels), you can use the **panels** from the same [React context](https://github.com/elastic/kibana/tree/main/packages/kbn-expandable-flyout/src/components/context);
- To have more details about how these above api work, see the code documentation [here](https://github.com/elastic/kibana/tree/main/packages/kbn-expandable-flyout/src/utils/helpers).
## Usage
To use the expandable flyout in your plugin, first you need wrap your code with the context provider at a high enough level as follows:
```typescript jsx
<ExpandableFlyoutProvider>
...
</ExpandableFlyoutProvider>
```
Then use the React UI component where you need:
```typescript jsx
<ExpandableFlyout registeredPanels={myPanels} />
```
where `myPanels` is a list of all the panels that can be rendered in the flyout (see interface [here](https://github.com/elastic/kibana/tree/main/packages/kbn-expandable-flyout/src/components/index)).
## Terminology
### Section
One of the 3 areas of the flyout (left, right or preview).
### Panel
A set of properties defining what's displayed in one of the flyout section.
## Future work
- currently the panels are aware of their width. This should be changed and the width of the left, right and preview sections should be handled by the flyout itself
- add the feature to save the flyout state (layout) to the url
- introduce the notion of scope to be able to handle more than one flyout per plugin??

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
export { ExpandableFlyout } from './src';
export { ExpandableFlyoutProvider, useExpandableFlyoutContext } from './src/context';
export type { ExpandableFlyoutProps } from './src';
export type { FlyoutPanel } from './src/types';

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-expandable-flyout'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/expandable-flyout",
"owner": "@elastic/security-threat-hunting-investigations"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/expandable-flyout",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,58 @@
/*
* 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 { FlyoutPanel } from './types';
export enum ActionType {
openFlyout = 'open_flyout',
openRightPanel = 'open_right_panel',
openLeftPanel = 'open_left_panel',
openPreviewPanel = 'open_preview_panel',
closeRightPanel = 'close_right_panel',
closeLeftPanel = 'close_left_panel',
closePreviewPanel = 'close_preview_panel',
previousPreviewPanel = 'previous_preview_panel',
closeFlyout = 'close_flyout',
}
export type Action =
| {
type: ActionType.openFlyout;
payload: {
right?: FlyoutPanel;
left?: FlyoutPanel;
preview?: FlyoutPanel;
};
}
| {
type: ActionType.openRightPanel;
payload: FlyoutPanel;
}
| {
type: ActionType.openLeftPanel;
payload: FlyoutPanel;
}
| {
type: ActionType.openPreviewPanel;
payload: FlyoutPanel;
}
| {
type: ActionType.closeRightPanel;
}
| {
type: ActionType.closeLeftPanel;
}
| {
type: ActionType.closePreviewPanel;
}
| {
type: ActionType.previousPreviewPanel;
}
| {
type: ActionType.closeFlyout;
};

View file

@ -0,0 +1,37 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React from 'react';
import { LEFT_SECTION } from './test_ids';
interface LeftSectionProps {
/**
* Component to be rendered
*/
component: React.ReactElement;
/**
* Width used when rendering the panel
*/
width: number;
}
/**
* Left section of the expanded flyout rendering a panel
*/
export const LeftSection: React.FC<LeftSectionProps> = ({ component, width }: LeftSectionProps) => {
return (
<EuiFlexItem grow data-test-subj={LEFT_SECTION}>
<EuiFlexGroup direction="column" style={{ maxWidth: width, width: 'auto' }}>
{component}
</EuiFlexGroup>
</EuiFlexItem>
);
};
LeftSection.displayName = 'LeftSection';

View file

@ -0,0 +1,55 @@
/*
* 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 { PreviewSection } from './preview_section';
import { PREVIEW_SECTION_BACK_BUTTON, PREVIEW_SECTION_CLOSE_BUTTON } from './test_ids';
import { ExpandableFlyoutContext } from '../context';
describe('PreviewSection', () => {
const context: ExpandableFlyoutContext = {
panels: {
right: {},
left: {},
preview: [
{
id: 'key',
},
],
},
} as unknown as ExpandableFlyoutContext;
it('should render close button in header', () => {
const component = <div>{'component'}</div>;
const width = 500;
const showBackButton = false;
const { getByTestId } = render(
<ExpandableFlyoutContext.Provider value={context}>
<PreviewSection component={component} width={width} showBackButton={showBackButton} />
</ExpandableFlyoutContext.Provider>
);
expect(getByTestId(PREVIEW_SECTION_CLOSE_BUTTON)).toBeInTheDocument();
});
it('should render back button in header', () => {
const component = <div>{'component'}</div>;
const width = 500;
const showBackButton = true;
const { getByTestId } = render(
<ExpandableFlyoutContext.Provider value={context}>
<PreviewSection component={component} width={width} showBackButton={showBackButton} />
</ExpandableFlyoutContext.Provider>
);
expect(getByTestId(PREVIEW_SECTION_BACK_BUTTON)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,124 @@
/*
* 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 {
EuiButtonEmpty,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
useEuiTheme,
} from '@elastic/eui';
import React from 'react';
import { css } from '@emotion/react';
import {
PREVIEW_SECTION,
PREVIEW_SECTION_BACK_BUTTON,
PREVIEW_SECTION_CLOSE_BUTTON,
} from './test_ids';
import { useExpandableFlyoutContext } from '../..';
import { BACK_BUTTON, CLOSE_BUTTON } from './translations';
interface PreviewSectionProps {
/**
* Component to be rendered
*/
component: React.ReactElement;
/**
* Width used when rendering the panel
*/
width: number | undefined;
/**
* Display the back button in the header
*/
showBackButton: boolean;
}
/**
* 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,
showBackButton,
width,
}: PreviewSectionProps) => {
const { euiTheme } = useEuiTheme();
const { closePreviewPanel, previousPreviewPanel } = useExpandableFlyoutContext();
const previewWith: string = width ? `${width}px` : '0px';
const closeButton = (
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="cross"
onClick={() => closePreviewPanel()}
data-test-subj={PREVIEW_SECTION_CLOSE_BUTTON}
aria-label={CLOSE_BUTTON}
/>
</EuiFlexItem>
);
const header = showBackButton ? (
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="xs"
iconType="arrowLeft"
iconSide="left"
onClick={() => previousPreviewPanel()}
data-test-subj={PREVIEW_SECTION_BACK_BUTTON}
aria-label={BACK_BUTTON}
>
{BACK_BUTTON}
</EuiButtonEmpty>
</EuiFlexItem>
{closeButton}
</EuiFlexGroup>
) : (
<EuiFlexGroup justifyContent="flexEnd">{closeButton}</EuiFlexGroup>
);
return (
<>
<div
css={css`
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: ${previewWith};
background-color: ${euiTheme.colors.shadow};
opacity: 0.5;
`}
/>
<div
css={css`
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: ${previewWith};
z-index: 1000;
`}
>
<EuiPanel
css={css`
margin: ${euiTheme.size.xs};
height: 100%;
`}
data-test-subj={PREVIEW_SECTION}
>
{header}
{component}
</EuiPanel>
</div>
</>
);
};
PreviewSection.displayName = 'PreviewSection';

View file

@ -0,0 +1,40 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React from 'react';
import { RIGHT_SECTION } from './test_ids';
interface RightSectionProps {
/**
* Component to be rendered
*/
component: React.ReactElement;
/**
* Width used when rendering the panel
*/
width: number;
}
/**
* Right section of the expanded flyout rendering a panel
*/
export const RightSection: React.FC<RightSectionProps> = ({
component,
width,
}: RightSectionProps) => {
return (
<EuiFlexItem grow={false} style={{ height: '100%' }} data-test-subj={RIGHT_SECTION}>
<EuiFlexGroup direction="column" style={{ width }}>
{component}
</EuiFlexGroup>
</EuiFlexItem>
);
};
RightSection.displayName = 'RightSection';

View file

@ -0,0 +1,17 @@
/*
* 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.
*/
export const RIGHT_SECTION = 'rightSection';
export const LEFT_SECTION = 'leftSection';
export const PREVIEW_SECTION = 'previewSection';
export const PREVIEW_SECTION_CLOSE_BUTTON = 'previewSectionCloseButton';
export const PREVIEW_SECTION_BACK_BUTTON = 'previewSectionBackButton';

View file

@ -0,0 +1,17 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const BACK_BUTTON = i18n.translate('expandableFlyout.previewSection.backButton', {
defaultMessage: 'Back',
});
export const CLOSE_BUTTON = i18n.translate('expandableFlyout.previewSection.closeButton', {
defaultMessage: 'Close',
});

View file

@ -0,0 +1,172 @@
/*
* 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, { createContext, useCallback, useContext, useMemo, useReducer } from 'react';
import { ActionType } from './actions';
import { reducer, State } from './reducer';
import type { FlyoutPanel } from './types';
import { initialState } from './reducer';
export interface ExpandableFlyoutContext {
/**
* Right, left and preview panels
*/
panels: State;
/**
* Open the flyout with left, right and/or preview panels
*/
openFlyout: (panels: { left?: FlyoutPanel; right?: FlyoutPanel; preview?: FlyoutPanel }) => void;
/**
* Replaces the current right panel with a new one
*/
openRightPanel: (panel: FlyoutPanel) => void;
/**
* Replaces the current left panel with a new one
*/
openLeftPanel: (panel: FlyoutPanel) => void;
/**
* Add a new preview panel to the list of current preview panels
*/
openPreviewPanel: (panel: FlyoutPanel) => void;
/**
* Closes right panel
*/
closeRightPanel: () => void;
/**
* Closes left panel
*/
closeLeftPanel: () => void;
/**
* Closes all preview panels
*/
closePreviewPanel: () => void;
/**
* Go back to previous preview panel
*/
previousPreviewPanel: () => void;
/**
* Close all panels and closes flyout
*/
closeFlyout: () => void;
}
export const ExpandableFlyoutContext = createContext<ExpandableFlyoutContext | undefined>(
undefined
);
export interface ExpandableFlyoutProviderProps {
/**
* React children
*/
children: React.ReactNode;
}
/**
* Wrap your plugin with this context for the ExpandableFlyout React component.
*/
export const ExpandableFlyoutProvider = ({ children }: ExpandableFlyoutProviderProps) => {
const [state, dispatch] = useReducer(reducer, initialState);
const openPanels = useCallback(
({
right,
left,
preview,
}: {
right?: FlyoutPanel;
left?: FlyoutPanel;
preview?: FlyoutPanel;
}) => dispatch({ type: ActionType.openFlyout, payload: { left, right, preview } }),
[dispatch]
);
const openRightPanel = useCallback(
(panel: FlyoutPanel) => dispatch({ type: ActionType.openRightPanel, payload: panel }),
[dispatch]
);
const openLeftPanel = useCallback(
(panel: FlyoutPanel) => dispatch({ type: ActionType.openLeftPanel, payload: panel }),
[dispatch]
);
const openPreviewPanel = useCallback(
(panel: FlyoutPanel) => dispatch({ type: ActionType.openPreviewPanel, payload: panel }),
[dispatch]
);
const closeRightPanel = useCallback(
() => dispatch({ type: ActionType.closeRightPanel }),
[dispatch]
);
const closeLeftPanel = useCallback(
() => dispatch({ type: ActionType.closeLeftPanel }),
[dispatch]
);
const closePreviewPanel = useCallback(
() => dispatch({ type: ActionType.closePreviewPanel }),
[dispatch]
);
const previousPreviewPanel = useCallback(
() => dispatch({ type: ActionType.previousPreviewPanel }),
[dispatch]
);
const closePanels = useCallback(() => dispatch({ type: ActionType.closeFlyout }), [dispatch]);
const contextValue = useMemo(
() => ({
panels: state,
openFlyout: openPanels,
openRightPanel,
openLeftPanel,
openPreviewPanel,
closeRightPanel,
closeLeftPanel,
closePreviewPanel,
closeFlyout: closePanels,
previousPreviewPanel,
}),
[
state,
openPanels,
openRightPanel,
openLeftPanel,
openPreviewPanel,
closeRightPanel,
closeLeftPanel,
closePreviewPanel,
closePanels,
previousPreviewPanel,
]
);
return (
<ExpandableFlyoutContext.Provider value={contextValue}>
{children}
</ExpandableFlyoutContext.Provider>
);
};
/**
* Retrieve context's properties
*/
export const useExpandableFlyoutContext = (): ExpandableFlyoutContext => {
const contextValue = useContext(ExpandableFlyoutContext);
if (!contextValue) {
throw new Error(
'ExpandableFlyoutContext can only be used within ExpandableFlyoutContext provider'
);
}
return contextValue;
};

View file

@ -0,0 +1,105 @@
/*
* 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 { Panel } from './types';
import { ExpandableFlyout } from '.';
import { LEFT_SECTION, PREVIEW_SECTION, RIGHT_SECTION } from './components/test_ids';
import { ExpandableFlyoutContext } from './context';
describe('ExpandableFlyout', () => {
const registeredPanels: Panel[] = [
{
key: 'key',
width: 500,
component: () => <div>{'component'}</div>,
},
];
const onClose = () => window.alert('closed');
it(`shouldn't render flyout if no panels`, () => {
const context: ExpandableFlyoutContext = {
panels: {
right: undefined,
left: undefined,
preview: [],
},
} as unknown as ExpandableFlyoutContext;
const result = render(
<ExpandableFlyoutContext.Provider value={context}>
<ExpandableFlyout registeredPanels={registeredPanels} onClose={onClose} />
</ExpandableFlyoutContext.Provider>
);
expect(result.asFragment()).toMatchInlineSnapshot(`<DocumentFragment />`);
});
it('should render right section', () => {
const context: ExpandableFlyoutContext = {
panels: {
right: {
id: 'key',
},
left: {},
preview: [],
},
} as unknown as ExpandableFlyoutContext;
const { getByTestId } = render(
<ExpandableFlyoutContext.Provider value={context}>
<ExpandableFlyout registeredPanels={registeredPanels} onClose={onClose} />
</ExpandableFlyoutContext.Provider>
);
expect(getByTestId(RIGHT_SECTION)).toBeInTheDocument();
});
it('should render left section', () => {
const context: ExpandableFlyoutContext = {
panels: {
right: {},
left: {
id: 'key',
},
preview: [],
},
} as unknown as ExpandableFlyoutContext;
const { getByTestId } = render(
<ExpandableFlyoutContext.Provider value={context}>
<ExpandableFlyout registeredPanels={registeredPanels} onClose={onClose} />
</ExpandableFlyoutContext.Provider>
);
expect(getByTestId(LEFT_SECTION)).toBeInTheDocument();
});
it('should render preview section', () => {
const context: ExpandableFlyoutContext = {
panels: {
right: {},
left: {},
preview: [
{
id: 'key',
},
],
},
} as unknown as ExpandableFlyoutContext;
const { getByTestId } = render(
<ExpandableFlyoutContext.Provider value={context}>
<ExpandableFlyout registeredPanels={registeredPanels} onClose={onClose} />
</ExpandableFlyoutContext.Provider>
);
expect(getByTestId(PREVIEW_SECTION)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,113 @@
/*
* 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, { useCallback, useMemo } from 'react';
import { css } from '@emotion/react';
import type { EuiFlyoutProps } from '@elastic/eui';
import { EuiFlexGroup, EuiFlyout } from '@elastic/eui';
import { useExpandableFlyoutContext } from './context';
import { PreviewSection } from './components/preview_section';
import { RightSection } from './components/right_section';
import type { FlyoutPanel, Panel } from './types';
import { LeftSection } from './components/left_section';
export interface ExpandableFlyoutProps extends EuiFlyoutProps {
/**
* List of all registered panels available for render
*/
registeredPanels: Panel[];
/**
* Propagate out EuiFlyout onClose event
*/
handleOnFlyoutClosed?: () => void;
}
/**
* Expandable flyout UI React component.
* Displays 3 sections (right, left, preview) depending on the panels in the context.
*/
export const ExpandableFlyout: React.FC<ExpandableFlyoutProps> = ({
registeredPanels,
handleOnFlyoutClosed,
...flyoutProps
}) => {
const { panels, closeFlyout } = useExpandableFlyoutContext();
const { left, right, preview } = panels;
const onClose = useCallback(() => {
if (handleOnFlyoutClosed) handleOnFlyoutClosed();
closeFlyout();
}, [closeFlyout, handleOnFlyoutClosed]);
const leftSection = useMemo(
() => registeredPanels.find((panel) => panel.key === left?.id),
[left, registeredPanels]
);
const rightSection = useMemo(
() => registeredPanels.find((panel) => panel.key === right?.id),
[right, registeredPanels]
);
// retrieve the last preview panel (most recent)
const mostRecentPreview = preview ? preview[preview.length - 1] : undefined;
const showBackButton = preview && preview.length > 1;
const previewSection = useMemo(
() => registeredPanels.find((panel) => panel.key === mostRecentPreview?.id),
[mostRecentPreview, registeredPanels]
);
// do not add the flyout to the dom if there aren't any panels to display
if (!left && !right && !preview.length) {
return <></>;
}
const width: number = (leftSection?.width ?? 0) + (rightSection?.width ?? 0);
return (
<EuiFlyout
css={css`
overflow-y: scroll;
`}
{...flyoutProps}
size={width}
ownFocus={false}
onClose={onClose}
>
<EuiFlexGroup
direction={leftSection ? 'row' : 'column'}
wrap={false}
gutterSize="none"
style={{ height: '100%' }}
>
{leftSection && left ? (
<LeftSection
component={leftSection.component({ ...(left as FlyoutPanel) })}
width={leftSection.width}
/>
) : null}
{rightSection && right ? (
<RightSection
component={rightSection.component({ ...(right as FlyoutPanel) })}
width={rightSection.width}
/>
) : null}
</EuiFlexGroup>
{previewSection && preview ? (
<PreviewSection
component={previewSection.component({ ...(mostRecentPreview as FlyoutPanel) })}
showBackButton={showBackButton}
width={leftSection?.width}
/>
) : null}
</EuiFlyout>
);
};
ExpandableFlyout.displayName = 'ExpandableFlyout';

View file

@ -0,0 +1,417 @@
/*
* 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 { FlyoutPanel } from './types';
import { initialState, reducer, State } from './reducer';
import { Action, ActionType } from './actions';
const rightPanel1: FlyoutPanel = {
id: 'right1',
path: ['path'],
};
const leftPanel1: FlyoutPanel = {
id: 'left1',
params: { id: 'id' },
};
const previewPanel1: FlyoutPanel = {
id: 'preview1',
state: { id: 'state' },
};
const rightPanel2: FlyoutPanel = {
id: 'right2',
path: ['path'],
};
const leftPanel2: FlyoutPanel = {
id: 'left2',
params: { id: 'id' },
};
const previewPanel2: FlyoutPanel = {
id: 'preview2',
state: { id: 'state' },
};
describe('reducer', () => {
describe('should handle openFlyout action', () => {
it('should add panels to empty state', () => {
const state: State = initialState;
const action: Action = {
type: ActionType.openFlyout,
payload: {
right: rightPanel1,
left: leftPanel1,
preview: previewPanel1,
},
};
const newState: State = reducer(state, action);
expect(newState).toEqual({
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
});
});
it('should override all panels in the state', () => {
const state: State = {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1, { id: 'preview' }],
};
const action: Action = {
type: ActionType.openFlyout,
payload: {
right: rightPanel2,
left: leftPanel2,
preview: previewPanel2,
},
};
const newState: State = reducer(state, action);
expect(newState).toEqual({
left: leftPanel2,
right: rightPanel2,
preview: [previewPanel2],
});
});
it('should remove all panels despite only passing a single section ', () => {
const state: State = {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
};
const action: Action = {
type: ActionType.openFlyout,
payload: {
right: rightPanel2,
},
};
const newState: State = reducer(state, action);
expect(newState).toEqual({
left: undefined,
right: rightPanel2,
preview: [],
});
});
});
describe('should handle openRightPanel action', () => {
it('should add right panel to empty state', () => {
const state: State = initialState;
const action: Action = {
type: ActionType.openRightPanel,
payload: rightPanel1,
};
const newState: State = reducer(state, action);
expect(newState).toEqual({
left: undefined,
right: rightPanel1,
preview: [],
});
});
it('should replace right panel', () => {
const state: State = {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
};
const action: Action = {
type: ActionType.openRightPanel,
payload: rightPanel2,
};
const newState: State = reducer(state, action);
expect(newState).toEqual({
left: leftPanel1,
right: rightPanel2,
preview: [previewPanel1],
});
});
});
describe('should handle openLeftPanel action', () => {
it('should add left panel to empty state', () => {
const state: State = initialState;
const action: Action = {
type: ActionType.openLeftPanel,
payload: leftPanel1,
};
const newState: State = reducer(state, action);
expect(newState).toEqual({
left: leftPanel1,
right: undefined,
preview: [],
});
});
it('should replace only left panel', () => {
const state: State = {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
};
const action: Action = {
type: ActionType.openLeftPanel,
payload: leftPanel2,
};
const newState: State = reducer(state, action);
expect(newState).toEqual({
left: leftPanel2,
right: rightPanel1,
preview: [previewPanel1],
});
});
});
describe('should handle openPreviewPanel action', () => {
it('should add preview panel to empty state', () => {
const state: State = initialState;
const action: Action = {
type: ActionType.openPreviewPanel,
payload: previewPanel1,
};
const newState: State = reducer(state, action);
expect(newState).toEqual({
left: undefined,
right: undefined,
preview: [previewPanel1],
});
});
it('should add preview panel to the list of preview panels', () => {
const state: State = {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
};
const action: Action = {
type: ActionType.openPreviewPanel,
payload: previewPanel2,
};
const newState: State = reducer(state, action);
expect(newState).toEqual({
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1, previewPanel2],
});
});
});
describe('should handle closeRightPanel action', () => {
it('should return empty state when removing right panel from empty state', () => {
const state: State = initialState;
const action: Action = {
type: ActionType.closeRightPanel,
};
const newState: State = reducer(state, action);
expect(newState).toEqual(state);
});
it(`should return unmodified state when removing right panel when no right panel exist`, () => {
const state: State = {
left: leftPanel1,
right: undefined,
preview: [previewPanel1],
};
const action: Action = {
type: ActionType.closeRightPanel,
};
const newState: State = reducer(state, action);
expect(newState).toEqual(state);
});
it('should remove right panel', () => {
const state: State = {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
};
const action: Action = {
type: ActionType.closeRightPanel,
};
const newState: State = reducer(state, action);
expect(newState).toEqual({
left: leftPanel1,
right: undefined,
preview: [previewPanel1],
});
});
});
describe('should handle closeLeftPanel action', () => {
it('should return empty state when removing left panel on empty state', () => {
const state: State = initialState;
const action: Action = {
type: ActionType.closeLeftPanel,
};
const newState: State = reducer(state, action);
expect(newState).toEqual(state);
});
it(`should return unmodified state when removing left panel when no left panel exist`, () => {
const state: State = {
left: undefined,
right: rightPanel1,
preview: [],
};
const action: Action = {
type: ActionType.closeLeftPanel,
};
const newState: State = reducer(state, action);
expect(newState).toEqual(state);
});
it('should remove left panel', () => {
const state: State = {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
};
const action: Action = {
type: ActionType.closeLeftPanel,
};
const newState: State = reducer(state, action);
expect(newState).toEqual({
left: undefined,
right: rightPanel1,
preview: [previewPanel1],
});
});
});
describe('should handle closePreviewPanel action', () => {
it('should return empty state when removing preview panel on empty state', () => {
const state: State = initialState;
const action: Action = {
type: ActionType.closePreviewPanel,
};
const newState: State = reducer(state, action);
expect(newState).toEqual(state);
});
it(`should return unmodified state when removing preview panel when no preview panel exist`, () => {
const state: State = {
left: leftPanel1,
right: rightPanel1,
preview: [],
};
const action: Action = {
type: ActionType.closePreviewPanel,
};
const newState: State = reducer(state, action);
expect(newState).toEqual(state);
});
it('should remove all preview panels', () => {
const state: State = {
left: rightPanel1,
right: leftPanel1,
preview: [previewPanel1, previewPanel2],
};
const action: Action = {
type: ActionType.closePreviewPanel,
};
const newState: State = reducer(state, action);
expect(newState).toEqual({
left: rightPanel1,
right: leftPanel1,
preview: [],
});
});
});
describe('should handle previousPreviewPanel action', () => {
it('should return empty state when previous preview panel on an empty state', () => {
const state: State = initialState;
const action: Action = {
type: ActionType.previousPreviewPanel,
};
const newState: State = reducer(state, action);
expect(newState).toEqual(state);
});
it(`should return unmodified state when previous preview panel when no preview panel exist`, () => {
const state: State = {
left: leftPanel1,
right: rightPanel1,
preview: [],
};
const action: Action = {
type: ActionType.previousPreviewPanel,
};
const newState: State = reducer(state, action);
expect(newState).toEqual(state);
});
it('should remove only last preview panel', () => {
const state: State = {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1, previewPanel2],
};
const action: Action = {
type: ActionType.previousPreviewPanel,
};
const newState: State = reducer(state, action);
expect(newState).toEqual({
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
});
});
});
describe('should handle closeFlyout action', () => {
it('should return empty state when closing flyout on an empty state', () => {
const state: State = initialState;
const action: Action = {
type: ActionType.closeFlyout,
};
const newState: State = reducer(state, action);
expect(newState).toEqual(initialState);
});
it('should remove all panels', () => {
const state: State = {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
};
const action: Action = {
type: ActionType.closeFlyout,
};
const newState: State = reducer(state, action);
expect(newState).toEqual({
left: undefined,
right: undefined,
preview: [],
});
});
});
});

View file

@ -0,0 +1,109 @@
/*
* 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 { FlyoutPanel } from './types';
import { Action, ActionType } from './actions';
export interface State {
/**
* Panel to render in the left section
*/
left: FlyoutPanel | undefined;
/**
* Panel to render in the right section
*/
right: FlyoutPanel | undefined;
/**
* Panels to render in the preview section
*/
preview: FlyoutPanel[];
}
export const initialState: State = {
left: undefined,
right: undefined,
preview: [],
};
export function reducer(state: State, action: Action) {
switch (action.type) {
/**
* Open the flyout by replacing the entire state with new panels.
*/
case ActionType.openFlyout: {
const { left, right, preview } = action.payload;
return {
left,
right,
preview: preview ? [preview] : [],
};
}
/**
* Opens a right section by replacing the previous right panel with the new one.
*/
case ActionType.openRightPanel: {
return { ...state, right: action.payload };
}
/**
* Opens a left section by replacing the previous left panel with the new one.
*/
case ActionType.openLeftPanel: {
return { ...state, left: action.payload };
}
/**
* Opens a preview section by adding to the array of preview panels.
*/
case ActionType.openPreviewPanel: {
return { ...state, preview: [...state.preview, action.payload] };
}
/**
* Closes the right section by removing the right panel.
*/
case ActionType.closeRightPanel: {
return { ...state, right: undefined };
}
/**
* Close the left section by removing the left panel.
*/
case ActionType.closeLeftPanel: {
return { ...state, left: undefined };
}
/**
* Closes the preview section by removing all the preview panels.
*/
case ActionType.closePreviewPanel: {
return { ...state, preview: [] };
}
/**
* Navigates to the previous preview panel by removing the last entry in the array of preview panels.
*/
case ActionType.previousPreviewPanel: {
const p: FlyoutPanel[] = [...state.preview];
p.pop();
return { ...state, preview: p };
}
/**
* Close the flyout by removing all the panels.
*/
case ActionType.closeFlyout: {
return {
left: undefined,
right: undefined,
preview: [],
};
}
}
}

View file

@ -0,0 +1,43 @@
/*
* 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';
export interface FlyoutPanel {
/**
* Unique key to identify the panel
*/
id: string;
/**
* Any parameters necessary for the initial requests within the flyout
*/
params?: Record<string, unknown>;
/**
* Tracks the path for what to show in a panel. We may have multiple tabs or details..., so easiest to just use a stack
*/
path?: string[];
/**
* Tracks visual state such as whether the panel is collapsed
*/
state?: Record<string, unknown>;
}
export interface Panel {
/**
* Unique key used to identify the panel
*/
key?: string;
/**
* Component to be rendered
*/
component: (props: FlyoutPanel) => React.ReactElement;
/**
* Width used when rendering the panel
*/
width: number; // TODO remove this, the width shouldn't be a property of a panel, but handled at the flyout level
}

View file

@ -0,0 +1,24 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react",
"@emotion/react/types/css-prop",
"@testing-library/jest-dom",
"@testing-library/react"
]
},
"include": [
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/i18n"
]
}

View file

@ -668,6 +668,8 @@
"@kbn/event-log-fixture-plugin/*": ["x-pack/test/plugin_api_integration/plugins/event_log/*"],
"@kbn/event-log-plugin": ["x-pack/plugins/event_log"],
"@kbn/event-log-plugin/*": ["x-pack/plugins/event_log/*"],
"@kbn/expandable-flyout": ["packages/kbn-expandable-flyout"],
"@kbn/expandable-flyout/*": ["packages/kbn-expandable-flyout/*"],
"@kbn/expect": ["packages/kbn-expect"],
"@kbn/expect/*": ["packages/kbn-expect/*"],
"@kbn/exploratory-view-example-plugin": ["x-pack/examples/exploratory_view_example"],

View file

@ -90,6 +90,10 @@ export const allowedExperimentalValues = Object.freeze({
* Enables top charts on Alerts Page
*/
alertsPageChartsEnabled: true,
/**
* Enables the new security flyout over the current alert details flyout
*/
securityFlyoutEnabled: false,
/**
* Keep DEPRECATED experimental flags that are documented to prevent failed upgrades.

View file

@ -11,6 +11,7 @@ import { EuiThemeProvider, useEuiTheme } from '@elastic/eui';
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 { ExpandableFlyout, ExpandableFlyoutProvider } from '@kbn/expandable-flyout';
import { useSecuritySolutionNavigation } from '../../../common/components/navigation/use_security_solution_navigation';
import { TimelineId } from '../../../../common/types/timeline';
import { getTimelineShowStatusByIdSelector } from '../../../timelines/components/flyout/selectors';
@ -80,34 +81,35 @@ export const SecuritySolutionTemplateWrapper: React.FC<Omit<KibanaPageTemplatePr
* between EuiPageTemplate and the security solution pages.
*/
return (
<StyledKibanaPageTemplate
$addBottomPadding={addBottomPadding}
$isShowingTimelineOverlay={isShowingTimelineOverlay}
paddingSize="none"
solutionNav={solutionNav}
restrictWidth={showEmptyState ? NO_DATA_PAGE_MAX_WIDTH : false}
{...rest}
>
<GlobalKQLHeader />
<KibanaPageTemplate.Section
className="securityPageWrapper"
data-test-subj="pageContainer"
paddingSize="l"
alignment={showEmptyState ? 'center' : 'top'}
component="div"
<ExpandableFlyoutProvider>
<StyledKibanaPageTemplate
$addBottomPadding={addBottomPadding}
$isShowingTimelineOverlay={isShowingTimelineOverlay}
paddingSize="none"
solutionNav={solutionNav}
restrictWidth={showEmptyState ? NO_DATA_PAGE_MAX_WIDTH : false}
{...rest}
>
{children}
</KibanaPageTemplate.Section>
{isTimelineBottomBarVisible && (
<KibanaPageTemplate.BottomBar {...SecuritySolutionBottomBarProps}>
<EuiThemeProvider colorMode={globalColorMode}>
<SecuritySolutionBottomBar />
</EuiThemeProvider>
</KibanaPageTemplate.BottomBar>
)}
</StyledKibanaPageTemplate>
<GlobalKQLHeader />
<KibanaPageTemplate.Section
className="securityPageWrapper"
data-test-subj="pageContainer"
paddingSize="l"
alignment={showEmptyState ? 'center' : 'top'}
component="div"
>
{children}
</KibanaPageTemplate.Section>
{isTimelineBottomBarVisible && (
<KibanaPageTemplate.BottomBar {...SecuritySolutionBottomBarProps}>
<EuiThemeProvider colorMode={globalColorMode}>
<SecuritySolutionBottomBar />
</EuiThemeProvider>
</KibanaPageTemplate.BottomBar>
)}
<ExpandableFlyout registeredPanels={[]} onClose={() => {}} />
</StyledKibanaPageTemplate>
</ExpandableFlyoutProvider>
);
});

View file

@ -8,6 +8,7 @@
import type { EuiDataGridCellValueElementProps } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { useExpandableFlyoutContext } from '@kbn/expandable-flyout';
import type {
SetEventsDeleted,
SetEventsLoading,
@ -19,6 +20,7 @@ import { getMappedNonEcsValue } from '../../../../timelines/components/timeline/
import type { TimelineItem, TimelineNonEcsData } from '../../../../../common/search_strategy';
import type { ColumnHeaderOptions, OnRowSelected } from '../../../../../common/types/timeline';
import { dataTableActions } from '../../../store/data_table';
import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental_features';
type Props = EuiDataGridCellValueElementProps & {
columnHeaders: ColumnHeaderOptions[];
@ -64,7 +66,10 @@ const RowActionComponent = ({
}: Props) => {
const { data: timelineNonEcsData, ecs: ecsData, _id: eventId, _index: indexName } = data ?? {};
const { openFlyout } = useExpandableFlyoutContext();
const dispatch = useDispatch();
const isSecurityFlyoutEnabled = useIsExperimentalFeatureEnabled('securityFlyoutEnabled');
const columnValues = useMemo(
() =>
@ -90,14 +95,18 @@ const RowActionComponent = ({
},
};
dispatch(
dataTableActions.toggleDetailPanel({
...updatedExpandedDetail,
tabType,
id: tableId,
})
);
}, [dispatch, eventId, indexName, tabType, tableId]);
if (isSecurityFlyoutEnabled) {
openFlyout({});
} else {
dispatch(
dataTableActions.toggleDetailPanel({
...updatedExpandedDetail,
tabType,
id: tableId,
})
);
}
}, [dispatch, eventId, indexName, isSecurityFlyoutEnabled, openFlyout, tabType, tableId]);
const Action = controlColumn.rowCellRender;

View file

@ -20,6 +20,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { Action } from '@kbn/ui-actions-plugin/public';
import { CellActionsProvider } from '@kbn/cell-actions';
import { ExpandableFlyoutProvider } from '@kbn/expandable-flyout';
import { ConsoleManager } from '../../management/components/console';
import type { State } from '../store';
import { createStore } from '../store';
@ -66,13 +67,15 @@ export const TestProvidersComponent: React.FC<Props> = ({
<ReduxStoreProvider store={store}>
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
<QueryClientProvider client={queryClient}>
<ConsoleManager>
<CellActionsProvider
getTriggerCompatibleActions={() => Promise.resolve(cellActions)}
>
<DragDropContext onDragEnd={onDragEnd}>{children}</DragDropContext>
</CellActionsProvider>
</ConsoleManager>
<ExpandableFlyoutProvider>
<ConsoleManager>
<CellActionsProvider
getTriggerCompatibleActions={() => Promise.resolve(cellActions)}
>
<DragDropContext onDragEnd={onDragEnd}>{children}</DragDropContext>
</CellActionsProvider>
</ConsoleManager>
</ExpandableFlyoutProvider>
</QueryClientProvider>
</ThemeProvider>
</ReduxStoreProvider>

View file

@ -142,5 +142,6 @@
"@kbn/cell-actions",
"@kbn/shared-ux-router",
"@kbn/alerts-as-data-utils",
"@kbn/expandable-flyout",
]
}

View file

@ -4073,6 +4073,10 @@
version "0.0.0"
uid ""
"@kbn/expandable-flyout@link:packages/kbn-expandable-flyout":
version "0.0.0"
uid ""
"@kbn/expect@link:packages/kbn-expect":
version "0.0.0"
uid ""