diff --git a/packages/kbn-expandable-flyout/README.md b/packages/kbn-expandable-flyout/README.md index ceac69e20722f..2baa93ee6058b 100644 --- a/packages/kbn-expandable-flyout/README.md +++ b/packages/kbn-expandable-flyout/README.md @@ -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: @@ -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). @@ -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 + + ... + + + +// state stored in memory - ... - ``` @@ -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 diff --git a/packages/kbn-expandable-flyout/index.ts b/packages/kbn-expandable-flyout/index.ts index d4299d676667f..e5eaae99c26f8 100644 --- a/packages/kbn-expandable-flyout/index.ts +++ b/packages/kbn-expandable-flyout/index.ts @@ -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'; diff --git a/packages/kbn-expandable-flyout/src/actions.ts b/packages/kbn-expandable-flyout/src/actions.ts index 56b6317032bc6..66ba9d900720b 100644 --- a/packages/kbn-expandable-flyout/src/actions.ts +++ b/packages/kbn-expandable-flyout/src/actions.ts @@ -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(ActionType.openRightPanel); -export const openLeftPanelAction = createAction(ActionType.openLeftPanel); -export const openPreviewPanelAction = createAction(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); diff --git a/packages/kbn-expandable-flyout/src/constants.ts b/packages/kbn-expandable-flyout/src/constants.ts index 4ee20ebb8e8f4..7e8236d0545f7 100644 --- a/packages/kbn-expandable-flyout/src/constants.ts +++ b/packages/kbn-expandable-flyout/src/constants.ts @@ -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'; diff --git a/packages/kbn-expandable-flyout/src/context.tsx b/packages/kbn-expandable-flyout/src/context.tsx new file mode 100644 index 0000000000000..b7c75463776d9 --- /dev/null +++ b/packages/kbn-expandable-flyout/src/context.tsx @@ -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( + 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( + ({ urlKey, children }) => { + const contextValue = useMemo( + () => ({ + urlKey, + }), + [urlKey] + ); + + return ( + + {children} + + ); + } +); + +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; +}; diff --git a/packages/kbn-expandable-flyout/src/hooks/use_expandable_flyout_api.ts b/packages/kbn-expandable-flyout/src/hooks/use_expandable_flyout_api.ts index 9f42870a31c0f..dcfcf429e1086 100644 --- a/packages/kbn-expandable-flyout/src/hooks/use_expandable_flyout_api.ts +++ b/packages/kbn-expandable-flyout/src/hooks/use_expandable_flyout_api.ts @@ -7,6 +7,8 @@ */ import { useCallback, useMemo } from 'react'; +import { REDUX_ID_FOR_MEMORY_STORAGE } from '../constants'; +import { useExpandableFlyoutContext } from '../context'; import { closeLeftPanelAction, closePanelsAction, @@ -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, @@ -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( () => ({ diff --git a/packages/kbn-expandable-flyout/src/hooks/use_expandable_flyout_state.ts b/packages/kbn-expandable-flyout/src/hooks/use_expandable_flyout_state.ts index 3ae9d69c4d0bc..015dfcd38f456 100644 --- a/packages/kbn-expandable-flyout/src/hooks/use_expandable_flyout_state.ts +++ b/packages/kbn-expandable-flyout/src/hooks/use_expandable_flyout_state.ts @@ -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)); }; diff --git a/packages/kbn-expandable-flyout/src/index.test.tsx b/packages/kbn-expandable-flyout/src/index.test.tsx index 0b8b62ce7187a..d08a78c706781 100644 --- a/packages/kbn-expandable-flyout/src/index.test.tsx +++ b/packages/kbn-expandable-flyout/src/index.test.tsx @@ -18,7 +18,9 @@ import { } from './components/test_ids'; import { type State } from './state'; import { TestProvider } from './test/provider'; +import { REDUX_ID_FOR_MEMORY_STORAGE } from './constants'; +const id = REDUX_ID_FOR_MEMORY_STORAGE; const registeredPanels: Panel[] = [ { key: 'key', @@ -28,14 +30,12 @@ const registeredPanels: Panel[] = [ describe('ExpandableFlyout', () => { it(`shouldn't render flyout if no panels`, () => { - const context = { - right: undefined, - left: undefined, - preview: [], - } as unknown as State; + const state: State = { + byId: {}, + }; const result = render( - + ); @@ -44,16 +44,20 @@ describe('ExpandableFlyout', () => { }); it('should render right section', () => { - const context = { - right: { - id: 'key', + const state = { + byId: { + [id]: { + right: { + id: 'key', + }, + left: undefined, + preview: undefined, + }, }, - left: {}, - preview: [], - } as unknown as State; + }; const { getByTestId } = render( - + ); @@ -62,16 +66,20 @@ describe('ExpandableFlyout', () => { }); it('should render left section', () => { - const context = { - right: {}, - left: { - id: 'key', + const state = { + byId: { + [id]: { + right: undefined, + left: { + id: 'key', + }, + preview: undefined, + }, }, - preview: [], - } as unknown as State; + }; const { getByTestId } = render( - + ); @@ -80,18 +88,22 @@ describe('ExpandableFlyout', () => { }); it('should render preview section', () => { - const context = { - right: {}, - left: {}, - preview: [ - { - id: 'key', + const state = { + byId: { + [id]: { + right: undefined, + left: undefined, + preview: [ + { + id: 'key', + }, + ], }, - ], - } as State; + }, + }; const { getByTestId } = render( - + ); diff --git a/packages/kbn-expandable-flyout/src/provider.test.tsx b/packages/kbn-expandable-flyout/src/provider.test.tsx new file mode 100644 index 0000000000000..c6246eff9fa32 --- /dev/null +++ b/packages/kbn-expandable-flyout/src/provider.test.tsx @@ -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 { TestProvider } from './test/provider'; +import { UrlSynchronizer } from './provider'; +import * as actions from './actions'; +import { State } from './state'; +import { of } from 'rxjs'; + +const mockGet = jest.fn(); +const mockSet = jest.fn(); +const mockChange$ = jest.fn().mockReturnValue(of({})); +jest.mock('@kbn/kibana-utils-plugin/public'); +const { createKbnUrlStateStorage } = jest.requireMock('@kbn/kibana-utils-plugin/public'); + +const urlKey = 'urlKey'; + +describe('UrlSynchronizer', () => { + it(`should not dispatch any actions or update url if urlKey isn't passed`, () => { + const urlChangedAction = jest.spyOn(actions, 'urlChangedAction'); + + const initialState: State = { + byId: { + [urlKey]: { + right: { id: 'key1' }, + left: { id: 'key11' }, + preview: undefined, + }, + }, + needsSync: true, + }; + + render( + + + + ); + + expect(urlChangedAction).not.toHaveBeenCalled(); + expect(mockSet).not.toHaveBeenCalled(); + }); + + it('should update url if no panels exist', () => { + (createKbnUrlStateStorage as jest.Mock).mockReturnValue({ + get: mockGet, + set: mockSet, + change$: mockChange$, + }); + const initialState: State = { + byId: {}, + needsSync: true, + }; + + render( + + + + ); + + expect(mockSet).toHaveBeenCalledWith('urlKey', { + left: undefined, + right: undefined, + preview: undefined, + }); + }); + + it('should dispatch action and update url with the correct value', () => { + const urlChangedAction = jest.spyOn(actions, 'urlChangedAction'); + + (createKbnUrlStateStorage as jest.Mock).mockReturnValue({ + get: mockGet, + set: mockSet, + change$: mockChange$, + }); + const initialState: State = { + byId: { + [urlKey]: { + right: { id: 'key1' }, + left: { id: 'key2' }, + preview: undefined, + }, + }, + needsSync: true, + }; + + render( + + + + ); + + expect(urlChangedAction).toHaveBeenCalledWith({ + id: urlKey, + preview: undefined, + }); + expect(mockSet).toHaveBeenCalled(); + }); +}); diff --git a/packages/kbn-expandable-flyout/src/provider.tsx b/packages/kbn-expandable-flyout/src/provider.tsx index ba18bd189f6e4..d88df39ab61eb 100644 --- a/packages/kbn-expandable-flyout/src/provider.tsx +++ b/packages/kbn-expandable-flyout/src/provider.tsx @@ -9,21 +9,20 @@ import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; import React, { FC, PropsWithChildren, useEffect, useMemo } from 'react'; import { Provider as ReduxProvider } from 'react-redux'; - import { useHistory } from 'react-router-dom'; -import { State } from './state'; +import { ExpandableFlyoutContextProvider, useExpandableFlyoutContext } from './context'; +import { FlyoutState } from './state'; import { useExpandableFlyoutState } from './hooks/use_expandable_flyout_state'; -import { EXPANDABLE_FLYOUT_URL_KEY } from './constants'; -import { Context, store, useDispatch } from './redux'; +import { Context, selectNeedsSync, store, useDispatch, useSelector } from './redux'; import { urlChangedAction } from './actions'; -export type ExpandableFlyoutStorageMode = 'memory' | 'url'; - /** * Dispatches actions when url state changes and initializes the state when the app is loaded with flyout url parameters */ -const UrlSynchronizer = () => { - const state = useExpandableFlyoutState(); +export const UrlSynchronizer = () => { + const { urlKey } = useExpandableFlyoutContext(); + const panels = useExpandableFlyoutState(); + const needsSync = useSelector(selectNeedsSync()); const dispatch = useDispatch(); const history = useHistory(); @@ -39,56 +38,64 @@ const UrlSynchronizer = () => { ); useEffect(() => { - const currentValue = urlStorage.get(EXPANDABLE_FLYOUT_URL_KEY); + if (!urlKey) { + return; + } + + const currentValue = urlStorage.get(urlKey); // Dispatch current value to redux store as it does not happen automatically if (currentValue) { - dispatch(urlChangedAction({ ...currentValue, preview: currentValue?.preview[0] })); + dispatch( + urlChangedAction({ + ...currentValue, + preview: currentValue?.preview?.[0], + id: urlKey, + }) + ); } - const subscription = urlStorage.change$(EXPANDABLE_FLYOUT_URL_KEY).subscribe((value) => { - dispatch(urlChangedAction({ ...value, preview: value?.preview?.[0] })); + const subscription = urlStorage.change$(urlKey).subscribe((value) => { + dispatch(urlChangedAction({ ...value, preview: value?.preview?.[0], id: urlKey })); }); return () => subscription.unsubscribe(); - }, [dispatch, urlStorage]); + }, [dispatch, urlKey, urlStorage]); useEffect(() => { - const { needsSync, ...stateToSync } = state; - - if (needsSync) { - urlStorage.set(EXPANDABLE_FLYOUT_URL_KEY, stateToSync); + if (!needsSync || !panels || !urlKey) { + return; } - }, [urlStorage, state]); + + const { left, right, preview } = panels; + urlStorage.set(urlKey, { left, right, preview }); + }, [needsSync, panels, urlKey, urlStorage]); return null; }; interface ExpandableFlyoutProviderProps { /** - * This allows the user to choose how the flyout storage is handled. - * Url storage syncs current values straight to the browser query string. + * Unique key to be used as url parameter to store the state of the flyout. + * Providing this will save the state of the flyout in the url. + * The word `memory` is reserved, do NOT use it! */ - storage?: ExpandableFlyoutStorageMode; + urlKey?: string; } /** * Wrap your plugin with this context for the ExpandableFlyout React component. - * Storage property allows you to specify how the flyout state works internally. - * With "url", it will be persisted into url and thus allow for deep linking & will survive webpage reloads. - * "memory" is based on an isolated redux context. The state is saved internally to the package, which means it will not be - * persisted when sharing url or reloading browser pages. */ export const ExpandableFlyoutProvider: FC> = ({ children, - storage = 'url', + urlKey, }) => { return ( - - <> - {storage === 'url' ? : null} + + + {urlKey ? : null} {children} - - + + ); }; diff --git a/packages/kbn-expandable-flyout/src/reducer.test.ts b/packages/kbn-expandable-flyout/src/reducer.test.ts index db18fbee3e2d7..df03d9277f946 100644 --- a/packages/kbn-expandable-flyout/src/reducer.test.ts +++ b/packages/kbn-expandable-flyout/src/reducer.test.ts @@ -21,6 +21,8 @@ import { previousPreviewPanelAction, } from './actions'; +const id1 = 'id1'; +const id2 = 'id2'; const rightPanel1: FlyoutPanelProps = { id: 'right1', path: { tab: 'tab' }, @@ -54,53 +56,109 @@ describe('reducer', () => { right: rightPanel1, left: leftPanel1, preview: previewPanel1, + id: id1, }); const newState: State = reducer(state, action); expect(newState).toEqual({ - left: leftPanel1, - right: rightPanel1, - preview: [previewPanel1], + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + }, needsSync: true, }); }); it('should override all panels in the state', () => { const state: State = { - left: leftPanel1, - right: rightPanel1, - preview: [previewPanel1, { id: 'preview' }], + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1, { id: 'preview' }], + }, + }, }; const action = openPanelsAction({ right: rightPanel2, left: leftPanel2, preview: previewPanel2, + id: id1, }); const newState: State = reducer(state, action); expect(newState).toEqual({ - left: leftPanel2, - right: rightPanel2, - preview: [previewPanel2], + byId: { + [id1]: { + left: leftPanel2, + right: rightPanel2, + preview: [previewPanel2], + }, + }, needsSync: true, }); }); it('should remove all panels despite only passing a single section ', () => { const state: State = { - left: leftPanel1, - right: rightPanel1, - preview: [previewPanel1], + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + }, }; const action = openPanelsAction({ right: rightPanel2, + id: id1, }); const newState: State = reducer(state, action); expect(newState).toEqual({ - left: undefined, + byId: { + [id1]: { + left: undefined, + right: rightPanel2, + preview: undefined, + }, + }, + needsSync: true, + }); + }); + + it('should add panels to a new key', () => { + const state: State = { + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + }, + }; + const action = openPanelsAction({ right: rightPanel2, - preview: [], + id: id2, + }); + const newState: State = reducer(state, action); + + expect(newState).toEqual({ + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + [id2]: { + left: undefined, + right: rightPanel2, + preview: undefined, + }, + }, needsSync: true, }); }); @@ -109,30 +167,72 @@ describe('reducer', () => { describe('should handle openRightPanel action', () => { it('should add right panel to empty state', () => { const state: State = initialState; - const action = openRightPanelAction(rightPanel1); + const action = openRightPanelAction({ right: rightPanel1, id: id1 }); const newState: State = reducer(state, action); expect(newState).toEqual({ - left: undefined, - right: rightPanel1, - preview: [], + byId: { + [id1]: { + left: undefined, + right: rightPanel1, + preview: undefined, + }, + }, needsSync: true, }); }); it('should replace right panel', () => { const state: State = { - left: leftPanel1, - right: rightPanel1, - preview: [previewPanel1], + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + }, }; - const action = openRightPanelAction(rightPanel2); + const action = openRightPanelAction({ right: rightPanel2, id: id1 }); const newState: State = reducer(state, action); expect(newState).toEqual({ - left: leftPanel1, - right: rightPanel2, - preview: [previewPanel1], + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel2, + preview: [previewPanel1], + }, + }, + needsSync: true, + }); + }); + + it('should add right panel to a different key', () => { + const state: State = { + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + }, + }; + const action = openRightPanelAction({ right: rightPanel2, id: id2 }); + const newState: State = reducer(state, action); + + expect(newState).toEqual({ + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + [id2]: { + left: undefined, + right: rightPanel2, + preview: undefined, + }, + }, needsSync: true, }); }); @@ -141,30 +241,72 @@ describe('reducer', () => { describe('should handle openLeftPanel action', () => { it('should add left panel to empty state', () => { const state: State = initialState; - const action = openLeftPanelAction(leftPanel1); + const action = openLeftPanelAction({ left: leftPanel1, id: id1 }); const newState: State = reducer(state, action); expect(newState).toEqual({ - left: leftPanel1, - right: undefined, - preview: [], + byId: { + [id1]: { + left: leftPanel1, + right: undefined, + preview: undefined, + }, + }, needsSync: true, }); }); it('should replace only left panel', () => { const state: State = { - left: leftPanel1, - right: rightPanel1, - preview: [previewPanel1], + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + }, }; - const action = openLeftPanelAction(leftPanel2); + const action = openLeftPanelAction({ left: leftPanel2, id: id1 }); const newState: State = reducer(state, action); expect(newState).toEqual({ - left: leftPanel2, - right: rightPanel1, - preview: [previewPanel1], + byId: { + [id1]: { + left: leftPanel2, + right: rightPanel1, + preview: [previewPanel1], + }, + }, + needsSync: true, + }); + }); + + it('should add left panel to a different key', () => { + const state: State = { + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + }, + }; + const action = openLeftPanelAction({ left: leftPanel2, id: id2 }); + const newState: State = reducer(state, action); + + expect(newState).toEqual({ + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + [id2]: { + left: leftPanel2, + right: undefined, + preview: undefined, + }, + }, needsSync: true, }); }); @@ -173,30 +315,72 @@ describe('reducer', () => { describe('should handle openPreviewPanel action', () => { it('should add preview panel to empty state', () => { const state: State = initialState; - const action = openPreviewPanelAction(previewPanel1); + const action = openPreviewPanelAction({ preview: previewPanel1, id: id1 }); const newState: State = reducer(state, action); expect(newState).toEqual({ - left: undefined, - right: undefined, - preview: [previewPanel1], + byId: { + [id1]: { + left: undefined, + right: undefined, + preview: [previewPanel1], + }, + }, needsSync: true, }); }); it('should add preview panel to the list of preview panels', () => { const state: State = { - left: leftPanel1, - right: rightPanel1, - preview: [previewPanel1], + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + }, }; - const action = openPreviewPanelAction(previewPanel2); + const action = openPreviewPanelAction({ preview: previewPanel2, id: id1 }); const newState: State = reducer(state, action); expect(newState).toEqual({ - left: leftPanel1, - right: rightPanel1, - preview: [previewPanel1, previewPanel2], + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1, previewPanel2], + }, + }, + needsSync: true, + }); + }); + + it('should add preview panel to a different key', () => { + const state: State = { + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + }, + }; + const action = openPreviewPanelAction({ preview: previewPanel2, id: id2 }); + const newState: State = reducer(state, action); + + expect(newState).toEqual({ + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + [id2]: { + left: undefined, + right: undefined, + preview: [previewPanel2], + }, + }, needsSync: true, }); }); @@ -205,7 +389,7 @@ describe('reducer', () => { describe('should handle closeRightPanel action', () => { it('should return empty state when removing right panel from empty state', () => { const state: State = initialState; - const action = closeRightPanelAction(); + const action = closeRightPanelAction({ id: id1 }); const newState: State = reducer(state, action); expect(newState).toEqual({ ...state, needsSync: true }); @@ -213,31 +397,68 @@ describe('reducer', () => { it(`should return unmodified state when removing right panel when no right panel exist`, () => { const state: State = { - left: leftPanel1, - right: undefined, - preview: [previewPanel1], - needsSync: true, + byId: { + [id1]: { + left: leftPanel1, + right: undefined, + preview: [previewPanel1], + }, + }, }; - const action = closeRightPanelAction(); + const action = closeRightPanelAction({ id: id1 }); const newState: State = reducer(state, action); - expect(newState).toEqual(state); + expect(newState).toEqual({ ...state, needsSync: true }); }); it('should remove right panel', () => { const state: State = { - left: leftPanel1, - right: rightPanel1, - preview: [previewPanel1], + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + }, }; - const action = closeRightPanelAction(); + const action = closeRightPanelAction({ id: id1 }); const newState: State = reducer(state, action); expect(newState).toEqual({ - left: leftPanel1, - right: undefined, - preview: [previewPanel1], + byId: { + [id1]: { + left: leftPanel1, + right: undefined, + preview: [previewPanel1], + }, + }, + needsSync: true, + }); + }); + + it('should not remove right panel for a different key', () => { + const state: State = { + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + }, + }; + const action = closeRightPanelAction({ id: id2 }); + + const newState: State = reducer(state, action); + + expect(newState).toEqual({ + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + }, needsSync: true, }); }); @@ -246,7 +467,7 @@ describe('reducer', () => { describe('should handle closeLeftPanel action', () => { it('should return empty state when removing left panel on empty state', () => { const state: State = initialState; - const action = closeLeftPanelAction(); + const action = closeLeftPanelAction({ id: id1 }); const newState: State = reducer(state, action); expect(newState).toEqual({ ...state, needsSync: true }); @@ -254,30 +475,66 @@ describe('reducer', () => { it(`should return unmodified state when removing left panel when no left panel exist`, () => { const state: State = { - left: undefined, - right: rightPanel1, - preview: [], - needsSync: true, + byId: { + [id1]: { + left: undefined, + right: rightPanel1, + preview: [previewPanel1], + }, + }, }; - const action = closeLeftPanelAction(); + const action = closeLeftPanelAction({ id: id1 }); const newState: State = reducer(state, action); - expect(newState).toEqual(state); + expect(newState).toEqual({ ...state, needsSync: true }); }); it('should remove left panel', () => { const state: State = { - left: leftPanel1, - right: rightPanel1, - preview: [previewPanel1], + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + }, }; - const action = closeLeftPanelAction(); + const action = closeLeftPanelAction({ id: id1 }); const newState: State = reducer(state, action); expect(newState).toEqual({ - left: undefined, - right: rightPanel1, - preview: [previewPanel1], + byId: { + [id1]: { + left: undefined, + right: rightPanel1, + preview: [previewPanel1], + }, + }, + needsSync: true, + }); + }); + + it('should not remove left panel for a different key', () => { + const state: State = { + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + }, + }; + const action = closeLeftPanelAction({ id: id2 }); + const newState: State = reducer(state, action); + + expect(newState).toEqual({ + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + }, needsSync: true, }); }); @@ -285,20 +542,24 @@ describe('reducer', () => { describe('should handle closePreviewPanel action', () => { it('should return empty state when removing preview panel on empty state', () => { - const state: State = { ...initialState, needsSync: true }; - const action = closePreviewPanelAction(); + const state: State = initialState; + const action = closePreviewPanelAction({ id: id1 }); const newState: State = reducer(state, action); - expect(newState).toEqual(state); + expect(newState).toEqual({ ...state, needsSync: true }); }); it(`should return unmodified state when removing preview panel when no preview panel exist`, () => { const state: State = { - left: leftPanel1, - right: rightPanel1, - preview: [], + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: undefined, + }, + }, }; - const action = closePreviewPanelAction(); + const action = closePreviewPanelAction({ id: id1 }); const newState: State = reducer(state, action); expect(newState).toEqual({ ...state, needsSync: true }); @@ -306,17 +567,50 @@ describe('reducer', () => { it('should remove all preview panels', () => { const state: State = { - left: rightPanel1, - right: leftPanel1, - preview: [previewPanel1, previewPanel2], + byId: { + [id1]: { + left: rightPanel1, + right: leftPanel1, + preview: [previewPanel1, previewPanel2], + }, + }, + }; + const action = closePreviewPanelAction({ id: id1 }); + const newState: State = reducer(state, action); + + expect(newState).toEqual({ + byId: { + [id1]: { + left: rightPanel1, + right: leftPanel1, + preview: undefined, + }, + }, + needsSync: true, + }); + }); + + it('should not remove preview panels for a different key', () => { + const state: State = { + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + }, }; - const action = closePreviewPanelAction(); + const action = closePreviewPanelAction({ id: id2 }); const newState: State = reducer(state, action); expect(newState).toEqual({ - left: rightPanel1, - right: leftPanel1, - preview: [], + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + }, needsSync: true, }); }); @@ -325,7 +619,7 @@ describe('reducer', () => { describe('should handle previousPreviewPanel action', () => { it('should return empty state when previous preview panel on an empty state', () => { const state: State = initialState; - const action = previousPreviewPanelAction(); + const action = previousPreviewPanelAction({ id: id1 }); const newState: State = reducer(state, action); expect(newState).toEqual({ ...initialState, needsSync: true }); @@ -333,30 +627,66 @@ describe('reducer', () => { it(`should return unmodified state when previous preview panel when no preview panel exist`, () => { const state: State = { - left: leftPanel1, - right: rightPanel1, - preview: [], - needsSync: true, + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: undefined, + }, + }, }; - const action = previousPreviewPanelAction(); + const action = previousPreviewPanelAction({ id: id1 }); const newState: State = reducer(state, action); - expect(newState).toEqual(state); + expect(newState).toEqual({ ...state, needsSync: true }); }); it('should remove only last preview panel', () => { const state: State = { - left: leftPanel1, - right: rightPanel1, - preview: [previewPanel1, previewPanel2], + byId: { + [id1]: { + left: rightPanel1, + right: leftPanel1, + preview: [previewPanel1, previewPanel2], + }, + }, }; - const action = previousPreviewPanelAction(); + const action = previousPreviewPanelAction({ id: id1 }); const newState: State = reducer(state, action); expect(newState).toEqual({ - left: leftPanel1, - right: rightPanel1, - preview: [previewPanel1], + byId: { + [id1]: { + left: rightPanel1, + right: leftPanel1, + preview: [previewPanel1], + }, + }, + needsSync: true, + }); + }); + + it('should not remove the last preview panel for a different key', () => { + const state: State = { + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + }, + }; + const action = previousPreviewPanelAction({ id: id2 }); + const newState: State = reducer(state, action); + + expect(newState).toEqual({ + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + }, needsSync: true, }); }); @@ -365,7 +695,7 @@ describe('reducer', () => { describe('should handle closeFlyout action', () => { it('should return empty state when closing flyout on an empty state', () => { const state: State = initialState; - const action = closePanelsAction(); + const action = closePanelsAction({ id: id1 }); const newState: State = reducer(state, action); expect(newState).toEqual({ ...initialState, needsSync: true }); @@ -373,17 +703,50 @@ describe('reducer', () => { it('should remove all panels', () => { const state: State = { - left: leftPanel1, - right: rightPanel1, - preview: [previewPanel1], + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + }, + }; + const action = closePanelsAction({ id: id1 }); + const newState: State = reducer(state, action); + + expect(newState).toEqual({ + byId: { + [id1]: { + left: undefined, + right: undefined, + preview: undefined, + }, + }, + needsSync: true, + }); + }); + + it('should not remove panels for a different key', () => { + const state: State = { + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + }, }; - const action = closePanelsAction(); + const action = closePanelsAction({ id: id2 }); const newState: State = reducer(state, action); expect(newState).toEqual({ - left: undefined, - right: undefined, - preview: [], + byId: { + [id1]: { + left: leftPanel1, + right: rightPanel1, + preview: [previewPanel1], + }, + }, needsSync: true, }); }); diff --git a/packages/kbn-expandable-flyout/src/reducer.ts b/packages/kbn-expandable-flyout/src/reducer.ts index 198f99d2785a6..617ad5e3a7c95 100644 --- a/packages/kbn-expandable-flyout/src/reducer.ts +++ b/packages/kbn-expandable-flyout/src/reducer.ts @@ -22,61 +22,123 @@ import { import { initialState } from './state'; export const reducer = createReducer(initialState, (builder) => { - builder.addCase(openPanelsAction, (state, { payload: { preview, left, right } }) => { - state.preview = preview ? [preview] : []; - state.right = right; - state.left = left; + builder.addCase(openPanelsAction, (state, { payload: { preview, left, right, id } }) => { + if (id in state.byId) { + state.byId[id].right = right; + state.byId[id].left = left; + state.byId[id].preview = preview ? [preview] : undefined; + } else { + state.byId[id] = { + left, + right, + preview: preview ? [preview] : undefined, + }; + } + state.needsSync = true; }); - builder.addCase(openLeftPanelAction, (state, { payload }) => { - state.left = payload; + builder.addCase(openLeftPanelAction, (state, { payload: { left, id } }) => { + if (id in state.byId) { + state.byId[id].left = left; + } else { + state.byId[id] = { + left, + right: undefined, + preview: undefined, + }; + } + state.needsSync = true; }); - builder.addCase(openRightPanelAction, (state, { payload }) => { - state.right = payload; + builder.addCase(openRightPanelAction, (state, { payload: { right, id } }) => { + if (id in state.byId) { + state.byId[id].right = right; + } else { + state.byId[id] = { + right, + left: undefined, + preview: undefined, + }; + } + state.needsSync = true; }); - builder.addCase(openPreviewPanelAction, (state, { payload }) => { - state.preview.push(payload); + builder.addCase(openPreviewPanelAction, (state, { payload: { preview, id } }) => { + if (id in state.byId) { + if (state.byId[id].preview) { + state.byId[id].preview?.push(preview); + } else { + state.byId[id].preview = preview ? [preview] : undefined; + } + } else { + state.byId[id] = { + right: undefined, + left: undefined, + preview: preview ? [preview] : undefined, + }; + } + state.needsSync = true; }); - builder.addCase(previousPreviewPanelAction, (state) => { - state.preview.pop(); + builder.addCase(previousPreviewPanelAction, (state, { payload: { id } }) => { + if (id in state.byId) { + state.byId[id].preview?.pop(); + } + state.needsSync = true; }); - builder.addCase(closePanelsAction, (state) => { - state.preview = []; - state.right = undefined; - state.left = undefined; + builder.addCase(closePanelsAction, (state, { payload: { id } }) => { + if (id in state.byId) { + state.byId[id].right = undefined; + state.byId[id].left = undefined; + state.byId[id].preview = undefined; + } + state.needsSync = true; }); - builder.addCase(closeLeftPanelAction, (state) => { - state.left = undefined; + builder.addCase(closeLeftPanelAction, (state, { payload: { id } }) => { + if (id in state.byId) { + state.byId[id].left = undefined; + } + state.needsSync = true; }); - builder.addCase(closeRightPanelAction, (state) => { - state.right = undefined; + builder.addCase(closeRightPanelAction, (state, { payload: { id } }) => { + if (id in state.byId) { + state.byId[id].right = undefined; + } + state.needsSync = true; }); - builder.addCase(closePreviewPanelAction, (state) => { - state.preview = []; + builder.addCase(closePreviewPanelAction, (state, { payload: { id } }) => { + if (id in state.byId) { + state.byId[id].preview = undefined; + } + state.needsSync = true; }); - builder.addCase(urlChangedAction, (state, { payload: { preview, left, right } }) => { - state.needsSync = false; - state.preview = preview ? [preview] : []; - state.left = left; - state.right = right; + builder.addCase(urlChangedAction, (state, { payload: { preview, left, right, id } }) => { + if (id in state.byId) { + state.byId[id].right = right; + state.byId[id].left = left; + state.byId[id].preview = preview ? [preview] : undefined; + } else { + state.byId[id] = { + right, + left, + preview: preview ? [preview] : undefined, + }; + } - return state; + state.needsSync = false; }); }); diff --git a/packages/kbn-expandable-flyout/src/redux.ts b/packages/kbn-expandable-flyout/src/redux.ts index d1f9d63c8a1a7..879e812ebcbea 100644 --- a/packages/kbn-expandable-flyout/src/redux.ts +++ b/packages/kbn-expandable-flyout/src/redux.ts @@ -9,6 +9,7 @@ import { createContext } from 'react'; import { createDispatchHook, createSelectorHook, ReactReduxContextValue } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; +import { createSelector } from 'reselect'; import { reducer } from './reducer'; import { initialState, State } from './state'; @@ -26,4 +27,9 @@ export const Context = createContext>({ export const useDispatch = createDispatchHook(Context); export const useSelector = createSelectorHook(Context); -export const stateSelector = (state: State) => state; +const stateSelector = (state: State) => state; + +export const selectPanelsById = (id: string) => + createSelector(stateSelector, (state) => state.byId[id] || {}); + +export const selectNeedsSync = () => createSelector(stateSelector, (state) => state.needsSync); diff --git a/packages/kbn-expandable-flyout/src/state.ts b/packages/kbn-expandable-flyout/src/state.ts index 2724194759b2b..fa2be0dd103cb 100644 --- a/packages/kbn-expandable-flyout/src/state.ts +++ b/packages/kbn-expandable-flyout/src/state.ts @@ -8,7 +8,7 @@ import { FlyoutPanelProps } from './types'; -export interface State { +export interface FlyoutState { /** * Panel to render in the left section */ @@ -20,8 +20,16 @@ export interface State { /** * Panels to render in the preview section */ - preview: FlyoutPanelProps[]; + preview: FlyoutPanelProps[] | undefined; +} +export interface State { + /** + * Store the panels for multiple flyouts + */ + byId: { + [id: string]: FlyoutState; + }; /** * Is the flyout in sync with external storage (eg. url)? * This value can be used in useEffect for example, to control whether we should @@ -31,8 +39,6 @@ export interface State { } export const initialState: State = { - left: undefined, - right: undefined, - preview: [], + byId: {}, needsSync: false, }; diff --git a/packages/kbn-expandable-flyout/src/test/provider.tsx b/packages/kbn-expandable-flyout/src/test/provider.tsx index 448b36e0b3d30..896b563056206 100644 --- a/packages/kbn-expandable-flyout/src/test/provider.tsx +++ b/packages/kbn-expandable-flyout/src/test/provider.tsx @@ -9,17 +9,20 @@ import { Provider as ReduxProvider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; import React, { FC, PropsWithChildren } from 'react'; +import { ExpandableFlyoutContextProvider } from '../context'; import { reducer } from '../reducer'; import { Context } from '../redux'; import { initialState, State } from '../state'; interface TestProviderProps { state?: State; + urlKey?: string; } export const TestProvider: FC> = ({ children, state = initialState, + urlKey, }) => { const store = configureStore({ reducer, @@ -29,8 +32,10 @@ export const TestProvider: FC> = ({ }); return ( - - {children} - + + + {children} + + ); }; diff --git a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx index 17d482ff4c21d..5213a24172357 100644 --- a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx @@ -12,6 +12,7 @@ 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 { ExpandableFlyoutProvider } from '@kbn/expandable-flyout'; +import { EXPANDABLE_FLYOUT_URL_KEY } from '../../../common/hooks/use_url_state'; import { SecuritySolutionFlyout } from '../../../flyout'; import { useSecuritySolutionNavigation } from '../../../common/components/navigation/use_security_solution_navigation'; import { TimelineId } from '../../../../common/types/timeline'; @@ -67,7 +68,7 @@ export const SecuritySolutionTemplateWrapper: React.FC + { useSyncGlobalQueryString(); useInitSearchBarFromUrlParams();