Skip to content

Commit

Permalink
[Security Solution] [Flyout] drive flyout state with url or memory + …
Browse files Browse the repository at this point in the history
…support back button navigation from timelines (elastic#169661)

## Summary

This is a PoC for flyout state (left, right, preview panels) stored
entirely in the url without separate syncing mechanism. It is also
possible to opt in for in-memory storage.

### This vs current solution:
- **browser navigation is supported**
- we dont need to sync anything with in-memory state
- we can remove useImperativeHandle from expandable flyout package
- flyout state can be updated on the individual widget level, without
prop drilling
- when clicking between alerts, current flyout arrangement is retained -
so the tabs you have open etc are still there (no custom code required)
- **it is now possible to investigate something in timeline using the
flyout action & go back to the flyout view**

elastic/security-team#8135

---------

Co-authored-by: kibanamachine <[email protected]>
  • Loading branch information
lgestc and kibanamachine authored Nov 30, 2023
1 parent 545e472 commit a2a6cd2
Show file tree
Hide file tree
Showing 59 changed files with 722 additions and 718 deletions.
6 changes: 6 additions & 0 deletions packages/kbn-expandable-flyout/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ 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

Expand Down
11 changes: 5 additions & 6 deletions packages/kbn-expandable-flyout/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@
*/

export { ExpandableFlyout } from './src';
export {
ExpandableFlyoutProvider,
useExpandableFlyoutContext,
type ExpandableFlyoutContext,
} from './src/context';

export type { ExpandableFlyoutApi } from './src/context';
export { useExpandableFlyoutContext, type ExpandableFlyoutContext } from './src/context';

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

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

export { EXPANDABLE_FLYOUT_URL_KEY } from './src/constants';
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ import {
PREVIEW_SECTION_CLOSE_BUTTON_TEST_ID,
PREVIEW_SECTION_TEST_ID,
} from './test_ids';
import { ExpandableFlyoutContext } from '../context';
import { ExpandableFlyoutContext, ExpandableFlyoutContextValue } from '../context';

describe('PreviewSection', () => {
const context: ExpandableFlyoutContext = {
const context = {
panels: {
right: {},
left: {},
Expand All @@ -27,7 +27,7 @@ describe('PreviewSection', () => {
},
],
},
} as unknown as ExpandableFlyoutContext;
} as unknown as ExpandableFlyoutContextValue;

const component = <div>{'component'}</div>;
const left = 500;
Expand Down
9 changes: 9 additions & 0 deletions packages/kbn-expandable-flyout/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*
* 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 EXPANDABLE_FLYOUT_URL_KEY = 'eventFlyout' as const;
193 changes: 5 additions & 188 deletions packages/kbn-expandable-flyout/src/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,202 +6,19 @@
* Side Public License, v 1.
*/

import React, {
createContext,
useCallback,
useContext,
useEffect,
useImperativeHandle,
useMemo,
useReducer,
} from 'react';
import { ActionType } from './actions';
import { reducer, State } from './reducer';
import type { FlyoutPanelProps } from './types';
import { initialState } from './reducer';
import { createContext, useContext } from 'react';
import { type ExpandableFlyoutContextValue } from './types';

export interface ExpandableFlyoutContext {
/**
* Right, left and preview panels
*/
panels: State;
/**
* Open the flyout with left, right and/or preview panels
*/
openFlyout: (panels: {
left?: FlyoutPanelProps;
right?: FlyoutPanelProps;
preview?: FlyoutPanelProps;
}) => void;
/**
* Replaces the current right panel with a new one
*/
openRightPanel: (panel: FlyoutPanelProps) => void;
/**
* Replaces the current left panel with a new one
*/
openLeftPanel: (panel: FlyoutPanelProps) => void;
/**
* Add a new preview panel to the list of current preview panels
*/
openPreviewPanel: (panel: FlyoutPanelProps) => 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 type { ExpandableFlyoutContextValue };

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

export type ExpandableFlyoutApi = Pick<ExpandableFlyoutContext, 'openFlyout'> & {
getState: () => State;
};

export interface ExpandableFlyoutProviderProps {
/**
* React children
*/
children: React.ReactNode;
/**
* Triggered whenever flyout state changes. You can use it to store it's state somewhere for instance.
*/
onChanges?: (state: State) => void;
/**
* Triggered whenever flyout is closed. This is independent from the onChanges above.
*/
onClosePanels?: () => void;
}

/**
* Wrap your plugin with this context for the ExpandableFlyout React component.
*/
export const ExpandableFlyoutProvider = React.forwardRef<
ExpandableFlyoutApi,
ExpandableFlyoutProviderProps
>(({ children, onChanges = () => {}, onClosePanels = () => {} }, ref) => {
const [state, dispatch] = useReducer(reducer, initialState);

useEffect(() => {
const closed = !state.right;
if (closed) {
// manual close is singalled via separate callback
return;
}

onChanges(state);
}, [state, onChanges]);

const openPanels = useCallback(
({
right,
left,
preview,
}: {
right?: FlyoutPanelProps;
left?: FlyoutPanelProps;
preview?: FlyoutPanelProps;
}) => dispatch({ type: ActionType.openFlyout, payload: { left, right, preview } }),
[dispatch]
);

const openRightPanel = useCallback(
(panel: FlyoutPanelProps) => dispatch({ type: ActionType.openRightPanel, payload: panel }),
[]
);

const openLeftPanel = useCallback(
(panel: FlyoutPanelProps) => dispatch({ type: ActionType.openLeftPanel, payload: panel }),
[]
);

const openPreviewPanel = useCallback(
(panel: FlyoutPanelProps) => dispatch({ type: ActionType.openPreviewPanel, payload: panel }),
[]
);

const closeRightPanel = useCallback(() => dispatch({ type: ActionType.closeRightPanel }), []);

const closeLeftPanel = useCallback(() => dispatch({ type: ActionType.closeLeftPanel }), []);

const closePreviewPanel = useCallback(() => dispatch({ type: ActionType.closePreviewPanel }), []);

const previousPreviewPanel = useCallback(
() => dispatch({ type: ActionType.previousPreviewPanel }),
[]
);

const closePanels = useCallback(() => {
dispatch({ type: ActionType.closeFlyout });
onClosePanels();
}, [onClosePanels]);

useImperativeHandle(
ref,
() => {
return {
openFlyout: openPanels,
getState: () => state,
};
},
[openPanels, state]
);

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 => {
export const useExpandableFlyoutContext = (): ExpandableFlyoutContextValue => {
const contextValue = useContext(ExpandableFlyoutContext);

if (!contextValue) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* 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, { FC, PropsWithChildren, useCallback, useMemo, useReducer } from 'react';
import { ActionType } from '../actions';
import { reducer } from '../reducer';
import type { ExpandableFlyoutContextValue, FlyoutPanelProps } from '../types';
import { initialState } from '../reducer';
import { ExpandableFlyoutContext } from '../context';

/**
* In-memory state provider for the expandable flyout, for cases when we don't want changes to be persisted
* in the url.
*/
export const MemoryStateProvider: FC<PropsWithChildren<{}>> = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState);

const openPanels = useCallback(
({
right,
left,
preview,
}: {
right?: FlyoutPanelProps;
left?: FlyoutPanelProps;
preview?: FlyoutPanelProps;
}) => dispatch({ type: ActionType.openFlyout, payload: { left, right, preview } }),
[dispatch]
);

const openRightPanel = useCallback(
(panel: FlyoutPanelProps) => dispatch({ type: ActionType.openRightPanel, payload: panel }),
[]
);

const openLeftPanel = useCallback(
(panel: FlyoutPanelProps) => dispatch({ type: ActionType.openLeftPanel, payload: panel }),
[]
);

const openPreviewPanel = useCallback(
(panel: FlyoutPanelProps) => dispatch({ type: ActionType.openPreviewPanel, payload: panel }),
[]
);

const closeRightPanel = useCallback(() => dispatch({ type: ActionType.closeRightPanel }), []);

const closeLeftPanel = useCallback(() => dispatch({ type: ActionType.closeLeftPanel }), []);

const closePreviewPanel = useCallback(() => dispatch({ type: ActionType.closePreviewPanel }), []);

const previousPreviewPanel = useCallback(
() => dispatch({ type: ActionType.previousPreviewPanel }),
[]
);

const closePanels = useCallback(() => {
dispatch({ type: ActionType.closeFlyout });
}, []);

const contextValue: ExpandableFlyoutContextValue = 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>
);
};
Loading

0 comments on commit a2a6cd2

Please sign in to comment.