Skip to content

Commit

Permalink
[Security Solution][Expandable flyout] add multi flyout support to kb…
Browse files Browse the repository at this point in the history
…n-expandable-flyout package (elastic#176457)
  • Loading branch information
PhilippeOberti authored and fkanout committed Mar 4, 2024
1 parent 77c7d91 commit 6e17a8d
Show file tree
Hide file tree
Showing 17 changed files with 989 additions and 252 deletions.
37 changes: 26 additions & 11 deletions packages/kbn-expandable-flyout/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ The flyout is composed of 3 sections:
## Design decisions

The expandable-flyout package is designed to render a single flyout for an entire plugin. While displaying multiple flyouts might be feasible, it will be a bit complicated, and we recommend instead to build multiple panels, with each their own context to manage their data (for example, take a look at the Security Solution [setup](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/flyout)).

The expandable-flyout is making some strict UI design decisions:
- when in collapsed mode (i.e. when only the right/preview section is open), the flyout's width linearly grows from its minimum value of 380px to its maximum value of 750px
- when in expanded mode (i.e. when the left section is opened), the flyout's width changes depending on the browser's width:
Expand All @@ -23,6 +21,25 @@ The expandable-flyout is making some strict UI design decisions:

> While the expandable-flyout will work on very small screens, having both the right and left sections visible at the same time will not be a good experience to the user. We recommend only showing the right panel, and therefore handling this situation when you build your panels by considering hiding the actions that could open the left panel (like the expand details button in the [FlyoutNavigation](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.tsx)).
## State persistence

The expandable flyout offers 2 ways of managing its state:

### Memory storage

The default behavior saves the state of the flyout in memory. The state is internal to the package and based on an isolated redux context. Using this mode means the state will not be persisted when sharing url or reloading browser pages.

### Url storage

The second way (done by setting the `urlKey` prop to a string value) saves the state of the flyout in the url. This allows the flyout to be automatically reopened when users refresh the browser page, or when users share an url. The `urlKey` will be used as the url parameter.

**_Note: the word `memory` cannot be used as an `urlKey` as it is reserved for the memory storage behavior. You can use any other string value, try to use something that should be unique._**

> We highly recommend NOT nesting flyouts in your code, as it would cause conflicts for the url keys. We recommend instead to build multiple panels, with each their own context to manage their data (for example, take a look at the Security Solution [setup](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution/public/flyout)).
>
> A good solution is for example to have one instance of a flyout at a page level, and then have multiple panels that can be opened in that flyout.

## Package API

The ExpandableFlyout [React component](https://github.com/elastic/kibana/tree/main/packages/kbn-expandable-flyout/src/index.tsx) renders the UI, leveraging an [EuiFlyout](https://eui.elastic.co/#/layout/flyout).
Expand All @@ -46,10 +63,15 @@ To control (or mutate) flyout's layout, you can utilize [useExpandableFlyoutApi]

To use the expandable flyout in your plugin, first you need wrap your code with the [context provider](https://github.com/elastic/kibana/blob/main/packages/kbn-expandable-flyout/src/context.tsx) at a high enough level as follows:
```typescript jsx
// state stored in the url
<ExpandableFlyoutProvider urlKey={'myUrlKey'}>
...
</ExpandableFlyoutProvider>


// state stored in memory
<ExpandableFlyoutProvider>

...

</ExpandableFlyoutProvider>
```

Expand All @@ -60,13 +82,6 @@ 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_

## State persistence

The expandable flyout offers 2 ways of managing its state:
- the default behavior saves the state of the flyout in the url. This allows the flyout to be automatically reopened when users refresh the browser page, or when users share a url
- the second way (done by setting the `storage` prop to `memory`) stores the state of the flyout in memory. This means that the flyout will not be reopened when users refresh the browser page, or when users share a url


## Terminology

### Section
Expand Down
4 changes: 1 addition & 3 deletions packages/kbn-expandable-flyout/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,9 @@ export { ExpandableFlyout } from './src';
export { useExpandableFlyoutApi } from './src/hooks/use_expandable_flyout_api';
export { useExpandableFlyoutState } from './src/hooks/use_expandable_flyout_state';

export { type State as ExpandableFlyoutState } from './src/state';
export { type FlyoutState as ExpandableFlyoutState } from './src/state';

export { ExpandableFlyoutProvider } from './src/provider';

export type { ExpandableFlyoutProps } from './src';
export type { FlyoutPanelProps, PanelPath, ExpandableFlyoutApi } from './src/types';

export { EXPANDABLE_FLYOUT_URL_KEY } from './src/constants';
91 changes: 83 additions & 8 deletions packages/kbn-expandable-flyout/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,24 +23,99 @@ export enum ActionType {
}

export const openPanelsAction = createAction<{
/**
* Panel to render in the right section
*/
right?: FlyoutPanelProps;
/**
* Panel to render in the left section
*/
left?: FlyoutPanelProps;
/**
* Panels to render in the preview section
*/
preview?: FlyoutPanelProps;
/**
* Unique identifier for the flyout (either the urlKey or 'memory')
*/
id: string;
}>(ActionType.openFlyout);

export const openRightPanelAction = createAction<FlyoutPanelProps>(ActionType.openRightPanel);
export const openLeftPanelAction = createAction<FlyoutPanelProps>(ActionType.openLeftPanel);
export const openPreviewPanelAction = createAction<FlyoutPanelProps>(ActionType.openPreviewPanel);
export const openRightPanelAction = createAction<{
/**
* Panel to render in the right section
*/
right: FlyoutPanelProps;
/**
* Unique identifier for the flyout (either the urlKey or 'memory')
*/
id: string;
}>(ActionType.openRightPanel);
export const openLeftPanelAction = createAction<{
/**
* Panel to render in the left section
*/
left: FlyoutPanelProps;
/**
* Unique identifier for the flyout (either the urlKey or 'memory')
*/
id: string;
}>(ActionType.openLeftPanel);
export const openPreviewPanelAction = createAction<{
/**
* Panels to render in the preview section
*/
preview: FlyoutPanelProps;
id: string;
}>(ActionType.openPreviewPanel);

export const closePanelsAction = createAction(ActionType.closeFlyout);
export const closeRightPanelAction = createAction(ActionType.closeRightPanel);
export const closeLeftPanelAction = createAction(ActionType.closeLeftPanel);
export const closePreviewPanelAction = createAction(ActionType.closePreviewPanel);
export const closePanelsAction = createAction<{
/**
* Unique identifier for the flyout (either the urlKey or 'memory')
*/
id: string;
}>(ActionType.closeFlyout);
export const closeRightPanelAction = createAction<{
/**
* Unique identifier for the flyout (either the urlKey or 'memory')
*/
id: string;
}>(ActionType.closeRightPanel);
export const closeLeftPanelAction = createAction<{
/**
* Unique identifier for the flyout (either the urlKey or 'memory')
*/
id: string;
}>(ActionType.closeLeftPanel);
export const closePreviewPanelAction = createAction<{
/**
* Unique identifier for the flyout (either the urlKey or 'memory')
*/
id: string;
}>(ActionType.closePreviewPanel);

export const previousPreviewPanelAction = createAction(ActionType.previousPreviewPanel);
export const previousPreviewPanelAction = createAction<{
/**
* Unique identifier for the flyout (either the urlKey or 'memory')
*/
id: string;
}>(ActionType.previousPreviewPanel);

export const urlChangedAction = createAction<{
/**
* Panel to render in the right section
*/
right?: FlyoutPanelProps;
/**
* Panel to render in the left section
*/
left?: FlyoutPanelProps;
/**
* Panels to render in the preview section
*/
preview?: FlyoutPanelProps;
/**
* Unique identifier for the flyout (either the urlKey or 'memory')
*/
id: string;
}>(ActionType.urlChanged);
5 changes: 4 additions & 1 deletion packages/kbn-expandable-flyout/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,7 @@
* Side Public License, v 1.
*/

export const EXPANDABLE_FLYOUT_URL_KEY = 'eventFlyout' as const;
/**
* This is a reserved word that we use as an id when no urlKey is provided and we are in memory storage mode
*/
export const REDUX_ID_FOR_MEMORY_STORAGE = 'memory';
63 changes: 63 additions & 0 deletions packages/kbn-expandable-flyout/src/context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* 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, memo, useContext, useMemo } from 'react';

export interface ExpandableFlyoutContext {
/**
* Unique key to be used as url parameter to store the state of the flyout
*/
urlKey: string | undefined;
}

export const ExpandableFlyoutContext = createContext<ExpandableFlyoutContext | undefined>(
undefined
);

export interface ExpandableFlyoutContextProviderProps {
/**
* Unique key to be used as url parameter to store the state of the flyout
*/
urlKey: string | undefined;
/**
* React components to render
*/
children: React.ReactNode;
}

/**
* Context used to share the value of the urlKey to the rest of the expandable flyout's code
*/
export const ExpandableFlyoutContextProvider = memo<ExpandableFlyoutContextProviderProps>(
({ urlKey, children }) => {
const contextValue = useMemo(
() => ({
urlKey,
}),
[urlKey]
);

return (
<ExpandableFlyoutContext.Provider value={contextValue}>
{children}
</ExpandableFlyoutContext.Provider>
);
}
);

ExpandableFlyoutContextProvider.displayName = 'ExpandableFlyoutContextProvider';

export const useExpandableFlyoutContext = () => {
const context = useContext(ExpandableFlyoutContext);
if (context === undefined) {
throw new Error(
'ExpandableFlyoutContext can only be used within ExpandableFlyoutContext provider'
);
}
return context;
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
*/

import { useCallback, useMemo } from 'react';
import { REDUX_ID_FOR_MEMORY_STORAGE } from '../constants';
import { useExpandableFlyoutContext } from '../context';
import {
closeLeftPanelAction,
closePanelsAction,
Expand All @@ -29,6 +31,10 @@ export type { ExpandableFlyoutApi };
export const useExpandableFlyoutApi = () => {
const dispatch = useDispatch();

const { urlKey } = useExpandableFlyoutContext();
// if no urlKey is provided, we are in memory storage mode and use the reserved word 'memory'
const id = urlKey || REDUX_ID_FOR_MEMORY_STORAGE;

const openPanels = useCallback(
({
right,
Expand All @@ -38,39 +44,43 @@ export const useExpandableFlyoutApi = () => {
right?: FlyoutPanelProps;
left?: FlyoutPanelProps;
preview?: FlyoutPanelProps;
}) => dispatch(openPanelsAction({ right, left, preview })),
[dispatch]
}) => dispatch(openPanelsAction({ right, left, preview, id })),
[dispatch, id]
);

const openRightPanel = useCallback(
(panel: FlyoutPanelProps) => dispatch(openRightPanelAction(panel)),
[dispatch]
(panel: FlyoutPanelProps) => dispatch(openRightPanelAction({ right: panel, id })),
[dispatch, id]
);

const openLeftPanel = useCallback(
(panel: FlyoutPanelProps) => dispatch(openLeftPanelAction(panel)),
[dispatch]
(panel: FlyoutPanelProps) => dispatch(openLeftPanelAction({ left: panel, id })),
[dispatch, id]
);

const openPreviewPanel = useCallback(
(panel: FlyoutPanelProps) => dispatch(openPreviewPanelAction(panel)),
[dispatch]
(panel: FlyoutPanelProps) => dispatch(openPreviewPanelAction({ preview: panel, id })),
[dispatch, id]
);

const closeRightPanel = useCallback(() => dispatch(closeRightPanelAction()), [dispatch]);
const closeRightPanel = useCallback(
() => dispatch(closeRightPanelAction({ id })),
[dispatch, id]
);

const closeLeftPanel = useCallback(() => dispatch(closeLeftPanelAction()), [dispatch]);
const closeLeftPanel = useCallback(() => dispatch(closeLeftPanelAction({ id })), [dispatch, id]);

const closePreviewPanel = useCallback(() => dispatch(closePreviewPanelAction()), [dispatch]);
const closePreviewPanel = useCallback(
() => dispatch(closePreviewPanelAction({ id })),
[dispatch, id]
);

const previousPreviewPanel = useCallback(
() => dispatch(previousPreviewPanelAction()),
[dispatch]
() => dispatch(previousPreviewPanelAction({ id })),
[dispatch, id]
);

const closePanels = useCallback(() => {
dispatch(closePanelsAction());
}, [dispatch]);
const closePanels = useCallback(() => dispatch(closePanelsAction({ id })), [dispatch, id]);

const api: ExpandableFlyoutApi = useMemo(
() => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@
* Side Public License, v 1.
*/

import { stateSelector, useSelector } from '../redux';
import { REDUX_ID_FOR_MEMORY_STORAGE } from '../constants';
import { useExpandableFlyoutContext } from '../context';
import { selectPanelsById, useSelector } from '../redux';

/**
* This hook allows you to access the flyout state, read open panels and previews.
* This hook allows you to access the flyout state, read open right, left and preview panels.
*/
export const useExpandableFlyoutState = () => {
return useSelector(stateSelector);
const { urlKey } = useExpandableFlyoutContext();
// if no urlKey is provided, we are in memory storage mode and use the reserved word 'memory'
const id = urlKey || REDUX_ID_FOR_MEMORY_STORAGE;
return useSelector(selectPanelsById(id));
};
Loading

0 comments on commit 6e17a8d

Please sign in to comment.