Skip to content

Commit

Permalink
[Expandable Flyout] - customize default right, left and preview width…
Browse files Browse the repository at this point in the history
…s for push mode (#206155)

## Summary

This PR is making some changes to the Expandable Flyout package. Prior
work had added [push
mode](#182615) to the package,
added [custom way](#170078) to
handle the width for multiple resolutions, and [added
support](#192906) for the internal
section to be resiable by users.

This PR improves the default user experience when using the flyout in
push mode. Until now, the default `right`, `left` and `preview` width in
`push` mode and `overlay` mode were identical. This meant that the
flyout rendered in `push` mode was most of the time using the whole
screen, not leaving any room to the rest of the page content (like the
alerts table).

The `push` widths are now calculated in a different way, to leave as
much room as possible while still allowing the flyout `right` and `left`
sections to render their content correctly, at least most of the time.
Users can still resize the whole flyout as well as the internal `right`
and `left` sections. The `push` widths are generally smaller/narrower
than the `overlay` widths.

#### The `overlay` mode default widths have not changed


https://github.com/user-attachments/assets/28b6c41e-b12c-45cf-aa3e-026a7acdb7b3

#### The `push` mode default widths


https://github.com/user-attachments/assets/93706f9e-212b-4cb4-8748-552f2daed585

### Checklist

- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
  • Loading branch information
PhilippeOberti authored Feb 6, 2025
1 parent ebb31d2 commit e7140ff
Show file tree
Hide file tree
Showing 11 changed files with 410 additions and 160 deletions.
34 changes: 20 additions & 14 deletions x-pack/solutions/security/packages/expandable-flyout/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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
Expand All @@ -13,14 +14,14 @@ The flyout is composed of 3 sections:
## Design decisions

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:
- if the window is smaller than 1600px, the flyout takes the entire browser window (minus 48px of padding on the left)
- for windows bigger than 1600px, the flyout's width is 80% of the entire browser window (with a max width of 1500px for the left section, and 750px for the right section)
The expandable-flyout offers 2 render modes: push and overlay (leveraged from the use of the see [EUI](https://eui.elastic.co/#/layout/flyout#push-versus-overlay)).

The flyout offers 2 sets of default widths: one for overlay mode and one for push mode. Those width are calculated based on the width of the brower window, and define the default values to be used to render the right, left and preview sections, in a way that is aesthetically pleasing. You can find the details of the calculations [here](https://github.com/elastic/kibana/blob/main/x-pack/solutions/security/packages/expandable-flyout/src/hooks/use_window_width.ts);

> 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/solutions/security/plugins/security_solution/public/flyout/shared/components/flyout_navigation.tsx)).
The flyout also offers a way to the users to change the width of the different sections. These are saved separately from the default widths mentioned above, and the users can always reset back to the default using the gear menu (see the `Optional properties` section below).

## State persistence

The expandable flyout offers 2 ways of managing its state:
Expand All @@ -31,15 +32,14 @@ The default behavior saves the state of the flyout in memory. The state is inter

### 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.
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/solutions/security/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/x-pack/solutions/security/packages/expandable-flyout/src/index.tsx) renders the UI, leveraging an [EuiFlyout](https://eui.elastic.co/#/layout/flyout).
Expand All @@ -49,6 +49,7 @@ To retrieve the flyout's layout (left, right and preview panels), you can utiliz
To control (or mutate) flyout's layout, you can utilize [useExpandableFlyoutApi](https://github.com/elastic/kibana/blob/main/x-pack/solutions/security/packages/expandable-flyout/src/hooks/use_expandable_flyout_api.ts).

**Expandable Flyout API** exposes the following methods:

- **openFlyout**: open the flyout with a set of panels
- **openRightPanel**: open a right panel
- **openLeftPanel**: open a left panel
Expand All @@ -59,13 +60,14 @@ To control (or mutate) flyout's layout, you can utilize [useExpandableFlyoutApi]
- **previousPreviewPanel**: navigate to the previous preview panel
- **closeFlyout**: close the flyout

> The expandable flyout propagates the `onClose` callback from the EuiFlyout component. As we recommend having a single instance of the flyout in your application, it's up to the application's code to dispatch the event (through Redux, window events, observable, prop drilling...).
> The expandable flyout propagates the `onClose` callback from the EuiFlyout component. As we recommend having a single instance of the flyout in your application, it's up to the application's code to dispatch the event (through Redux, window events, observable, prop drilling...).
When calling `openFlyout`, the right panel state is automatically appended in the `history` slice in the redux context. To access the flyout's history, you can use the [useExpandableFlyoutHistory](https://github.com/elastic/kibana/blob/main/x-pack/solutions/security/packages/expandable-flyout/src/hooks/use_expandable_flyout_history.ts) hook.

## Usage

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/x-pack/solutions/security/packages/expandable-flyout/src/context.tsx) at a high enough level as follows:

```typescript jsx
// state stored in the url
<ExpandableFlyoutProvider urlKey={'myUrlKey'}>
Expand All @@ -84,22 +86,26 @@ Then use the [React UI component](https://github.com/elastic/kibana/tree/main/x-
```typescript jsx
<ExpandableFlyout registeredPanels={myPanels} />
```

_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,
flyoutCustomProps ? : {
hideSettings? : boolean;
pushVsOverlay? : { disabled: boolean; tooltip: string; };
resize? : { disabled: boolean; tooltip: string; };
};
```

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.
The gear icon can be hidden by setting the `hideSettings` property to `true` in the flyout's custom props. When shown, clicking on the gear icon opens a popover with the other options rendered in it.

The `pushVsOverlay` property allows to disable the push/overlay toggle and when enabled allows users to switch between the 2 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.

The `resize` property allow to disable the `Reset size` button and when enabled allows users to reset all the widths to the default (see calculations [here](https://github.com/elastic/kibana/blob/main/x-pack/solutions/security/packages/expandable-flyout/src/hooks/use_window_width.ts)).

## Terminology

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,18 +153,21 @@ export const Container: React.FC<ContainerProps> = memo(

const flyoutWidth = useMemo(() => {
if (showCollapsed) {
return flyoutWidths.collapsedWidth || defaultWidths.rightWidth;
return flyoutWidths.collapsedWidth || defaultWidths[type].rightWidth;
}
if (showExpanded) {
return flyoutWidths.expandedWidth || defaultWidths.rightWidth + defaultWidths.leftWidth;
return (
flyoutWidths.expandedWidth ||
defaultWidths[type].rightWidth + defaultWidths[type].leftWidth
);
}
}, [
showCollapsed,
showExpanded,
defaultWidths,
flyoutWidths.collapsedWidth,
flyoutWidths.expandedWidth,
defaultWidths.rightWidth,
defaultWidths.leftWidth,
showCollapsed,
showExpanded,
type,
]);

// callback function called when user changes the flyout's width
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,19 @@ import {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiSplitPanel,
EuiText,
useEuiTheme,
EuiSplitPanel,
} from '@elastic/eui';
import React, { memo, useMemo } from 'react';
import { css } from '@emotion/react';
import { has } from 'lodash';
import { selectDefaultWidths, selectUserSectionWidths, useSelector } from '../store/redux';
import {
selectDefaultWidths,
selectPushVsOverlay,
selectUserSectionWidths,
useSelector,
} from '../store/redux';
import {
PREVIEW_SECTION_BACK_BUTTON_TEST_ID,
PREVIEW_SECTION_CLOSE_BUTTON_TEST_ID,
Expand Down Expand Up @@ -85,14 +90,17 @@ export const PreviewSection: React.FC<PreviewSectionProps> = memo(

const { rightPercentage } = useSelector(selectUserSectionWidths);
const defaultPercentages = useSelector(selectDefaultWidths);
const type = useSelector(selectPushVsOverlay);

// Calculate the width of the preview section based on the following
// - if only the right section is visible, then we use 100% of the width (minus some padding)
// - if both the right and left sections are visible, we use the width of the right section (minus the same padding)
const width = useMemo(() => {
const percentage = rightPercentage ? rightPercentage : defaultPercentages.rightPercentage;
const percentage = rightPercentage
? rightPercentage
: defaultPercentages[type].rightPercentage;
return showExpanded ? `calc(${percentage}% - 8px)` : `calc(100% - 8px)`;
}, [defaultPercentages.rightPercentage, rightPercentage, showExpanded]);
}, [defaultPercentages, rightPercentage, showExpanded, type]);

const closeButton = (
<EuiFlexItem grow={false}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { css } from '@emotion/react';
import { changeUserSectionWidthsAction } from '../store/actions';
import {
selectDefaultWidths,
selectPushVsOverlay,
selectUserSectionWidths,
useDispatch,
useSelector,
Expand Down Expand Up @@ -52,15 +53,16 @@ export const ResizableContainer: React.FC<ResizableContainerProps> = memo(
const dispatch = useDispatch();

const { leftPercentage, rightPercentage } = useSelector(selectUserSectionWidths);
const type = useSelector(selectPushVsOverlay);
const defaultPercentages = useSelector(selectDefaultWidths);

const initialLeftPercentage = useMemo(
() => leftPercentage || defaultPercentages.leftPercentage,
[defaultPercentages.leftPercentage, leftPercentage]
() => leftPercentage || defaultPercentages[type].leftPercentage,
[defaultPercentages, leftPercentage, type]
);
const initialRightPercentage = useMemo(
() => rightPercentage || defaultPercentages.rightPercentage,
[defaultPercentages.rightPercentage, rightPercentage]
() => rightPercentage || defaultPercentages[type].rightPercentage,
[defaultPercentages, rightPercentage, type]
);

const onWidthChange = useCallback(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,7 @@
*/

import { renderHook } from '@testing-library/react';
import {
FULL_WIDTH_PADDING,
MAX_RESOLUTION_BREAKPOINT,
MIN_RESOLUTION_BREAKPOINT,
RIGHT_SECTION_MAX_WIDTH,
RIGHT_SECTION_MIN_WIDTH,
useWindowWidth,
} from './use_window_width';
import { useWindowWidth } from './use_window_width';
import { useDispatch } from '../store/redux';
import { setDefaultWidthsAction } from '../store/actions';

Expand Down Expand Up @@ -48,7 +41,7 @@ describe('useWindowWidth', () => {
expect(mockUseDispatch).not.toHaveBeenCalled();
});

it('should handle very small screens', () => {
it('should handle screens below 380px', () => {
global.innerWidth = 300;

const mockUseDispatch = jest.fn();
Expand All @@ -59,14 +52,17 @@ describe('useWindowWidth', () => {
expect(hookResult.result.current).toEqual(300);
expect(mockUseDispatch).toHaveBeenCalledWith(
setDefaultWidthsAction({
left: -48,
right: 300,
preview: 300,
leftOverlay: -48,
leftPush: 380,
previewOverlay: 300,
previewPush: 300,
rightOverlay: 300,
rightPush: 300,
})
);
});

it('should handle small screens', () => {
it('should handle screens between 380px and 992px', () => {
global.innerWidth = 500;

const mockUseDispatch = jest.fn();
Expand All @@ -77,58 +73,99 @@ describe('useWindowWidth', () => {
expect(hookResult.result.current).toEqual(500);
expect(mockUseDispatch).toHaveBeenCalledWith(
setDefaultWidthsAction({
left: 72,
right: 380,
preview: 380,
leftOverlay: 72,
leftPush: 380,
previewOverlay: 380,
previewPush: 380,
rightOverlay: 380,
rightPush: 380,
})
);
});

it('should handle medium screens', () => {
global.innerWidth = 1300;
it('should handle screens between 992px and 1600px', () => {
global.innerWidth = 1000;

const mockUseDispatch = jest.fn();
(useDispatch as jest.Mock).mockImplementation(() => mockUseDispatch);

const hookResult = renderHook(() => useWindowWidth());

const right =
RIGHT_SECTION_MIN_WIDTH +
(RIGHT_SECTION_MAX_WIDTH - RIGHT_SECTION_MIN_WIDTH) *
((1300 - MIN_RESOLUTION_BREAKPOINT) /
(MAX_RESOLUTION_BREAKPOINT - MIN_RESOLUTION_BREAKPOINT));
const left = 1300 - right - FULL_WIDTH_PADDING;
const preview = right;
const rightOverlay = 380 + (750 - 380) * ((1000 - 992) / (1920 - 992));
const leftOverlay = 1000 - rightOverlay - 48;
const previewOverlay = rightOverlay;
const rightPush = 380 + (600 - 380) * ((1000 - 1600) / (2560 - 1600));
const leftPush = 380;
const previewPush = rightPush;

expect(hookResult.result.current).toEqual(1300);
expect(hookResult.result.current).toEqual(1000);
expect(mockUseDispatch).toHaveBeenCalledWith(
setDefaultWidthsAction({
left,
right,
preview,
rightOverlay,
leftOverlay,
previewOverlay,
leftPush,
previewPush,
rightPush,
})
);
});

it('should handle large screens', () => {
global.innerWidth = 2500;
it('should handle screens between 1600px and 1920', () => {
global.innerWidth = 1800;

const mockUseDispatch = jest.fn();
(useDispatch as jest.Mock).mockImplementation(() => mockUseDispatch);

const hookResult = renderHook(() => useWindowWidth());

expect(hookResult.result.current).toEqual(2500);
const rightOverlay = 380 + (750 - 380) * ((1800 - 992) / (1920 - 992));
const leftOverlay = ((1800 - rightOverlay) * 80) / 100;
const previewOverlay = rightOverlay;
const rightPush = 380 + (600 - 380) * ((1800 - 1600) / (2560 - 1600));
const leftPush = ((1800 - rightPush - 200) * 40) / 100;
const previewPush = rightPush;

expect(hookResult.result.current).toEqual(1800);
expect(mockUseDispatch).toHaveBeenCalledWith(
setDefaultWidthsAction({
rightOverlay,
leftOverlay,
previewOverlay,
leftPush,
previewPush,
rightPush,
})
);
});

it('should handle screens between 1920px and 2560px', () => {
global.innerWidth = 2400;

const mockUseDispatch = jest.fn();
(useDispatch as jest.Mock).mockImplementation(() => mockUseDispatch);

const hookResult = renderHook(() => useWindowWidth());

const leftOverlay = ((2400 - 750) * 80) / 100;
const rightPush = 380 + (600 - 380) * ((2400 - 1600) / (2560 - 1600));
const leftPush = ((2400 - rightPush - 200) * 40) / 100;
const previewPush = rightPush;

expect(hookResult.result.current).toEqual(2400);
expect(mockUseDispatch).toHaveBeenCalledWith(
setDefaultWidthsAction({
left: 1400,
right: 750,
preview: 750,
rightOverlay: 750,
leftOverlay,
previewOverlay: 750,
leftPush,
previewPush,
rightPush,
})
);
});

it('should handle very large screens', () => {
it('should handle screens above 2560px', () => {
global.innerWidth = 3800;

const mockUseDispatch = jest.fn();
Expand All @@ -139,9 +176,12 @@ describe('useWindowWidth', () => {
expect(hookResult.result.current).toEqual(3800);
expect(mockUseDispatch).toHaveBeenCalledWith(
setDefaultWidthsAction({
left: 1500,
right: 750,
preview: 750,
leftOverlay: 1500,
leftPush: 1200,
previewOverlay: 750,
previewPush: 600,
rightOverlay: 750,
rightPush: 600,
})
);
});
Expand Down
Loading

0 comments on commit e7140ff

Please sign in to comment.