[Security Solution][Flyout] Use kbn url storage provider, housekeeping (#175882)

## Summary

**This does not change any of the api's defined previously.** 

Changes:
- replace custom hook for url serialization with off the shelf solution
from public plugins
- cleanup the codebase futher
- remove internal context based hook

### Checklist

Delete any items that are not applicable to this PR.

- [x] [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

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Luke G 2024-02-01 20:58:14 +01:00 committed by GitHub
parent 585630d060
commit 1fe7833a23
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 265 additions and 418 deletions

View file

@ -27,7 +27,11 @@ The expandable-flyout is making some strict UI design decisions:
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).
The ExpandableFlyout [hooks](https://github.com/elastic/kibana/blob/main/packages/kbn-expandable-flyout/src/context.tsx) expose the state and the following api:
To retrieve the flyout's layout (left, right and preview panels), you can utilize [useExpandableFlyoutState](https://github.com/elastic/kibana/blob/main/packages/kbn-expandable-flyout/src/hooks/use_expandable_flyout_state.ts).
To control (or mutate) flyout's layout, you can utilize [useExpandableFlyoutApi](https://github.com/elastic/kibana/blob/main/packages/kbn-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
@ -38,10 +42,6 @@ The ExpandableFlyout [hooks](https://github.com/elastic/kibana/blob/main/package
- **previousPreviewPanel**: navigate to the previous preview panel
- **closeFlyout**: close the flyout
To retrieve the flyout's layout (left, right and preview panels), you can use the **useExpandableFlyoutState** from the same [React context](https://github.com/elastic/kibana/blob/main/packages/kbn-expandable-flyout/src/context.tsx).
To control (or mutate) flyout's layout, you can use the **useExpandableFlyoutApi** from the same [React context](https://github.com/elastic/kibana/blob/main/packages/kbn-expandable-flyout/src/context.tsx).
## 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/packages/kbn-expandable-flyout/src/context.tsx) at a high enough level as follows:

View file

@ -8,11 +8,8 @@
export { ExpandableFlyout } from './src';
export {
type ExpandableFlyoutContext,
useExpandableFlyoutState,
useExpandableFlyoutApi,
} from './src/context';
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';

View file

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

View file

@ -19,6 +19,7 @@ export enum ActionType {
closePreviewPanel = 'close_preview_panel',
previousPreviewPanel = 'previous_preview_panel',
closeFlyout = 'close_flyout',
urlChanged = 'urlChanged',
}
export const openPanelsAction = createAction<{
@ -37,3 +38,9 @@ export const closeLeftPanelAction = createAction(ActionType.closeLeftPanel);
export const closePreviewPanelAction = createAction(ActionType.closePreviewPanel);
export const previousPreviewPanelAction = createAction(ActionType.previousPreviewPanel);
export const urlChangedAction = createAction<{
right?: FlyoutPanelProps;
left?: FlyoutPanelProps;
preview?: FlyoutPanelProps;
}>(ActionType.urlChanged);

View file

@ -14,21 +14,19 @@ import {
PREVIEW_SECTION_CLOSE_BUTTON_TEST_ID,
PREVIEW_SECTION_TEST_ID,
} from './test_ids';
import { ExpandableFlyoutContextValue } from '../context';
import { TestProvider } from '../test/provider';
import { State } from '../state';
describe('PreviewSection', () => {
const context = {
panels: {
right: {},
left: {},
preview: [
{
id: 'key',
},
],
},
} as unknown as ExpandableFlyoutContextValue;
right: {},
left: {},
preview: [
{
id: 'key',
},
],
} as unknown as State;
const component = <div>{'component'}</div>;
const left = 500;
@ -37,7 +35,7 @@ describe('PreviewSection', () => {
const showBackButton = false;
const { getByTestId } = render(
<TestProvider state={context.panels}>
<TestProvider state={context}>
<PreviewSection component={component} leftPosition={left} showBackButton={showBackButton} />
</TestProvider>
);
@ -49,7 +47,7 @@ describe('PreviewSection', () => {
const showBackButton = true;
const { getByTestId } = render(
<TestProvider state={context.panels}>
<TestProvider state={context}>
<PreviewSection component={component} leftPosition={left} showBackButton={showBackButton} />
</TestProvider>
);
@ -67,7 +65,7 @@ describe('PreviewSection', () => {
};
const { getByTestId, getByText } = render(
<TestProvider state={context.panels}>
<TestProvider state={context}>
<PreviewSection
component={component}
leftPosition={left}

View file

@ -1,57 +0,0 @@
/*
* 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 { createContext, useContext } from 'react';
import { useFlyoutMemoryState } from './context/memory_state_provider';
import { useFlyoutUrlState } from './context/url_state_provider';
import { type ExpandableFlyoutApi } from './types';
export type { ExpandableFlyoutApi as ExpandableFlyoutContextValue };
type ExpandableFlyoutContextValue = 'memory' | 'url';
export const ExpandableFlyoutContext = createContext<ExpandableFlyoutContextValue | undefined>(
undefined
);
/**
* Retrieve Flyout's api and state
* @deprecated
*/
export const useExpandableFlyoutContext = (): ExpandableFlyoutApi => {
const contextValue = useContext(ExpandableFlyoutContext);
if (!contextValue) {
throw new Error(
'ExpandableFlyoutContext can only be used within ExpandableFlyoutContext provider'
);
}
const memoryState = useFlyoutMemoryState();
const urlState = useFlyoutUrlState();
return contextValue === 'memory' ? memoryState : urlState;
};
/**
* This hook allows you to interact with the flyout, open panels and previews etc.
*/
export const useExpandableFlyoutApi = () => {
const { panels, ...api } = useExpandableFlyoutContext();
return api;
};
/**
* This hook allows you to access the flyout state, read open panels and previews.
*/
export const useExpandableFlyoutState = () => {
const expandableFlyoutApiAndState = useExpandableFlyoutContext();
return expandableFlyoutApiAndState.panels;
};

View file

@ -1,109 +0,0 @@
/*
* 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 { useCallback, useMemo } from 'react';
import { FlyoutPanelProps, ExpandableFlyoutApi } from '../types';
import { useRightPanel } from '../hooks/use_right_panel';
import { useLeftPanel } from '../hooks/use_left_panel';
import { usePreviewPanel } from '../hooks/use_preview_panel';
import { State } from '../state';
export const useFlyoutUrlState = (): ExpandableFlyoutApi => {
const { setRightPanelState, rightPanelState } = useRightPanel();
const { setLeftPanelState, leftPanelState } = useLeftPanel();
const { previewState, setPreviewState } = usePreviewPanel();
const panels: State = useMemo(
() => ({
left: leftPanelState,
right: rightPanelState,
preview: previewState || [],
}),
[leftPanelState, previewState, rightPanelState]
);
const openPanels = useCallback(
({
right,
left,
preview,
}: {
right?: FlyoutPanelProps;
left?: FlyoutPanelProps;
preview?: FlyoutPanelProps;
}) => {
setRightPanelState(right);
setLeftPanelState(left);
setPreviewState(preview ? [preview] : []);
},
[setRightPanelState, setLeftPanelState, setPreviewState]
);
const openRightPanel = useCallback(
(panel: FlyoutPanelProps) => {
setRightPanelState(panel);
},
[setRightPanelState]
);
const openLeftPanel = useCallback(
(panel: FlyoutPanelProps) => setLeftPanelState(panel),
[setLeftPanelState]
);
const openPreviewPanel = useCallback(
(panel: FlyoutPanelProps) => setPreviewState([...(previewState ?? []), panel]),
[previewState, setPreviewState]
);
const closeRightPanel = useCallback(() => setRightPanelState(undefined), [setRightPanelState]);
const closeLeftPanel = useCallback(() => setLeftPanelState(undefined), [setLeftPanelState]);
const closePreviewPanel = useCallback(() => setPreviewState([]), [setPreviewState]);
const previousPreviewPanel = useCallback(
() => setPreviewState(previewState?.slice(0, previewState.length - 1)),
[previewState, setPreviewState]
);
const closePanels = useCallback(() => {
setRightPanelState(undefined);
setLeftPanelState(undefined);
setPreviewState([]);
}, [setRightPanelState, setLeftPanelState, setPreviewState]);
const contextValue: ExpandableFlyoutApi = useMemo(
() => ({
panels,
openFlyout: openPanels,
openRightPanel,
openLeftPanel,
openPreviewPanel,
closeRightPanel,
closeLeftPanel,
closePreviewPanel,
closeFlyout: closePanels,
previousPreviewPanel,
}),
[
panels,
openPanels,
openRightPanel,
openLeftPanel,
openPreviewPanel,
closeRightPanel,
closeLeftPanel,
closePreviewPanel,
closePanels,
previousPreviewPanel,
]
);
return contextValue;
};

View file

@ -6,18 +6,7 @@
* Side Public License, v 1.
*/
import React, { createContext, FC, useCallback, useMemo } from 'react';
import {
createDispatchHook,
createSelectorHook,
Provider as ReduxProvider,
ReactReduxContextValue,
} from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { reducer } from '../reducer';
import { initialState, State } from '../state';
import type { ExpandableFlyoutApi, FlyoutPanelProps } from '../types';
import { useCallback, useMemo } from 'react';
import {
closeLeftPanelAction,
closePanelsAction,
@ -29,24 +18,15 @@ import {
openRightPanelAction,
previousPreviewPanelAction,
} from '../actions';
import { useDispatch } from '../redux';
import { FlyoutPanelProps, type ExpandableFlyoutApi } from '../types';
export const store = configureStore({
reducer,
devTools: process.env.NODE_ENV !== 'production',
preloadedState: {},
enhancers: [],
});
export type { ExpandableFlyoutApi };
export const Context = createContext<ReactReduxContextValue<State>>({
store,
storeState: initialState,
});
const useDispatch = createDispatchHook(Context);
const useSelector = createSelectorHook(Context);
export const useFlyoutMemoryState = (): ExpandableFlyoutApi => {
const state = useSelector((s) => s);
/**
* This hook allows you to interact with the flyout, open panels and previews etc.
*/
export const useExpandableFlyoutApi = () => {
const dispatch = useDispatch();
const openPanels = useCallback(
@ -94,7 +74,6 @@ export const useFlyoutMemoryState = (): ExpandableFlyoutApi => {
const api: ExpandableFlyoutApi = useMemo(
() => ({
panels: state,
openFlyout: openPanels,
openRightPanel,
openLeftPanel,
@ -106,7 +85,6 @@ export const useFlyoutMemoryState = (): ExpandableFlyoutApi => {
previousPreviewPanel,
}),
[
state,
openPanels,
openRightPanel,
openLeftPanel,
@ -121,15 +99,3 @@ export const useFlyoutMemoryState = (): ExpandableFlyoutApi => {
return api;
};
/**
* 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 = ({ children }) => {
return (
<ReduxProvider context={Context} store={store}>
{children}
</ReduxProvider>
);
};

View file

@ -0,0 +1,16 @@
/*
* 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 { stateSelector, useSelector } from '../redux';
/**
* This hook allows you to access the flyout state, read open panels and previews.
*/
export const useExpandableFlyoutState = () => {
return useSelector(stateSelector);
};

View file

@ -1,23 +0,0 @@
/*
* 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 { useUrlState } from '@kbn/url-state';
import { EXPANDABLE_FLYOUT_URL_KEY } from '../constants';
import { FlyoutPanelProps } from '../types';
/**
* This hook stores state in the URL
*/
export const useLeftPanel = () => {
const [leftPanelState, setLeftPanelState] = useUrlState<FlyoutPanelProps>(
EXPANDABLE_FLYOUT_URL_KEY,
'leftPanel'
);
return { leftPanelState, setLeftPanelState } as const;
};

View file

@ -1,23 +0,0 @@
/*
* 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 { useUrlState } from '@kbn/url-state';
import { EXPANDABLE_FLYOUT_URL_KEY } from '../constants';
import { FlyoutPanelProps } from '../types';
/**
* This hook stores state in the URL
*/
export const usePreviewPanel = () => {
const [previewState, setPreviewState] = useUrlState<FlyoutPanelProps[]>(
EXPANDABLE_FLYOUT_URL_KEY,
'preview'
);
return { previewState, setPreviewState } as const;
};

View file

@ -1,23 +0,0 @@
/*
* 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 { useUrlState } from '@kbn/url-state';
import { EXPANDABLE_FLYOUT_URL_KEY } from '../constants';
import { FlyoutPanelProps } from '../types';
/**
* This hook stores state in the URL
*/
export const useRightPanel = () => {
const [rightPanelState, setRightPanelState] = useUrlState<FlyoutPanelProps>(
EXPANDABLE_FLYOUT_URL_KEY,
'rightPanel'
);
return { rightPanelState, setRightPanelState } as const;
};

View file

@ -19,8 +19,8 @@ import {
EuiTitle,
} from '@elastic/eui';
import { ExpandableFlyout } from '.';
import { ExpandableFlyoutContextValue } from './context';
import { TestProvider } from './test/provider';
import { State } from './state';
export default {
component: ExpandableFlyout,
@ -101,89 +101,81 @@ const registeredPanels = [
];
export const Right: Story<void> = () => {
const context = {
panels: {
right: {
id: 'right',
},
left: {},
preview: [],
const state = {
right: {
id: 'right',
},
} as unknown as ExpandableFlyoutContextValue;
left: {},
preview: [],
} as unknown as State;
return (
<TestProvider state={context.panels}>
<TestProvider state={state}>
<ExpandableFlyout registeredPanels={registeredPanels} />
</TestProvider>
);
};
export const Left: Story<void> = () => {
const context = {
panels: {
right: {
id: 'right',
},
left: {
id: 'left',
},
preview: [],
const state = {
right: {
id: 'right',
},
} as unknown as ExpandableFlyoutContextValue;
left: {
id: 'left',
},
preview: [],
} as unknown as State;
return (
<TestProvider state={context.panels}>
<TestProvider state={state}>
<ExpandableFlyout registeredPanels={registeredPanels} />
</TestProvider>
);
};
export const Preview: Story<void> = () => {
const context = {
panels: {
right: {
id: 'right',
},
left: {
id: 'left',
},
preview: [
{
id: 'preview1',
},
],
const state = {
right: {
id: 'right',
},
} as unknown as ExpandableFlyoutContextValue;
left: {
id: 'left',
},
preview: [
{
id: 'preview1',
},
],
} as unknown as State;
return (
<TestProvider state={context.panels}>
<TestProvider state={state}>
<ExpandableFlyout registeredPanels={registeredPanels} />
</TestProvider>
);
};
export const MultiplePreviews: Story<void> = () => {
const context = {
panels: {
right: {
id: 'right',
},
left: {
id: 'left',
},
preview: [
{
id: 'preview1',
},
{
id: 'preview2',
},
],
const state = {
right: {
id: 'right',
},
} as unknown as ExpandableFlyoutContextValue;
left: {
id: 'left',
},
preview: [
{
id: 'preview1',
},
{
id: 'preview2',
},
],
} as unknown as State;
return (
<TestProvider state={context.panels}>
<TestProvider state={state}>
<ExpandableFlyout registeredPanels={registeredPanels} />
</TestProvider>
);

View file

@ -18,7 +18,6 @@ import {
} from './components/test_ids';
import { type State } from './state';
import { TestProvider } from './test/provider';
jest.mock('./context/url_state_provider');
const registeredPanels: Panel[] = [
{

View file

@ -11,7 +11,8 @@ import { EuiFlyoutProps } from '@elastic/eui';
import { EuiFlexGroup, EuiFlyout } from '@elastic/eui';
import { useSectionSizes } from './hooks/use_sections_sizes';
import { useWindowSize } from './hooks/use_window_size';
import { useExpandableFlyoutContext } from './context';
import { useExpandableFlyoutState } from './hooks/use_expandable_flyout_state';
import { useExpandableFlyoutApi } from './hooks/use_expandable_flyout_api';
import { PreviewSection } from './components/preview_section';
import { RightSection } from './components/right_section';
import type { FlyoutPanelProps, Panel } from './types';
@ -40,9 +41,8 @@ export const ExpandableFlyout: React.FC<ExpandableFlyoutProps> = ({
}) => {
const windowWidth = useWindowSize();
const { closeFlyout, panels } = useExpandableFlyoutContext();
const { left, right, preview } = panels;
const { left, right, preview } = useExpandableFlyoutState();
const { closeFlyout } = useExpandableFlyoutApi();
const leftSection = useMemo(
() => registeredPanels.find((panel) => panel.key === left?.id),

View file

@ -6,16 +6,70 @@
* Side Public License, v 1.
*/
import React, { FC, PropsWithChildren } from 'react';
import { ExpandableFlyoutContext } from './context';
import { MemoryStateProvider } from './context/memory_state_provider';
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 { useExpandableFlyoutState } from './hooks/use_expandable_flyout_state';
import { EXPANDABLE_FLYOUT_URL_KEY } from './constants';
import { Context, store, useDispatch } 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();
const dispatch = useDispatch();
const history = useHistory();
const urlStorage = useMemo(
() =>
createKbnUrlStateStorage({
history,
useHash: false,
useHashQuery: false,
}),
[history]
);
useEffect(() => {
const currentValue = urlStorage.get<State>(EXPANDABLE_FLYOUT_URL_KEY);
// Dispatch current value to redux store as it does not happen automatically
if (currentValue) {
dispatch(urlChangedAction({ ...currentValue, preview: currentValue?.preview[0] }));
}
const subscription = urlStorage.change$<State>(EXPANDABLE_FLYOUT_URL_KEY).subscribe((value) => {
dispatch(urlChangedAction({ ...value, preview: value?.preview?.[0] }));
});
return () => subscription.unsubscribe();
}, [dispatch, urlStorage]);
useEffect(() => {
const { needsSync, ...stateToSync } = state;
if (needsSync) {
urlStorage.set(EXPANDABLE_FLYOUT_URL_KEY, stateToSync);
}
}, [urlStorage, state]);
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.
*/
storage?: 'url' | 'memory';
storage?: ExpandableFlyoutStorageMode;
}
/**
@ -30,8 +84,11 @@ export const ExpandableFlyoutProvider: FC<PropsWithChildren<ExpandableFlyoutProv
storage = 'url',
}) => {
return (
<ExpandableFlyoutContext.Provider value={storage}>
<MemoryStateProvider>{children}</MemoryStateProvider>
</ExpandableFlyoutContext.Provider>
<ReduxProvider context={Context} store={store}>
<>
{storage === 'url' ? <UrlSynchronizer /> : null}
{children}
</>
</ReduxProvider>
);
};

View file

@ -61,6 +61,7 @@ describe('reducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
needsSync: true,
});
});
@ -81,6 +82,7 @@ describe('reducer', () => {
left: leftPanel2,
right: rightPanel2,
preview: [previewPanel2],
needsSync: true,
});
});
@ -99,6 +101,7 @@ describe('reducer', () => {
left: undefined,
right: rightPanel2,
preview: [],
needsSync: true,
});
});
});
@ -113,6 +116,7 @@ describe('reducer', () => {
left: undefined,
right: rightPanel1,
preview: [],
needsSync: true,
});
});
@ -129,6 +133,7 @@ describe('reducer', () => {
left: leftPanel1,
right: rightPanel2,
preview: [previewPanel1],
needsSync: true,
});
});
});
@ -143,6 +148,7 @@ describe('reducer', () => {
left: leftPanel1,
right: undefined,
preview: [],
needsSync: true,
});
});
@ -159,6 +165,7 @@ describe('reducer', () => {
left: leftPanel2,
right: rightPanel1,
preview: [previewPanel1],
needsSync: true,
});
});
});
@ -173,6 +180,7 @@ describe('reducer', () => {
left: undefined,
right: undefined,
preview: [previewPanel1],
needsSync: true,
});
});
@ -189,6 +197,7 @@ describe('reducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1, previewPanel2],
needsSync: true,
});
});
});
@ -199,7 +208,7 @@ describe('reducer', () => {
const action = closeRightPanelAction();
const newState: State = reducer(state, action);
expect(newState).toEqual(state);
expect(newState).toEqual({ ...state, needsSync: true });
});
it(`should return unmodified state when removing right panel when no right panel exist`, () => {
@ -207,6 +216,7 @@ describe('reducer', () => {
left: leftPanel1,
right: undefined,
preview: [previewPanel1],
needsSync: true,
};
const action = closeRightPanelAction();
const newState: State = reducer(state, action);
@ -228,6 +238,7 @@ describe('reducer', () => {
left: leftPanel1,
right: undefined,
preview: [previewPanel1],
needsSync: true,
});
});
});
@ -238,7 +249,7 @@ describe('reducer', () => {
const action = closeLeftPanelAction();
const newState: State = reducer(state, action);
expect(newState).toEqual(state);
expect(newState).toEqual({ ...state, needsSync: true });
});
it(`should return unmodified state when removing left panel when no left panel exist`, () => {
@ -246,6 +257,7 @@ describe('reducer', () => {
left: undefined,
right: rightPanel1,
preview: [],
needsSync: true,
};
const action = closeLeftPanelAction();
const newState: State = reducer(state, action);
@ -266,13 +278,14 @@ describe('reducer', () => {
left: undefined,
right: rightPanel1,
preview: [previewPanel1],
needsSync: true,
});
});
});
describe('should handle closePreviewPanel action', () => {
it('should return empty state when removing preview panel on empty state', () => {
const state: State = initialState;
const state: State = { ...initialState, needsSync: true };
const action = closePreviewPanelAction();
const newState: State = reducer(state, action);
@ -288,7 +301,7 @@ describe('reducer', () => {
const action = closePreviewPanelAction();
const newState: State = reducer(state, action);
expect(newState).toEqual(state);
expect(newState).toEqual({ ...state, needsSync: true });
});
it('should remove all preview panels', () => {
@ -304,6 +317,7 @@ describe('reducer', () => {
left: rightPanel1,
right: leftPanel1,
preview: [],
needsSync: true,
});
});
});
@ -314,7 +328,7 @@ describe('reducer', () => {
const action = previousPreviewPanelAction();
const newState: State = reducer(state, action);
expect(newState).toEqual(state);
expect(newState).toEqual({ ...initialState, needsSync: true });
});
it(`should return unmodified state when previous preview panel when no preview panel exist`, () => {
@ -322,6 +336,7 @@ describe('reducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [],
needsSync: true,
};
const action = previousPreviewPanelAction();
const newState: State = reducer(state, action);
@ -342,6 +357,7 @@ describe('reducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
needsSync: true,
});
});
});
@ -352,7 +368,7 @@ describe('reducer', () => {
const action = closePanelsAction();
const newState: State = reducer(state, action);
expect(newState).toEqual(initialState);
expect(newState).toEqual({ ...initialState, needsSync: true });
});
it('should remove all panels', () => {
@ -368,6 +384,7 @@ describe('reducer', () => {
left: undefined,
right: undefined,
preview: [],
needsSync: true,
});
});
});

View file

@ -17,6 +17,7 @@ import {
closeRightPanelAction,
previousPreviewPanelAction,
openPreviewPanelAction,
urlChangedAction,
} from './actions';
import { initialState } from './state';
@ -25,39 +26,57 @@ export const reducer = createReducer(initialState, (builder) => {
state.preview = preview ? [preview] : [];
state.right = right;
state.left = left;
state.needsSync = true;
});
builder.addCase(openLeftPanelAction, (state, { payload }) => {
state.left = payload;
state.needsSync = true;
});
builder.addCase(openRightPanelAction, (state, { payload }) => {
state.right = payload;
state.needsSync = true;
});
builder.addCase(openPreviewPanelAction, (state, { payload }) => {
state.preview.push(payload);
state.needsSync = true;
});
builder.addCase(previousPreviewPanelAction, (state) => {
state.preview.pop();
state.needsSync = true;
});
builder.addCase(closePanelsAction, (state) => {
state.preview = [];
state.right = undefined;
state.left = undefined;
state.needsSync = true;
});
builder.addCase(closeLeftPanelAction, (state) => {
state.left = undefined;
state.needsSync = true;
});
builder.addCase(closeRightPanelAction, (state) => {
state.right = undefined;
state.needsSync = true;
});
builder.addCase(closePreviewPanelAction, (state) => {
state.preview = [];
state.needsSync = true;
});
builder.addCase(urlChangedAction, (state, { payload: { preview, left, right } }) => {
state.needsSync = false;
state.preview = preview ? [preview] : [];
state.left = left;
state.right = right;
return state;
});
});

View file

@ -0,0 +1,29 @@
/*
* 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 { createContext } from 'react';
import { createDispatchHook, createSelectorHook, ReactReduxContextValue } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { reducer } from './reducer';
import { initialState, State } from './state';
export const store = configureStore({
reducer,
devTools: process.env.NODE_ENV !== 'production',
enhancers: [],
});
export const Context = createContext<ReactReduxContextValue<State>>({
store,
storeState: initialState,
});
export const useDispatch = createDispatchHook(Context);
export const useSelector = createSelectorHook(Context);
export const stateSelector = (state: State) => state;

View file

@ -21,10 +21,18 @@ export interface State {
* Panels to render in the preview section
*/
preview: FlyoutPanelProps[];
/**
* Is the flyout in sync with external storage (eg. url)?
* This value can be used in useEffect for example, to control whether we should
* call an external state sync method.
*/
needsSync?: boolean;
}
export const initialState: State = {
left: undefined,
right: undefined,
preview: [],
needsSync: false,
};

View file

@ -10,8 +10,7 @@ import { Provider as ReduxProvider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import React, { FC, PropsWithChildren } from 'react';
import { reducer } from '../reducer';
import { Context } from '../context/memory_state_provider';
import { ExpandableFlyoutContext } from '../context';
import { Context } from '../redux';
import { initialState, State } from '../state';
interface TestProviderProps {
@ -30,10 +29,8 @@ export const TestProvider: FC<PropsWithChildren<TestProviderProps>> = ({
});
return (
<ExpandableFlyoutContext.Provider value="memory">
<ReduxProvider store={store} context={Context}>
{children}
</ReduxProvider>
</ExpandableFlyoutContext.Provider>
<ReduxProvider store={store} context={Context}>
{children}
</ReduxProvider>
);
};

View file

@ -7,14 +7,8 @@
*/
import React from 'react';
import { State } from './state';
export interface ExpandableFlyoutApi {
/**
* Right, left and preview panels
*/
panels: State;
/**
* Open the flyout with left, right and/or preview panels
*/

View file

@ -20,6 +20,6 @@
],
"kbn_references": [
"@kbn/i18n",
"@kbn/url-state"
"@kbn/kibana-utils-plugin"
]
}

View file

@ -17,7 +17,6 @@ import { mockDataFormattedForFieldBrowser } from '../../shared/mocks/mock_data_f
import { TestProvidersComponent } from '../../../../common/mock';
import { useGetAlertDetailsFlyoutLink } from '../../../../timelines/components/side_panel/event_details/use_get_alert_details_flyout_link';
import { URL_PARAM_KEY } from '../../../../common/hooks/use_url_state';
import { ExpandableFlyoutProvider } from '@kbn/expandable-flyout';
jest.mock('../../../../common/lib/kibana');
jest.mock('../hooks/use_assistant');
@ -40,11 +39,9 @@ const mockContextValue = {
const renderHeaderActions = (contextValue: RightPanelContext) =>
render(
<TestProvidersComponent>
<ExpandableFlyoutProvider>
<RightPanelContext.Provider value={contextValue}>
<HeaderActions />
</RightPanelContext.Provider>
</ExpandableFlyoutProvider>
<RightPanelContext.Provider value={contextValue}>
<HeaderActions />
</RightPanelContext.Provider>
</TestProvidersComponent>
);

View file

@ -16,7 +16,6 @@ import {
INVESTIGATION_GUIDE_TEST_ID,
} from './test_ids';
import { mockContextValue } from '../mocks/mock_context';
import { mockFlyoutContextValue } from '../../shared/mocks/mock_flyout_context';
import type { ExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { useInvestigationGuide } from '../../shared/hooks/use_investigation_guide';
@ -25,6 +24,8 @@ import { LeftPanelInvestigationTab, DocumentDetailsLeftPanelKey } from '../../le
jest.mock('../../shared/hooks/use_investigation_guide');
jest.mock('@kbn/expandable-flyout', () => ({ useExpandableFlyoutApi: jest.fn() }));
const mockFlyoutContextValue = { openLeftPanel: jest.fn() };
const NO_DATA_MESSAGE = 'Investigation guideTheres no investigation guide for this rule.';
const PREVIEW_MESSAGE = 'Investigation guide is not available in alert preview.';

View file

@ -5,12 +5,12 @@
* 2.0.
*/
import type { ExpandableFlyoutContextValue } from '@kbn/expandable-flyout/src/context';
import type { ExpandableFlyoutApi } from '@kbn/expandable-flyout';
/**
* Mock flyout context
* Mock flyout api
*/
export const mockFlyoutContextValue: ExpandableFlyoutContextValue = {
export const mockFlyoutApi: ExpandableFlyoutApi = {
openFlyout: jest.fn(),
openRightPanel: jest.fn(),
openLeftPanel: jest.fn(),
@ -20,9 +20,4 @@ export const mockFlyoutContextValue: ExpandableFlyoutContextValue = {
closePreviewPanel: jest.fn(),
previousPreviewPanel: jest.fn(),
closeFlyout: jest.fn(),
panels: {
left: undefined,
right: undefined,
preview: [],
},
};

View file

@ -19,18 +19,13 @@ import type { ExpandableFlyoutState } from '@kbn/expandable-flyout';
import {
useExpandableFlyoutApi,
type ExpandableFlyoutApi,
ExpandableFlyoutProvider,
useExpandableFlyoutState,
} from '@kbn/expandable-flyout';
const expandDetails = jest.fn();
const ExpandableFlyoutTestProviders: FC<PropsWithChildren<{}>> = ({ children }) => {
return (
<TestProviders>
<ExpandableFlyoutProvider>{children}</ExpandableFlyoutProvider>
</TestProviders>
);
return <TestProviders>{children}</TestProviders>;
};
jest.mock('@kbn/expandable-flyout', () => ({

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import React, { type PropsWithChildren } from 'react';
import { mount } from 'enzyme';
import { waitFor } from '@testing-library/react';
@ -23,14 +23,12 @@ jest.mock('../../../../../common/hooks/use_experimental_features', () => ({
useIsExperimentalFeatureEnabled: () => mockUseIsExperimentalFeatureEnabled,
}));
jest.mock('@kbn/expandable-flyout/src/context', () => {
const original = jest.requireActual('@kbn/expandable-flyout/src/context');
jest.mock('@kbn/expandable-flyout', () => {
return {
...original,
useExpandableFlyoutApi: () => ({
openRightPanel: mockOpenRightPanel,
}),
TestProvider: ({ children }: PropsWithChildren<{}>) => <>{children}</>,
};
});

View file

@ -26,13 +26,13 @@ describe('Expandable flyout state sync', { tags: ['@ess', '@serverless'] }, () =
});
it('should test flyout url sync', () => {
cy.url().should('not.include', 'rightPanel');
cy.url().should('not.include', 'right');
expandFirstAlertExpandableFlyout();
cy.log('should serialize its state to url');
cy.url().should('include', 'rightPanel');
cy.url().should('include', 'right');
cy.get(DOCUMENT_DETAILS_FLYOUT_HEADER_TITLE).should('have.text', rule.name);
cy.log('should reopen the flyout after browser refresh');
@ -40,13 +40,13 @@ describe('Expandable flyout state sync', { tags: ['@ess', '@serverless'] }, () =
cy.reload();
waitForAlertsToPopulate();
cy.url().should('include', 'rightPanel');
cy.url().should('include', 'right');
cy.get(DOCUMENT_DETAILS_FLYOUT_HEADER_TITLE).should('have.text', rule.name);
cy.log('should clear the url state when flyout is closed');
closeFlyout();
cy.url().should('not.include', 'rightPanel');
cy.url().should('not.include', 'right');
});
});