From a2a6cd2a83246ae910219b92f7929f09f1276394 Mon Sep 17 00:00:00 2001 From: Luke G <11671118+lgestc@users.noreply.github.com> Date: Thu, 30 Nov 2023 14:51:55 +0100 Subject: [PATCH] [Security Solution] [Flyout] drive flyout state with url or memory + support back button navigation from timelines (#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** https://github.com/elastic/security-team/issues/8135 --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- packages/kbn-expandable-flyout/README.md | 6 + packages/kbn-expandable-flyout/index.ts | 11 +- .../src/components/preview_section.test.tsx | 6 +- .../kbn-expandable-flyout/src/constants.ts | 9 + .../kbn-expandable-flyout/src/context.tsx | 193 +----------------- .../src/context/memory_state_provider.tsx | 98 +++++++++ .../src/context/url_state_provider.tsx | 117 +++++++++++ .../src/hooks/use_left_panel.ts | 23 +++ .../src/hooks/use_preview_panel.ts | 23 +++ .../src/hooks/use_right_panel.ts | 23 +++ .../src/index.stories.tsx | 18 +- .../kbn-expandable-flyout/src/index.test.tsx | 18 +- packages/kbn-expandable-flyout/src/index.tsx | 21 +- .../kbn-expandable-flyout/src/provider.tsx | 37 ++++ packages/kbn-expandable-flyout/src/types.ts | 49 +++++ packages/kbn-expandable-flyout/tsconfig.json | 3 +- packages/kbn-url-state/README.md | 36 ++-- packages/kbn-url-state/index.test.ts | 88 +++----- packages/kbn-url-state/index.ts | 116 ++++++++++- packages/kbn-url-state/use_sync_to_url.ts | 92 --------- .../timeline/use_init_timeline_url_param.ts | 21 +- .../public/common/hooks/use_url_state.ts | 26 ++- .../utils/global_query_string/helpers.ts | 7 + .../use_investigate_in_timeline.tsx | 3 + .../pages/alerts/alert_details_redirect.tsx | 3 +- .../left/tabs/insights_tab.tsx | 17 +- .../components/correlations_overview.test.tsx | 3 +- .../right/components/description.test.tsx | 14 +- .../right/components/description.tsx | 8 +- .../right/components/header_actions.test.tsx | 9 +- .../right/components/header_actions.tsx | 4 +- .../right/components/header_title.test.tsx | 3 +- .../highlighted_fields_cell.test.tsx | 3 +- .../components/host_entity_overview.test.tsx | 3 +- .../components/investigation_section.test.tsx | 3 +- .../components/prevalence_overview.test.tsx | 3 +- .../right/components/reason.test.tsx | 16 +- .../right/components/reason.tsx | 10 +- .../components/response_section.test.tsx | 3 +- .../right/components/session_preview.test.tsx | 3 +- .../right/components/status.test.tsx | 3 +- .../threat_intelligence_overview.test.tsx | 3 +- .../components/user_entity_overview.test.tsx | 3 +- .../visualizations_section.test.tsx | 3 +- .../shared/context/url_sync.tsx | 47 ----- ...expandable_flyout_state_from_event_meta.ts | 7 +- .../use_sync_flyout_state_with_url.test.tsx | 77 ------- .../url/use_sync_flyout_state_with_url.tsx | 55 ----- .../shared/mocks/mock_flyout_context.ts | 11 +- .../components/risk_summary.stories.tsx | 3 +- .../user_right/content.stories.tsx | 3 +- .../security_solution/public/flyout/index.tsx | 42 +--- .../components/flyout_navigation.stories.tsx | 5 +- .../components/flyout_navigation.test.tsx | 5 +- .../shared/components/flyout_navigation.tsx | 2 +- .../components/flyout/header/index.tsx | 6 +- .../timeline/query_tab_content/index.tsx | 7 +- .../plugins/security_solution/tsconfig.json | 1 - .../alert_details_url_sync.cy.ts | 8 +- 59 files changed, 722 insertions(+), 718 deletions(-) create mode 100644 packages/kbn-expandable-flyout/src/constants.ts create mode 100644 packages/kbn-expandable-flyout/src/context/memory_state_provider.tsx create mode 100644 packages/kbn-expandable-flyout/src/context/url_state_provider.tsx create mode 100644 packages/kbn-expandable-flyout/src/hooks/use_left_panel.ts create mode 100644 packages/kbn-expandable-flyout/src/hooks/use_preview_panel.ts create mode 100644 packages/kbn-expandable-flyout/src/hooks/use_right_panel.ts create mode 100644 packages/kbn-expandable-flyout/src/provider.tsx delete mode 100644 packages/kbn-url-state/use_sync_to_url.ts delete mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/shared/context/url_sync.tsx delete mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/url/use_sync_flyout_state_with_url.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/url/use_sync_flyout_state_with_url.tsx diff --git a/packages/kbn-expandable-flyout/README.md b/packages/kbn-expandable-flyout/README.md index 3e3b620a06b53..d5428bd7e4fcb 100644 --- a/packages/kbn-expandable-flyout/README.md +++ b/packages/kbn-expandable-flyout/README.md @@ -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 diff --git a/packages/kbn-expandable-flyout/index.ts b/packages/kbn-expandable-flyout/index.ts index 5a9094d6dbac9..6de82033af04c 100644 --- a/packages/kbn-expandable-flyout/index.ts +++ b/packages/kbn-expandable-flyout/index.ts @@ -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'; diff --git a/packages/kbn-expandable-flyout/src/components/preview_section.test.tsx b/packages/kbn-expandable-flyout/src/components/preview_section.test.tsx index 950d406893082..69bdd7050e64a 100644 --- a/packages/kbn-expandable-flyout/src/components/preview_section.test.tsx +++ b/packages/kbn-expandable-flyout/src/components/preview_section.test.tsx @@ -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: {}, @@ -27,7 +27,7 @@ describe('PreviewSection', () => { }, ], }, - } as unknown as ExpandableFlyoutContext; + } as unknown as ExpandableFlyoutContextValue; const component =
{'component'}
; const left = 500; diff --git a/packages/kbn-expandable-flyout/src/constants.ts b/packages/kbn-expandable-flyout/src/constants.ts new file mode 100644 index 0000000000000..4ee20ebb8e8f4 --- /dev/null +++ b/packages/kbn-expandable-flyout/src/constants.ts @@ -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; diff --git a/packages/kbn-expandable-flyout/src/context.tsx b/packages/kbn-expandable-flyout/src/context.tsx index 9738c2a4c867d..6deb5d2bede30 100644 --- a/packages/kbn-expandable-flyout/src/context.tsx +++ b/packages/kbn-expandable-flyout/src/context.tsx @@ -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( +export const ExpandableFlyoutContext = createContext( undefined ); -export type ExpandableFlyoutApi = Pick & { - 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 ( - - {children} - - ); -}); - /** * Retrieve context's properties */ -export const useExpandableFlyoutContext = (): ExpandableFlyoutContext => { +export const useExpandableFlyoutContext = (): ExpandableFlyoutContextValue => { const contextValue = useContext(ExpandableFlyoutContext); if (!contextValue) { diff --git a/packages/kbn-expandable-flyout/src/context/memory_state_provider.tsx b/packages/kbn-expandable-flyout/src/context/memory_state_provider.tsx new file mode 100644 index 0000000000000..f70ae9dbbe8de --- /dev/null +++ b/packages/kbn-expandable-flyout/src/context/memory_state_provider.tsx @@ -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> = ({ 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 ( + + {children} + + ); +}; diff --git a/packages/kbn-expandable-flyout/src/context/url_state_provider.tsx b/packages/kbn-expandable-flyout/src/context/url_state_provider.tsx new file mode 100644 index 0000000000000..e6f81e23d2fb1 --- /dev/null +++ b/packages/kbn-expandable-flyout/src/context/url_state_provider.tsx @@ -0,0 +1,117 @@ +/* + * 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 } from 'react'; +import { FlyoutPanelProps, ExpandableFlyoutContextValue } from '../types'; +import { useRightPanel } from '../hooks/use_right_panel'; +import { useLeftPanel } from '../hooks/use_left_panel'; +import { usePreviewPanel } from '../hooks/use_preview_panel'; +import { ExpandableFlyoutContext } from '../context'; +import { State } from '../reducer'; + +/** + * Private component that manages flyout state with url query params + */ +export const UrlStateProvider: FC> = ({ children }) => { + 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: ExpandableFlyoutContextValue = useMemo( + () => ({ + panels, + openFlyout: openPanels, + openRightPanel, + openLeftPanel, + openPreviewPanel, + closeRightPanel, + closeLeftPanel, + closePreviewPanel, + closeFlyout: closePanels, + previousPreviewPanel, + }), + [ + panels, + openPanels, + openRightPanel, + openLeftPanel, + openPreviewPanel, + closeRightPanel, + closeLeftPanel, + closePreviewPanel, + closePanels, + previousPreviewPanel, + ] + ); + + return ( + + {children} + + ); +}; diff --git a/packages/kbn-expandable-flyout/src/hooks/use_left_panel.ts b/packages/kbn-expandable-flyout/src/hooks/use_left_panel.ts new file mode 100644 index 0000000000000..2a3e4212a06fe --- /dev/null +++ b/packages/kbn-expandable-flyout/src/hooks/use_left_panel.ts @@ -0,0 +1,23 @@ +/* + * 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( + EXPANDABLE_FLYOUT_URL_KEY, + 'leftPanel' + ); + + return { leftPanelState, setLeftPanelState } as const; +}; diff --git a/packages/kbn-expandable-flyout/src/hooks/use_preview_panel.ts b/packages/kbn-expandable-flyout/src/hooks/use_preview_panel.ts new file mode 100644 index 0000000000000..5e9cfddb93ba4 --- /dev/null +++ b/packages/kbn-expandable-flyout/src/hooks/use_preview_panel.ts @@ -0,0 +1,23 @@ +/* + * 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( + EXPANDABLE_FLYOUT_URL_KEY, + 'preview' + ); + + return { previewState, setPreviewState } as const; +}; diff --git a/packages/kbn-expandable-flyout/src/hooks/use_right_panel.ts b/packages/kbn-expandable-flyout/src/hooks/use_right_panel.ts new file mode 100644 index 0000000000000..2bce75d65f23e --- /dev/null +++ b/packages/kbn-expandable-flyout/src/hooks/use_right_panel.ts @@ -0,0 +1,23 @@ +/* + * 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( + EXPANDABLE_FLYOUT_URL_KEY, + 'rightPanel' + ); + + return { rightPanelState, setRightPanelState } as const; +}; diff --git a/packages/kbn-expandable-flyout/src/index.stories.tsx b/packages/kbn-expandable-flyout/src/index.stories.tsx index 2bbc26c3363f3..615621bf9a571 100644 --- a/packages/kbn-expandable-flyout/src/index.stories.tsx +++ b/packages/kbn-expandable-flyout/src/index.stories.tsx @@ -19,7 +19,7 @@ import { EuiTitle, } from '@elastic/eui'; import { ExpandableFlyout } from '.'; -import { ExpandableFlyoutContext } from './context'; +import { ExpandableFlyoutContext, ExpandableFlyoutContextValue } from './context'; export default { component: ExpandableFlyout, @@ -100,7 +100,7 @@ const registeredPanels = [ ]; export const Right: Story = () => { - const context: ExpandableFlyoutContext = { + const context = { panels: { right: { id: 'right', @@ -109,7 +109,7 @@ export const Right: Story = () => { preview: [], }, closeFlyout: () => window.alert('closeFlyout api'), - } as unknown as ExpandableFlyoutContext; + } as unknown as ExpandableFlyoutContextValue; return ( @@ -119,7 +119,7 @@ export const Right: Story = () => { }; export const Left: Story = () => { - const context: ExpandableFlyoutContext = { + const context = { panels: { right: { id: 'right', @@ -130,7 +130,7 @@ export const Left: Story = () => { preview: [], }, closeFlyout: () => window.alert('closeFlyout api'), - } as unknown as ExpandableFlyoutContext; + } as unknown as ExpandableFlyoutContextValue; return ( @@ -140,7 +140,7 @@ export const Left: Story = () => { }; export const Preview: Story = () => { - const context: ExpandableFlyoutContext = { + const context = { panels: { right: { id: 'right', @@ -156,7 +156,7 @@ export const Preview: Story = () => { }, closePreviewPanel: () => window.alert('closePreviewPanel api'), closeFlyout: () => window.alert('closeFlyout api'), - } as unknown as ExpandableFlyoutContext; + } as unknown as ExpandableFlyoutContextValue; return ( @@ -166,7 +166,7 @@ export const Preview: Story = () => { }; export const MultiplePreviews: Story = () => { - const context: ExpandableFlyoutContext = { + const context = { panels: { right: { id: 'right', @@ -186,7 +186,7 @@ export const MultiplePreviews: Story = () => { closePreviewPanel: () => window.alert('closePreviewPanel api'), previousPreviewPanel: () => window.alert('previousPreviewPanel api'), closeFlyout: () => window.alert('closeFlyout api'), - } as unknown as ExpandableFlyoutContext; + } as unknown as ExpandableFlyoutContextValue; return ( diff --git a/packages/kbn-expandable-flyout/src/index.test.tsx b/packages/kbn-expandable-flyout/src/index.test.tsx index c6da99ad01777..46ae2dce508f2 100644 --- a/packages/kbn-expandable-flyout/src/index.test.tsx +++ b/packages/kbn-expandable-flyout/src/index.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { Panel } from './types'; +import { ExpandableFlyoutContextValue, Panel } from './types'; import { ExpandableFlyout } from '.'; import { LEFT_SECTION_TEST_ID, @@ -26,13 +26,13 @@ describe('ExpandableFlyout', () => { ]; it(`shouldn't render flyout if no panels`, () => { - const context: ExpandableFlyoutContext = { + const context = { panels: { right: undefined, left: undefined, preview: [], }, - } as unknown as ExpandableFlyoutContext; + } as unknown as ExpandableFlyoutContextValue; const result = render( @@ -44,7 +44,7 @@ describe('ExpandableFlyout', () => { }); it('should render right section', () => { - const context: ExpandableFlyoutContext = { + const context = { panels: { right: { id: 'key', @@ -52,7 +52,7 @@ describe('ExpandableFlyout', () => { left: {}, preview: [], }, - } as unknown as ExpandableFlyoutContext; + } as unknown as ExpandableFlyoutContextValue; const { getByTestId } = render( @@ -64,7 +64,7 @@ describe('ExpandableFlyout', () => { }); it('should render left section', () => { - const context: ExpandableFlyoutContext = { + const context = { panels: { right: {}, left: { @@ -72,7 +72,7 @@ describe('ExpandableFlyout', () => { }, preview: [], }, - } as unknown as ExpandableFlyoutContext; + } as unknown as ExpandableFlyoutContextValue; const { getByTestId } = render( @@ -84,7 +84,7 @@ describe('ExpandableFlyout', () => { }); it('should render preview section', () => { - const context: ExpandableFlyoutContext = { + const context: ExpandableFlyoutContextValue = { panels: { right: {}, left: {}, @@ -94,7 +94,7 @@ describe('ExpandableFlyout', () => { }, ], }, - } as unknown as ExpandableFlyoutContext; + } as unknown as ExpandableFlyoutContextValue; const { getByTestId } = render( diff --git a/packages/kbn-expandable-flyout/src/index.tsx b/packages/kbn-expandable-flyout/src/index.tsx index 17613be6859b7..e7f6c3fcafb23 100644 --- a/packages/kbn-expandable-flyout/src/index.tsx +++ b/packages/kbn-expandable-flyout/src/index.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useCallback, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { EuiFlyoutProps } from '@elastic/eui'; import { EuiFlexGroup, EuiFlyout } from '@elastic/eui'; import { useSectionSizes } from './hooks/use_sections_sizes'; @@ -25,10 +25,6 @@ export interface ExpandableFlyoutProps extends Omit { * List of all registered panels available for render */ registeredPanels: Panel[]; - /** - * Propagate out EuiFlyout onClose event - */ - handleOnFlyoutClosed?: () => void; } /** @@ -40,18 +36,13 @@ export interface ExpandableFlyoutProps extends Omit { */ export const ExpandableFlyout: React.FC = ({ registeredPanels, - handleOnFlyoutClosed, ...flyoutProps }) => { const windowWidth = useWindowSize(); - const { panels, closeFlyout } = useExpandableFlyoutContext(); - const { left, right, preview } = panels; + const { closeFlyout, panels } = useExpandableFlyoutContext(); - const onClose = useCallback(() => { - if (handleOnFlyoutClosed) handleOnFlyoutClosed(); - closeFlyout(); - }, [closeFlyout, handleOnFlyoutClosed]); + const { left, right, preview } = panels; const leftSection = useMemo( () => registeredPanels.find((panel) => panel.key === left?.id), @@ -69,7 +60,7 @@ export const ExpandableFlyout: React.FC = ({ ? mostRecentPreview?.params?.banner : undefined; - const showBackButton = preview && preview.length > 1; + const showBackButton = !!preview && preview.length > 1; const previewSection = useMemo( () => registeredPanels.find((panel) => panel.key === mostRecentPreview?.id), [mostRecentPreview, registeredPanels] @@ -86,13 +77,13 @@ export const ExpandableFlyout: React.FC = ({ showPreview, }); - const hideFlyout = !left && !right && !preview.length; + const hideFlyout = !left && !right && !preview?.length; if (hideFlyout) { return null; } return ( - + > = ({ + children, + storage = 'url', +}) => { + if (storage === 'memory') { + return {children}; + } + + return {children}; +}; diff --git a/packages/kbn-expandable-flyout/src/types.ts b/packages/kbn-expandable-flyout/src/types.ts index bfe64c4599085..92c4af79024b8 100644 --- a/packages/kbn-expandable-flyout/src/types.ts +++ b/packages/kbn-expandable-flyout/src/types.ts @@ -7,6 +7,55 @@ */ import React from 'react'; +import { State } from './reducer'; + +export interface ExpandableFlyoutContextValue { + /** + * 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 interface PanelPath { /** diff --git a/packages/kbn-expandable-flyout/tsconfig.json b/packages/kbn-expandable-flyout/tsconfig.json index d1755389bcddc..9a5dcbaf03048 100644 --- a/packages/kbn-expandable-flyout/tsconfig.json +++ b/packages/kbn-expandable-flyout/tsconfig.json @@ -19,6 +19,7 @@ "target/**/*" ], "kbn_references": [ - "@kbn/i18n" + "@kbn/i18n", + "@kbn/url-state" ] } diff --git a/packages/kbn-url-state/README.md b/packages/kbn-url-state/README.md index e7b131e3743d5..3549936888f9f 100644 --- a/packages/kbn-url-state/README.md +++ b/packages/kbn-url-state/README.md @@ -1,45 +1,33 @@ # @kbn/url-state - utils for syncing state to URL -This package provides: +This package provides a React hook called `useUrlState` that can be used to synchronize state to the URL. This can be useful when you want to make a portion of state shareable. -- a React hook called `useSyncToUrl` that can be used to synchronize state to the URL. This can be useful when you want to make a portion of state shareable. - -## useSyncToUrl - -The `useSyncToUrl` hook takes three arguments: - -``` -key (string): The key to use in the URL to store the state. -restore (function): A function that is called with the deserialized value from the URL. You should use this function to update your state based on the value from the URL. -cleanupOnHistoryNavigation (optional boolean, default: true): If true, the hook will clear the URL state when the user navigates using the browser's history API. -``` +The state is grouped under a namespace, to avoid collisions. See the example url below for how it would look like. ### Example usage: ``` import React, { useState } from 'react'; -import { useSyncToUrl } from '@kbn/url-state'; +import { useUrlState } from '@kbn/url-state'; function MyComponent() { - const [count, setCount] = useState(0); - - useSyncToUrl('count', (value) => { - setCount(value); - }); + const [name, setName] = useUrlState('namespace','name'); const handleClick = () => { - setCount((prevCount) => prevCount + 1); + setName('John Doe') }; return (
-

Count: {count}

- +

Name: {name}

+
); } ``` -In this example, the count state is synced to the URL using the `useSyncToUrl` hook. -Whenever the count state changes, the hook will update the URL with the new value. -When the user copies the updated url or refreshes the page, `restore` function will be called to update the count state. \ No newline at end of file +The resulting URL will look like this: + +``` +http://localhost:5601/?namespace=(name:John%20Doe) +``` diff --git a/packages/kbn-url-state/index.test.ts b/packages/kbn-url-state/index.test.ts index e2a85e58902f1..7e646a387d245 100644 --- a/packages/kbn-url-state/index.test.ts +++ b/packages/kbn-url-state/index.test.ts @@ -7,8 +7,7 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; -import { useSyncToUrl } from '.'; -import { encode } from '@kbn/rison'; +import { useUrlState } from '.'; describe('useSyncToUrl', () => { let originalLocation: Location; @@ -28,95 +27,70 @@ describe('useSyncToUrl', () => { window.history = { ...originalHistory, replaceState: jest.fn(), + pushState: jest.fn(), }; + + jest.useFakeTimers(); }); afterEach(() => { window.location = originalLocation; window.history = originalHistory; + jest.useRealTimers(); }); - it('should restore the value from the query string on mount', () => { - const key = 'testKey'; - const restoredValue = { test: 'value' }; - const encodedValue = encode(restoredValue); - const restore = jest.fn(); - - window.location.search = `?${key}=${encodedValue}`; - - renderHook(() => useSyncToUrl(key, restore)); - - expect(restore).toHaveBeenCalledWith(restoredValue); - }); - - it('should sync the value to the query string', () => { - const key = 'testKey'; - const valueToSerialize = { test: 'value' }; + it('should update the URL when the state changes', () => { + window.location.hash = '#should_be_there'; - const { result } = renderHook(() => useSyncToUrl(key, jest.fn())); + const { result } = renderHook(() => useUrlState('namespace', 'test')); act(() => { - result.current(valueToSerialize); + result.current[1]('foo'); + jest.runAllTimers(); }); - expect(window.history.replaceState).toHaveBeenCalledWith( - { path: expect.any(String) }, + expect(window.history.pushState).toHaveBeenCalledWith( + {}, '', - '/?testKey=%28test%3Avalue%29' + '#should_be_there?namespace=(test:foo)' ); }); - it('should should not alter the location hash', () => { - const key = 'testKey'; - const valueToSerialize = { test: 'value' }; + it('should remove the key from the namespace after undefined is passed (state clear mechanism)', () => { window.location.hash = '#should_be_there'; - const { result } = renderHook(() => useSyncToUrl(key, jest.fn())); + const { result } = renderHook(() => useUrlState('namespace', 'test')); act(() => { - result.current(valueToSerialize); + result.current[1](undefined); + jest.runAllTimers(); }); - expect(window.history.replaceState).toHaveBeenCalledWith( - { path: expect.any(String) }, - '', - '/#should_be_there?testKey=%28test%3Avalue%29' - ); + expect(window.history.pushState).toHaveBeenCalledWith({}, '', '#should_be_there?namespace=()'); }); - it('should clear the value from the query string on unmount', () => { - const key = 'testKey'; - - // Location should have a key to clear - window.location.search = `?${key}=${encode({ test: 'value' })}`; - - const { unmount } = renderHook(() => useSyncToUrl(key, jest.fn())); + it('should restore the value from the query string on mount', () => { + window.location.search = `?namespace=(test:foo)`; - act(() => { - unmount(); - }); + const { + result: { current: state }, + } = renderHook(() => useUrlState('namespace', 'test')); - expect(window.history.replaceState).toHaveBeenCalledWith( - { path: expect.any(String) }, - '', - expect.any(String) - ); + expect(state[0]).toEqual('foo'); }); - it('should clear the value from the query string when history back or forward is pressed', () => { - const key = 'testKey'; - const restore = jest.fn(); + it('should return updated state on browser navigation', () => { + window.location.search = '?namespace=(test:foo)'; - // Location should have a key to clear - window.location.search = `?${key}=${encode({ test: 'value' })}`; + const { result } = renderHook(() => useUrlState('namespace', 'test')); - renderHook(() => useSyncToUrl(key, restore, true)); + expect(result.current[0]).toEqual('foo'); act(() => { - window.dispatchEvent(new Event('popstate')); + window.location.search = '?namespace=(test:bar)'; + window.dispatchEvent(new CustomEvent('popstate')); }); - expect(window.history.replaceState).toHaveBeenCalledTimes(1); - expect(window.history.replaceState).toHaveBeenCalledWith({ path: expect.any(String) }, '', '/'); + expect(result.current[0]).toEqual('bar'); }); }); diff --git a/packages/kbn-url-state/index.ts b/packages/kbn-url-state/index.ts index 73568222fb4c0..d165d75e1f956 100644 --- a/packages/kbn-url-state/index.ts +++ b/packages/kbn-url-state/index.ts @@ -6,4 +6,118 @@ * Side Public License, v 1. */ -export { useSyncToUrl } from './use_sync_to_url'; +import { useCallback, useEffect, useState } from 'react'; +import { encode, decode, RisonValue } from '@kbn/rison'; +import { stringify, parse } from 'query-string'; + +interface StateCache { + namespaces: Record>; + timeoutHandle: number; +} + +/** + * Temporary cache for state stored in the URL. This will be serialized to the URL + * in a single batched update to avoid excessive history entries. + */ +const cache: StateCache = { + namespaces: {}, + timeoutHandle: 0, +}; + +const CUSTOM_URL_EVENT = 'url:update' as const; + +// This is a list of events that can trigger a render. +const URL_CHANGE_EVENTS: string[] = ['popstate', CUSTOM_URL_EVENT]; + +/** + * This hook stores state in the URL, but with a namespace to avoid collisions with other values in the URL. + * It also batches updates to the URL to avoid excessive history entries. + * With it, you can store state in the URL and have it persist across page refreshes. + * The state is stored in the URL as a Rison encoded object. + * + * Example: when called like this `const [value, setValue] = useUrlState('myNamespace', 'myKey');` + * the state will be stored in the URL like this: `?myNamespace=(myKey:!n)` + * + * State is not cleared from the URL when the hook is unmounted and this is by design. + * If you want it to be cleared, you can do it manually by calling `setValue(undefined)`. + * + * @param urlNamespace actual top level query param key + * @param key sub key of the query param + */ +export const useUrlState = (urlNamespace: string, key: string) => { + if (!cache.namespaces[urlNamespace]) { + cache.namespaces[urlNamespace] = {}; + } + + const [internalValue, setInternalValue] = useState(undefined); + + useEffect(() => { + // This listener is called on browser navigation or on custom event. + // It updates the LOCAL state, allowing dependent components to re-render. + const listener = () => { + const searchParams = new URLSearchParams(window.location.search); + const param = searchParams.get(urlNamespace); + + const decodedState = param ? decode(param) : ({} as Record); + const decodedValue = (decodedState as Record | undefined)?.[key]; + cache.namespaces[urlNamespace][key] = decodedValue; + setInternalValue(decodedValue as unknown as T); + }; + + listener(); + + URL_CHANGE_EVENTS.forEach((event) => window.addEventListener(event, listener)); + + return () => URL_CHANGE_EVENTS.forEach((event) => window.removeEventListener(event, listener)); + }, [key, urlNamespace]); + + const setValue = useCallback( + (updatedValue: T | undefined) => { + const currentValue = cache.namespaces[urlNamespace][key]; + + const canSpread = + typeof updatedValue === 'object' && + typeof currentValue === 'object' && + !Array.isArray(updatedValue) && + !Array.isArray(currentValue); + + cache.namespaces[urlNamespace][key] = canSpread + ? ({ ...currentValue, ...updatedValue } as unknown as T) + : (updatedValue as unknown as T); + + // This batches updates to the URL state to avoid excessive history entries + if (cache.timeoutHandle) { + window.clearTimeout(cache.timeoutHandle); + } + + // The push state call is delayed to make sure that multiple calls to setValue + // within a short period of time are batched together. + cache.timeoutHandle = window.setTimeout(() => { + const searchParams = parse(location.search); + + for (const ns in cache.namespaces) { + if (!Object.prototype.hasOwnProperty.call(cache.namespaces, ns)) { + continue; + } + searchParams[ns] = encode(cache.namespaces[ns]); + } + + const newSearch = stringify(searchParams, { encode: false }); + + if (window.location.search === newSearch) { + return; + } + + const newUrl = `${window.location.hash}?${newSearch}`; + + window.history.pushState({}, '', newUrl); + // This custom event is used to notify other instances + // of this hook that the URL has changed. + window.dispatchEvent(new Event(CUSTOM_URL_EVENT)); + }, 0); + }, + [key, urlNamespace] + ); + + return [internalValue, setValue] as const; +}; diff --git a/packages/kbn-url-state/use_sync_to_url.ts b/packages/kbn-url-state/use_sync_to_url.ts deleted file mode 100644 index e6f1531980f75..0000000000000 --- a/packages/kbn-url-state/use_sync_to_url.ts +++ /dev/null @@ -1,92 +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, useEffect } from 'react'; -import { encode, decode } from '@kbn/rison'; - -// https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event -const POPSTATE_EVENT = 'popstate' as const; - -/** - * Sync any object with browser query string using @knb/rison - * @param key query string param to use - * @param restore use this to handle restored state - * @param cleanupOnHistoryNavigation use history events to cleanup state on back / forward naviation. true by default - */ -export const useSyncToUrl = ( - key: string, - restore: (data: TValueToSerialize) => void, - cleanupOnHistoryNavigation = true -) => { - useEffect(() => { - const params = new URLSearchParams(window.location.search); - const param = params.get(key); - - if (!param) { - return; - } - - const decodedQuery = decode(param); - - if (!decodedQuery) { - return; - } - - // Only restore the value if it is not falsy - restore(decodedQuery as unknown as TValueToSerialize); - }, [key, restore]); - - /** - * Synces value with the url state, under specified key. If payload is undefined, the value will be removed from the query string althogether. - */ - const syncValueToQueryString = useCallback( - (valueToSerialize?: TValueToSerialize) => { - const searchParams = new URLSearchParams(window.location.search); - - if (valueToSerialize) { - const serializedPayload = encode(valueToSerialize); - searchParams.set(key, serializedPayload); - } else { - searchParams.delete(key); - } - - const stringifiedSearchParams = searchParams.toString(); - const newSearch = stringifiedSearchParams.length > 0 ? `?${stringifiedSearchParams}` : ''; - - if (window.location.search === newSearch) { - return; - } - - // Update query string without unnecessary re-render - const newUrl = `${window.location.pathname}${window.location.hash}${newSearch}`; - window.history.replaceState({ path: newUrl }, '', newUrl); - }, - [key] - ); - - // Clear remove state from the url on unmount / when history back or forward is pressed - useEffect(() => { - const clearState = () => { - syncValueToQueryString(undefined); - }; - - if (cleanupOnHistoryNavigation) { - window.addEventListener(POPSTATE_EVENT, clearState); - } - - return () => { - clearState(); - - if (cleanupOnHistoryNavigation) { - window.removeEventListener(POPSTATE_EVENT, clearState); - } - }; - }, [cleanupOnHistoryNavigation, syncValueToQueryString]); - - return syncValueToQueryString; -}; diff --git a/x-pack/plugins/security_solution/public/common/hooks/timeline/use_init_timeline_url_param.ts b/x-pack/plugins/security_solution/public/common/hooks/timeline/use_init_timeline_url_param.ts index c16f5ebb4bae5..4d6ef73c643de 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/timeline/use_init_timeline_url_param.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/timeline/use_init_timeline_url_param.ts @@ -5,7 +5,8 @@ * 2.0. */ -import { useCallback } from 'react'; +import { useCallback, useEffect } from 'react'; +import { safeDecode } from '@kbn/rison'; import { useDispatch } from 'react-redux'; @@ -40,5 +41,23 @@ export const useInitTimelineFromUrlParam = () => { [dispatch] ); + useEffect(() => { + const listener = () => { + const timelineState = new URLSearchParams(window.location.search).get(URL_PARAM_KEY.timeline); + + if (!timelineState) { + return; + } + + const parsedState = safeDecode(timelineState) as TimelineUrl | null; + + onInitialize(parsedState); + }; + + // This is needed to initialize the timeline from the URL when the user clicks the back / forward buttons + window.addEventListener('popstate', listener); + return () => window.removeEventListener('popstate', listener); + }, [onInitialize]); + useInitializeUrlParam(URL_PARAM_KEY.timeline, onInitialize); }; diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_url_state.ts b/x-pack/plugins/security_solution/public/common/hooks/use_url_state.ts index 30d914f5ccc83..cf5616e0c33fe 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_url_state.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_url_state.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { EXPANDABLE_FLYOUT_URL_KEY } from '@kbn/expandable-flyout'; import { useSyncGlobalQueryString } from '../utils/global_query_string'; import { useInitSearchBarFromUrlParams } from './search_bar/use_init_search_bar_url_params'; import { useInitTimerangeFromUrlParam } from './search_bar/use_init_timerange_url_params'; @@ -15,9 +16,6 @@ import { useQueryTimelineByIdOnUrlChange } from './timeline/use_query_timeline_b import { useInitFlyoutFromUrlParam } from './flyout/use_init_flyout_url_param'; import { useSyncFlyoutUrlParam } from './flyout/use_sync_flyout_url_param'; -// NOTE: the expandable flyout package url state is handled here: -// x-pack/plugins/security_solution/public/flyout/url/use_sync_flyout_state_with_url.tsx - export const useUrlState = () => { useSyncGlobalQueryString(); useInitSearchBarFromUrlParams(); @@ -30,14 +28,14 @@ export const useUrlState = () => { useSyncFlyoutUrlParam(); }; -export enum URL_PARAM_KEY { - appQuery = 'query', - eventFlyout = 'eventFlyout', - filters = 'filters', - savedQuery = 'savedQuery', - sourcerer = 'sourcerer', - timeline = 'timeline', - timerange = 'timerange', - pageFilter = 'pageFilters', - rulesTable = 'rulesTable', -} +export const URL_PARAM_KEY = { + appQuery: 'query', + eventFlyout: EXPANDABLE_FLYOUT_URL_KEY, + filters: 'filters', + savedQuery: 'savedQuery', + sourcerer: 'sourcerer', + timeline: 'timeline', + timerange: 'timerange', + pageFilter: 'pageFilters', + rulesTable: 'rulesTable', +} as const; diff --git a/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.ts b/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.ts index a5b3bb1c8d31f..1be01959e2d0a 100644 --- a/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.ts @@ -97,3 +97,10 @@ export const useReplaceUrlParams = (): ((params: Record { + // NOTE: This is a workaround to make sure that new history entry is created as a result of the user action. + // This is needed because of the way global url state is handled in the security app. + // (it defaults to replace the url params instead of pushing new history entry) + window.history.pushState({}, '', window.location.href); +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx index 6fe5693796cbf..02d149860c1b4 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx @@ -18,6 +18,7 @@ import { useApi } from '@kbn/securitysolution-list-hooks'; import type { Filter } from '@kbn/es-query'; import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; +import { createHistoryEntry } from '../../../../common/utils/global_query_string/helpers'; import { timelineDefaults } from '../../../../timelines/store/timeline/defaults'; import { useKibana } from '../../../../common/lib/kibana'; import { TimelineId } from '../../../../../common/types/timeline'; @@ -175,6 +176,8 @@ export const useInvestigateInTimeline = ({ ); const investigateInTimelineAlertClick = useCallback(async () => { + createHistoryEntry(); + startTransaction({ name: ALERTS_ACTIONS.INVESTIGATE_IN_TIMELINE }); if (onInvestigateInTimelineAlertClick) { onInvestigateInTimelineAlertClick(); diff --git a/x-pack/plugins/security_solution/public/detections/pages/alerts/alert_details_redirect.tsx b/x-pack/plugins/security_solution/public/detections/pages/alerts/alert_details_redirect.tsx index 1f1ce5618abfd..f0ff67c39f0e1 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/alerts/alert_details_redirect.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/alerts/alert_details_redirect.tsx @@ -23,7 +23,6 @@ import { URL_PARAM_KEY } from '../../../common/hooks/use_url_state'; import { inputsSelectors } from '../../../common/store'; import { formatPageFilterSearchParam } from '../../../../common/utils/format_page_filter_search_param'; import { resolveFlyoutParams } from './utils'; -import { FLYOUT_URL_PARAM } from '../../../flyout/document_details/shared/hooks/url/use_sync_flyout_state_with_url'; export const AlertDetailsRedirect = () => { const { alertId } = useParams<{ alertId: string }>(); @@ -72,7 +71,7 @@ export const AlertDetailsRedirect = () => { const pageFiltersQuery = encode(formatPageFilterSearchParam([statusPageFilter])); - const currentFlyoutParams = searchParams.get(FLYOUT_URL_PARAM); + const currentFlyoutParams = searchParams.get(URL_PARAM_KEY.eventFlyout); const [isSecurityFlyoutEnabled] = useUiSetting$(ENABLE_EXPANDABLE_FLYOUT_SETTING); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/insights_tab.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/insights_tab.tsx index f3297b57183f3..3964f87e9bde6 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/insights_tab.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/insights_tab.tsx @@ -5,13 +5,13 @@ * 2.0. */ -import React, { memo, useCallback, useState, useEffect } from 'react'; +import React, { memo, useCallback } from 'react'; import { EuiButtonGroup, EuiSpacer } from '@elastic/eui'; import type { EuiButtonGroupOptionProps } from '@elastic/eui/src/components/button/button_group/button_group'; -import { useExpandableFlyoutContext } from '@kbn/expandable-flyout'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { useExpandableFlyoutContext } from '@kbn/expandable-flyout'; import { INSIGHTS_TAB_BUTTON_GROUP_TEST_ID, INSIGHTS_TAB_ENTITIES_BUTTON_TEST_ID, @@ -77,14 +77,11 @@ const insightsButtons: EuiButtonGroupOptionProps[] = [ */ export const InsightsTab: React.FC = memo(() => { const { eventId, indexName, scopeId } = useLeftPanelContext(); - const { panels, openLeftPanel } = useExpandableFlyoutContext(); - const [activeInsightsId, setActiveInsightsId] = useState( - panels.left?.path?.subTab ?? ENTITIES_TAB_ID - ); + const { openLeftPanel, panels } = useExpandableFlyoutContext(); + const activeInsightsId = panels.left?.path?.subTab ?? ENTITIES_TAB_ID; const onChangeCompressed = useCallback( (optionId: string) => { - setActiveInsightsId(optionId); openLeftPanel({ id: DocumentDetailsLeftPanelKey, path: { @@ -101,12 +98,6 @@ export const InsightsTab: React.FC = memo(() => { [eventId, indexName, scopeId, openLeftPanel] ); - useEffect(() => { - if (panels.left?.path?.subTab) { - setActiveInsightsId(panels.left?.path?.subTab); - } - }, [panels.left?.path?.subTab]); - return ( <> ', () => { it('should navigate to the left section Insights tab when clicking on button', () => { const flyoutContextValue = { openLeftPanel: jest.fn(), - } as unknown as ExpandableFlyoutContext; + } as unknown as ExpandableFlyoutContextValue; const { getByTestId } = render( diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/description.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/description.test.tsx index 5f4a1ef3283eb..81d6993e805f5 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/description.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/description.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { FormattedMessage, __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { render } from '@testing-library/react'; import { DESCRIPTION_TITLE_TEST_ID, @@ -19,6 +19,8 @@ import { mockGetFieldsData } from '../../shared/mocks/mock_get_fields_data'; import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context'; import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; import { DocumentDetailsPreviewPanelKey } from '../../preview'; +import type { ExpandableFlyoutContextValue } from '@kbn/expandable-flyout/src/types'; +import { i18n } from '@kbn/i18n'; const ruleUuid = { category: 'kibana', @@ -46,7 +48,7 @@ const ruleName = { const flyoutContextValue = { openPreviewPanel: jest.fn(), -} as unknown as ExpandableFlyoutContext; +} as unknown as ExpandableFlyoutContextValue; const panelContextValue = (dataFormattedForFieldBrowser: TimelineEventsDetailsItem[]) => ({ @@ -135,11 +137,9 @@ describe('', () => { indexName: panelContext.indexName, scopeId: panelContext.scopeId, banner: { - title: ( - + title: i18n.translate( + 'xpack.securitySolution.flyout.right.about.description.rulePreviewTitle', + { defaultMessage: 'Preview rule details' } ), backgroundColor: 'warning', textColor: 'warning', diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/description.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/description.tsx index 585325a1212e1..c612e2a6fb5a6 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/description.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/description.tsx @@ -47,11 +47,9 @@ export const Description: FC = () => { indexName, scopeId, banner: { - title: ( - + title: i18n.translate( + 'xpack.securitySolution.flyout.right.about.description.rulePreviewTitle', + { defaultMessage: 'Preview rule details' } ), backgroundColor: 'warning', textColor: 'warning', diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/header_actions.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/header_actions.test.tsx index ec7f85be212c9..1e052aa170347 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/header_actions.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/header_actions.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react'; +import type { ExpandableFlyoutContextValue } from '@kbn/expandable-flyout/src/context'; import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context'; import { copyToClipboard } from '@elastic/eui'; import { RightPanelContext } from '../context'; @@ -17,7 +18,7 @@ import { mockGetFieldsData } from '../../shared/mocks/mock_get_fields_data'; import { mockDataFormattedForFieldBrowser } from '../../shared/mocks/mock_data_formatted_for_field_browser'; import { TestProvidersComponent } from '../../../../common/mock'; import { useGetAlertDetailsFlyoutLink } from '../../../../timelines/components/side_panel/event_details/use_get_alert_details_flyout_link'; -import { FLYOUT_URL_PARAM } from '../../shared/hooks/url/use_sync_flyout_state_with_url'; +import { URL_PARAM_KEY } from '../../../../common/hooks/use_url_state'; jest.mock('../../../../common/lib/kibana'); jest.mock('../hooks/use_assistant'); @@ -32,7 +33,7 @@ jest.mock('@elastic/eui', () => ({ })); const alertUrl = 'https://example.com/alert'; -const flyoutContextValue = {} as unknown as ExpandableFlyoutContext; +const flyoutContextValue = {} as unknown as ExpandableFlyoutContextValue; const mockContextValue = { dataFormattedForFieldBrowser: mockDataFormattedForFieldBrowser, getFieldsData: jest.fn().mockImplementation(mockGetFieldsData), @@ -58,7 +59,7 @@ describe('', () => { describe('Share alert url action', () => { it('should render share button in the title and copy the the value to clipboard if document is an alert', () => { const syncedFlyoutState = 'flyoutState'; - const query = `?${FLYOUT_URL_PARAM}=${syncedFlyoutState}`; + const query = `?${URL_PARAM_KEY.eventFlyout}=${syncedFlyoutState}`; Object.defineProperty(window, 'location', { value: { @@ -73,7 +74,7 @@ describe('', () => { fireEvent.click(shareButton); expect(copyToClipboard).toHaveBeenCalledWith( - `${alertUrl}&${FLYOUT_URL_PARAM}=${syncedFlyoutState}` + `${alertUrl}&${URL_PARAM_KEY.eventFlyout}=${syncedFlyoutState}` ); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/header_actions.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/header_actions.tsx index 2862aed53501b..52bcb514e7cab 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/header_actions.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/header_actions.tsx @@ -10,8 +10,8 @@ import React, { memo } from 'react'; import { EuiButtonIcon, EuiCopy, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { NewChatById } from '@kbn/elastic-assistant'; +import { URL_PARAM_KEY } from '../../../../common/hooks/use_url_state'; import { copyFunction } from '../../../shared/utils/copy_to_clipboard'; -import { FLYOUT_URL_PARAM } from '../../shared/hooks/url/use_sync_flyout_state_with_url'; import { useGetAlertDetailsFlyoutLink } from '../../../../timelines/components/side_panel/event_details/use_get_alert_details_flyout_link'; import { useBasicDataFromDetailsData } from '../../../../timelines/components/side_panel/event_details/helpers'; import { useAssistant } from '../hooks/use_assistant'; @@ -39,7 +39,7 @@ export const HeaderActions: VFC = memo(() => { const modifier = (value: string) => { const query = new URLSearchParams(window.location.search); - return `${value}&${FLYOUT_URL_PARAM}=${query.get(FLYOUT_URL_PARAM)}`; + return `${value}&${URL_PARAM_KEY.eventFlyout}=${query.get(URL_PARAM_KEY.eventFlyout)}`; }; const { showAssistant, promptContextId } = useAssistant({ diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/header_title.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/header_title.test.tsx index 696fb7c032cd4..9c3157f9d5d0a 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/header_title.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/header_title.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { render } from '@testing-library/react'; +import type { ExpandableFlyoutContextValue } from '@kbn/expandable-flyout/src/context'; import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context'; import { RightPanelContext } from '../context'; import { @@ -28,7 +29,7 @@ moment.suppressDeprecationWarnings = true; moment.tz.setDefault('UTC'); const dateFormat = 'MMM D, YYYY @ HH:mm:ss.SSS'; -const flyoutContextValue = {} as unknown as ExpandableFlyoutContext; +const flyoutContextValue = {} as unknown as ExpandableFlyoutContextValue; const mockContextValue = { dataFormattedForFieldBrowser: mockDataFormattedForFieldBrowser, getFieldsData: jest.fn().mockImplementation(mockGetFieldsData), diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_cell.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_cell.test.tsx index c56fef68a8bfa..0112e06cb489f 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_cell.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_cell.test.tsx @@ -13,6 +13,7 @@ import { HIGHLIGHTED_FIELDS_LINKED_CELL_TEST_ID, } from './test_ids'; import { HighlightedFieldsCell } from './highlighted_fields_cell'; +import type { ExpandableFlyoutContextValue } from '@kbn/expandable-flyout/src/context'; import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context'; import { RightPanelContext } from '../context'; import { LeftPanelInsightsTab, DocumentDetailsLeftPanelKey } from '../../left'; @@ -24,7 +25,7 @@ jest.mock('../../../../management/hooks'); const flyoutContextValue = { openLeftPanel: jest.fn(), -} as unknown as ExpandableFlyoutContext; +} as unknown as ExpandableFlyoutContextValue; const panelContextValue = { eventId: 'event id', indexName: 'indexName', diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.test.tsx index 31a86495d0561..36fb3731943c4 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.test.tsx @@ -21,6 +21,7 @@ import { import { RightPanelContext } from '../context'; import { mockContextValue } from '../mocks/mock_context'; import { mockDataFormattedForFieldBrowser } from '../../shared/mocks/mock_data_formatted_for_field_browser'; +import type { ExpandableFlyoutContextValue } from '@kbn/expandable-flyout/src/context'; import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context'; import { LeftPanelInsightsTab, DocumentDetailsLeftPanelKey } from '../../left'; import { ENTITIES_TAB_ID } from '../../left/components/entities_details'; @@ -42,7 +43,7 @@ const panelContextValue = { const flyoutContextValue = { openLeftPanel: jest.fn(), -} as unknown as ExpandableFlyoutContext; +} as unknown as ExpandableFlyoutContextValue; const mockUseGlobalTime = jest.fn().mockReturnValue({ from, to }); jest.mock('../../../../common/containers/use_global_time', () => { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/investigation_section.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/investigation_section.test.tsx index 0d53dcd97a120..6775ba2e00b6a 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/investigation_section.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/investigation_section.test.tsx @@ -14,13 +14,14 @@ import { } from './test_ids'; import { RightPanelContext } from '../context'; import { InvestigationSection } from './investigation_section'; +import type { ExpandableFlyoutContextValue } from '@kbn/expandable-flyout/src/context'; import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context'; import { useRuleWithFallback } from '../../../../detection_engine/rule_management/logic/use_rule_with_fallback'; import { mockDataFormattedForFieldBrowser } from '../../shared/mocks/mock_data_formatted_for_field_browser'; jest.mock('../../../../detection_engine/rule_management/logic/use_rule_with_fallback'); -const flyoutContextValue = {} as unknown as ExpandableFlyoutContext; +const flyoutContextValue = {} as unknown as ExpandableFlyoutContextValue; const panelContextValue = { dataFormattedForFieldBrowser: mockDataFormattedForFieldBrowser.filter( (d) => d.field !== 'kibana.alert.rule.type' diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/prevalence_overview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/prevalence_overview.test.tsx index fe711387dbf17..5bdca0ec3dfa2 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/prevalence_overview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/prevalence_overview.test.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import type { ExpandableFlyoutContextValue } from '@kbn/expandable-flyout/src/context'; import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context'; import { render } from '@testing-library/react'; import { TestProviders } from '../../../../common/mock'; @@ -35,7 +36,7 @@ const NO_DATA_MESSAGE = 'No prevalence data available.'; const flyoutContextValue = { openLeftPanel: jest.fn(), -} as unknown as ExpandableFlyoutContext; +} as unknown as ExpandableFlyoutContextValue; const renderPrevalenceOverview = (contextValue: RightPanelContext = mockContextValue) => render( diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/reason.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/reason.test.tsx index 65a241a00e6bb..9b459acbaf327 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/reason.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/reason.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { FormattedMessage, __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { REASON_DETAILS_PREVIEW_BUTTON_TEST_ID, REASON_TITLE_TEST_ID } from './test_ids'; import { Reason } from './reason'; import { RightPanelContext } from '../context'; @@ -15,10 +15,12 @@ import { mockGetFieldsData } from '../../shared/mocks/mock_get_fields_data'; import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context'; import { mockDataFormattedForFieldBrowser } from '../../shared/mocks/mock_data_formatted_for_field_browser'; import { DocumentDetailsPreviewPanelKey } from '../../preview'; +import type { ExpandableFlyoutContextValue } from '@kbn/expandable-flyout/src/types'; +import { i18n } from '@kbn/i18n'; const flyoutContextValue = { openPreviewPanel: jest.fn(), -} as unknown as ExpandableFlyoutContext; +} as unknown as ExpandableFlyoutContextValue; const panelContextValue = { eventId: 'event id', @@ -89,11 +91,11 @@ describe('', () => { indexName: panelContextValue.indexName, scopeId: panelContextValue.scopeId, banner: { - title: ( - + title: i18n.translate( + 'xpack.securitySolution.flyout.right.about.reason.alertReasonPreviewTitle', + { + defaultMessage: 'Preview alert reason', + } ), backgroundColor: 'warning', textColor: 'warning', diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/reason.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/reason.tsx index 392e753213714..96ec5b2b0232c 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/reason.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/reason.tsx @@ -41,11 +41,11 @@ export const Reason: FC = () => { indexName, scopeId, banner: { - title: ( - + title: i18n.translate( + 'xpack.securitySolution.flyout.right.about.reason.alertReasonPreviewTitle', + { + defaultMessage: 'Preview alert reason', + } ), backgroundColor: 'warning', textColor: 'warning', diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/response_section.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/response_section.test.tsx index 2a2609b4749e0..d521155ea1631 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/response_section.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/response_section.test.tsx @@ -10,12 +10,13 @@ import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { render } from '@testing-library/react'; import { RESPONSE_SECTION_CONTENT_TEST_ID, RESPONSE_SECTION_HEADER_TEST_ID } from './test_ids'; import { RightPanelContext } from '../context'; +import type { ExpandableFlyoutContextValue } from '@kbn/expandable-flyout/src/context'; import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context'; import { ResponseSection } from './response_section'; const PREVIEW_MESSAGE = 'Response is not available in alert preview.'; -const flyoutContextValue = {} as unknown as ExpandableFlyoutContext; +const flyoutContextValue = {} as unknown as ExpandableFlyoutContextValue; const panelContextValue = {} as unknown as RightPanelContext; const renderResponseSection = () => diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview.test.tsx index 775e195e764fd..525ca03dbc045 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview.test.tsx @@ -10,6 +10,7 @@ import { useProcessData } from '../hooks/use_process_data'; import { SessionPreview } from './session_preview'; import { TestProviders } from '../../../../common/mock'; import React from 'react'; +import type { ExpandableFlyoutContextValue } from '@kbn/expandable-flyout/src/context'; import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context'; import { RightPanelContext } from '../context'; @@ -17,7 +18,7 @@ jest.mock('../hooks/use_process_data'); const flyoutContextValue = { openLeftPanel: jest.fn(), -} as unknown as ExpandableFlyoutContext; +} as unknown as ExpandableFlyoutContextValue; const panelContextValue = { eventId: 'event id', diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/status.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/status.test.tsx index 0b52e0ef67665..1c9412deab05a 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/status.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/status.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { render } from '@testing-library/react'; +import type { ExpandableFlyoutContextValue } from '@kbn/expandable-flyout/src/context'; import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context'; import { RightPanelContext } from '../context'; import { DocumentStatus } from './status'; @@ -19,7 +20,7 @@ jest.mock('../../../../detections/components/alerts_table/timeline_actions/use_a const flyoutContextValue = { closeFlyout: jest.fn(), -} as unknown as ExpandableFlyoutContext; +} as unknown as ExpandableFlyoutContextValue; const renderStatus = (contextValue: RightPanelContext) => render( diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/threat_intelligence_overview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/threat_intelligence_overview.test.tsx index f5ae35db72f16..d752df3d9350e 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/threat_intelligence_overview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/threat_intelligence_overview.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { render } from '@testing-library/react'; +import type { ExpandableFlyoutContextValue } from '@kbn/expandable-flyout/src/context'; import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context'; import { RightPanelContext } from '../context'; import { TestProviders } from '../../../../common/mock'; @@ -147,7 +148,7 @@ describe('', () => { }); const flyoutContextValue = { openLeftPanel: jest.fn(), - } as unknown as ExpandableFlyoutContext; + } as unknown as ExpandableFlyoutContextValue; const { getByTestId } = render( diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.test.tsx index 155f2c127fc3c..c8673f41376f4 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.test.tsx @@ -20,6 +20,7 @@ import { import { useObservedUserDetails } from '../../../../explore/users/containers/users/observed_details'; import { mockContextValue } from '../mocks/mock_context'; import { mockDataFormattedForFieldBrowser } from '../../shared/mocks/mock_data_formatted_for_field_browser'; +import type { ExpandableFlyoutContextValue } from '@kbn/expandable-flyout/src/context'; import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context'; import { RightPanelContext } from '../context'; import { LeftPanelInsightsTab, DocumentDetailsLeftPanelKey } from '../../left'; @@ -42,7 +43,7 @@ const panelContextValue = { const flyoutContextValue = { openLeftPanel: jest.fn(), -} as unknown as ExpandableFlyoutContext; +} as unknown as ExpandableFlyoutContextValue; const mockUseGlobalTime = jest.fn().mockReturnValue({ from, to }); jest.mock('../../../../common/containers/use_global_time', () => { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.test.tsx index 14a0136c73ed9..8133dfa528526 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.test.tsx @@ -15,6 +15,7 @@ import { mockContextValue } from '../mocks/mock_context'; import { mockDataFormattedForFieldBrowser } from '../../shared/mocks/mock_data_formatted_for_field_browser'; import { RightPanelContext } from '../context'; import { useAlertPrevalenceFromProcessTree } from '../../../../common/containers/alerts/use_alert_prevalence_from_process_tree'; +import type { ExpandableFlyoutContextValue } from '@kbn/expandable-flyout/src/context'; import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context'; jest.mock('../../../../common/containers/alerts/use_alert_prevalence_from_process_tree', () => ({ @@ -40,7 +41,7 @@ describe('', () => { it('should render visualizations component', () => { const flyoutContextValue = { openLeftPanel: jest.fn(), - } as unknown as ExpandableFlyoutContext; + } as unknown as ExpandableFlyoutContextValue; const { getByTestId, getAllByRole } = render( diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/context/url_sync.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/context/url_sync.tsx deleted file mode 100644 index 29b7019fa7196..0000000000000 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/context/url_sync.tsx +++ /dev/null @@ -1,47 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { createContext, useContext, useMemo, type FC } from 'react'; -import { useSyncFlyoutStateWithUrl } from '../hooks/url/use_sync_flyout_state_with_url'; - -export type SecuritySolutionFlyoutCloseContextValue = ReturnType; - -export const SecuritySolutionFlyoutCloseContext = createContext< - SecuritySolutionFlyoutCloseContextValue | undefined ->(undefined); - -/** - * Exposes the flyout close context value (returned from syncUrl) as a hook. - */ -export const useSecurityFlyoutUrlSync = () => { - const contextValue = useContext(SecuritySolutionFlyoutCloseContext); - - if (!contextValue) { - throw new Error('useSecurityFlyoutUrlSync can only be used inside respective provider'); - } - - return contextValue; -}; - -/** - * Provides urlSync hook return value as a context value, for reuse in other components. - * Main goal here is to avoid calling useSyncFlyoutStateWithUrl multiple times. - */ -export const SecuritySolutionFlyoutUrlSyncProvider: FC = ({ children }) => { - const [flyoutRef, handleFlyoutChangedOrClosed] = useSyncFlyoutStateWithUrl(); - - const value: SecuritySolutionFlyoutCloseContextValue = useMemo( - () => [flyoutRef, handleFlyoutChangedOrClosed], - [flyoutRef, handleFlyoutChangedOrClosed] - ); - - return ( - - {children} - - ); -}; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/url/expandable_flyout_state_from_event_meta.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/url/expandable_flyout_state_from_event_meta.ts index e25824ed8b68a..d80d6f5983b89 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/url/expandable_flyout_state_from_event_meta.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/url/expandable_flyout_state_from_event_meta.ts @@ -5,7 +5,6 @@ * 2.0. */ -import type { ExpandableFlyoutContext } from '@kbn/expandable-flyout'; import { DocumentDetailsRightPanelKey } from '../../../right'; interface RedirectParams { @@ -19,11 +18,7 @@ interface RedirectParams { * This value can be used to open the flyout either by passing it directly to the flyout api (exposed via ref) or * by serializing it to the url & performing a redirect */ -export const expandableFlyoutStateFromEventMeta = ({ - index, - eventId, - scopeId, -}: RedirectParams): ExpandableFlyoutContext['panels'] => { +export const expandableFlyoutStateFromEventMeta = ({ index, eventId, scopeId }: RedirectParams) => { return { right: { id: DocumentDetailsRightPanelKey, diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/url/use_sync_flyout_state_with_url.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/url/use_sync_flyout_state_with_url.test.tsx deleted file mode 100644 index 984b2a2e223dc..0000000000000 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/url/use_sync_flyout_state_with_url.test.tsx +++ /dev/null @@ -1,77 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ExpandableFlyoutApi } from '@kbn/expandable-flyout'; -import { useSyncToUrl } from '@kbn/url-state'; -import { renderHook } from '@testing-library/react-hooks'; -import { useSyncFlyoutStateWithUrl } from './use_sync_flyout_state_with_url'; - -jest.mock('@kbn/url-state'); - -describe('useSyncFlyoutStateWithUrl', () => { - it('should return an array containing flyoutApi ref and handleFlyoutChanges function', () => { - const { result } = renderHook(() => useSyncFlyoutStateWithUrl()); - const [flyoutApi, handleFlyoutChanges] = result.current; - - expect(flyoutApi.current).toBeNull(); - expect(typeof handleFlyoutChanges).toBe('function'); - }); - - it('should open flyout when relevant url state is detected in the query string', () => { - jest.useFakeTimers(); - - jest.mocked(useSyncToUrl).mockImplementation((_urlKey, callback) => { - setTimeout(() => callback({ mocked: { flyout: 'state' } }), 0); - return jest.fn(); - }); - - const { result } = renderHook(() => useSyncFlyoutStateWithUrl()); - const [flyoutApi, handleFlyoutChanges] = result.current; - - const flyoutApiMock: ExpandableFlyoutApi = { - openFlyout: jest.fn(), - getState: () => ({ left: undefined, right: undefined, preview: [] }), - }; - - expect(typeof handleFlyoutChanges).toBe('function'); - expect(flyoutApi.current).toBeNull(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (flyoutApi as any).current = flyoutApiMock; - - jest.runOnlyPendingTimers(); - jest.useRealTimers(); - - expect(flyoutApiMock.openFlyout).toHaveBeenCalledTimes(1); - expect(flyoutApiMock.openFlyout).toHaveBeenCalledWith({ mocked: { flyout: 'state' } }); - }); - - it('should sync flyout state to url whenever handleFlyoutChanges is called by the consumer', () => { - const syncStateToUrl = jest.fn(); - jest.mocked(useSyncToUrl).mockImplementation((_urlKey, callback) => { - setTimeout(() => callback({ mocked: { flyout: 'state' } }), 0); - return syncStateToUrl; - }); - - const { result } = renderHook(() => useSyncFlyoutStateWithUrl()); - const [_flyoutApi, handleFlyoutChanges] = result.current; - - handleFlyoutChanges(); - - expect(syncStateToUrl).toHaveBeenCalledTimes(1); - expect(syncStateToUrl).toHaveBeenLastCalledWith(undefined); - - handleFlyoutChanges({ left: undefined, right: undefined, preview: [] }); - - expect(syncStateToUrl).toHaveBeenLastCalledWith({ - left: undefined, - right: undefined, - preview: undefined, - }); - expect(syncStateToUrl).toHaveBeenCalledTimes(2); - }); -}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/url/use_sync_flyout_state_with_url.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/url/use_sync_flyout_state_with_url.tsx deleted file mode 100644 index 97e2500f3f948..0000000000000 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/url/use_sync_flyout_state_with_url.tsx +++ /dev/null @@ -1,55 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { useCallback, useRef } from 'react'; -import type { ExpandableFlyoutApi, ExpandableFlyoutContext } from '@kbn/expandable-flyout'; -import { useSyncToUrl } from '@kbn/url-state'; -import last from 'lodash/last'; -import { URL_PARAM_KEY } from '../../../../../common/hooks/use_url_state'; - -export const FLYOUT_URL_PARAM = URL_PARAM_KEY.eventFlyout; - -type FlyoutState = Parameters[0]; - -/** - * Sync flyout state with the url and open it when relevant url state is detected in the query string - * @returns [ref, flyoutChangesHandler] - */ -export const useSyncFlyoutStateWithUrl = () => { - const flyoutApi = useRef(null); - - const handleRestoreFlyout = useCallback( - (state?: FlyoutState) => { - if (!state) { - return; - } - - flyoutApi.current?.openFlyout(state); - }, - [flyoutApi] - ); - - const syncStateToUrl = useSyncToUrl(FLYOUT_URL_PARAM, handleRestoreFlyout); - - // This should be bound to flyout changed and closed events. - // When flyout is closed, url state is cleared - const handleFlyoutChanges = useCallback( - (state?: ExpandableFlyoutContext['panels']) => { - if (!state) { - return syncStateToUrl(undefined); - } - - return syncStateToUrl({ - ...state, - preview: last(state.preview), - }); - }, - [syncStateToUrl] - ); - - return [flyoutApi, handleFlyoutChanges] as const; -}; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/mocks/mock_flyout_context.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/mocks/mock_flyout_context.ts index b579a68994604..419047bc30f77 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/mocks/mock_flyout_context.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/mocks/mock_flyout_context.ts @@ -5,14 +5,12 @@ * 2.0. */ -import type { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context'; -import type { State } from '@kbn/expandable-flyout/src/reducer'; +import type { ExpandableFlyoutContextValue } from '@kbn/expandable-flyout/src/context'; /** * Mock flyout context */ -export const mockFlyoutContextValue: ExpandableFlyoutContext = { - panels: {} as State, +export const mockFlyoutContextValue: ExpandableFlyoutContextValue = { openFlyout: jest.fn(), openRightPanel: jest.fn(), openLeftPanel: jest.fn(), @@ -22,4 +20,9 @@ export const mockFlyoutContextValue: ExpandableFlyoutContext = { closePreviewPanel: jest.fn(), previousPreviewPanel: jest.fn(), closeFlyout: jest.fn(), + panels: { + left: undefined, + right: undefined, + preview: [], + }, }; diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/risk_summary.stories.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/risk_summary.stories.tsx index e45869115c50f..7c12607919cf7 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/risk_summary.stories.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/shared/components/risk_summary.stories.tsx @@ -7,6 +7,7 @@ import React from 'react'; import type { Story } from '@storybook/react'; +import type { ExpandableFlyoutContextValue } from '@kbn/expandable-flyout/src/context'; import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context'; import { StorybookProviders } from '../../../../common/mock/storybook_providers'; import { mockRiskScoreState } from '../../../../timelines/components/side_panel/new_user_detail/__mocks__'; @@ -20,7 +21,7 @@ export default { const flyoutContextValue = { openLeftPanel: () => window.alert('openLeftPanel called'), panels: {}, -} as unknown as ExpandableFlyoutContext; +} as unknown as ExpandableFlyoutContextValue; export const Default: Story = () => { return ( diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/content.stories.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/content.stories.tsx index 746aa8b25e0f1..b4555969dd41c 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/content.stories.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/content.stories.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; import { EuiFlyout } from '@elastic/eui'; +import type { ExpandableFlyoutContextValue } from '@kbn/expandable-flyout/src/context'; import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context'; import { StorybookProviders } from '../../../common/mock/storybook_providers'; import { @@ -20,7 +21,7 @@ import { UserPanelContent } from './content'; const flyoutContextValue = { openLeftPanel: () => window.alert('openLeftPanel called'), panels: {}, -} as unknown as ExpandableFlyoutContext; +} as unknown as ExpandableFlyoutContextValue; storiesOf('Components/UserPanelContent', module) .addDecorator((storyFn) => ( diff --git a/x-pack/plugins/security_solution/public/flyout/index.tsx b/x-pack/plugins/security_solution/public/flyout/index.tsx index 1c8ac6c4cc2c9..a9194a3a766c6 100644 --- a/x-pack/plugins/security_solution/public/flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/flyout/index.tsx @@ -23,10 +23,6 @@ import { RightPanelProvider } from './document_details/right/context'; import type { LeftPanelProps } from './document_details/left'; import { LeftPanel, DocumentDetailsLeftPanelKey } from './document_details/left'; import { LeftPanelProvider } from './document_details/left/context'; -import { - SecuritySolutionFlyoutUrlSyncProvider, - useSecurityFlyoutUrlSync, -} from './document_details/shared/context/url_sync'; import type { PreviewPanelProps } from './document_details/preview'; import { PreviewPanel, DocumentDetailsPreviewPanelKey } from './document_details/preview'; import { PreviewPanelProvider } from './document_details/preview/context'; @@ -34,7 +30,6 @@ import type { UserPanelExpandableFlyoutProps } from './entity_details/user_right import { UserPanel, UserPanelKey } from './entity_details/user_right'; import type { RiskInputsExpandableFlyoutProps } from './entity_details/risk_inputs_left'; import { RiskInputsPanel, RiskInputsPanelKey } from './entity_details/risk_inputs_left'; - /** * List of all panels that will be used within the document details expandable flyout. * This needs to be passed to the expandable flyout registeredPanels property. @@ -84,42 +79,15 @@ const expandableFlyoutDocumentsPanels: ExpandableFlyoutProps['registeredPanels'] }, ]; -const OuterProviders: FC = ({ children }) => { - return {children}; -}; - -const InnerProviders: FC = ({ children }) => { - const [flyoutRef, handleFlyoutChangedOrClosed] = useSecurityFlyoutUrlSync(); - - return ( - - {children} - - ); -}; - +// NOTE: provider below accepts "storage" prop, please take a look into component's JSDoc. export const SecuritySolutionFlyoutContextProvider: FC = ({ children }) => ( - - {children} - + {children} ); SecuritySolutionFlyoutContextProvider.displayName = 'SecuritySolutionFlyoutContextProvider'; -export const SecuritySolutionFlyout = memo(() => { - const [_flyoutRef, handleFlyoutChangedOrClosed] = useSecurityFlyoutUrlSync(); - - return ( - - ); -}); +export const SecuritySolutionFlyout = memo(() => ( + +)); SecuritySolutionFlyout.displayName = 'SecuritySolutionFlyout'; diff --git a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.stories.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.stories.tsx index cf8d82ff251a5..e41bf0c347b45 100644 --- a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.stories.tsx +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.stories.tsx @@ -7,6 +7,7 @@ import React from 'react'; import type { Story } from '@storybook/react'; +import type { ExpandableFlyoutContextValue } from '@kbn/expandable-flyout/src/context'; import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context'; import { EuiButtonIcon } from '@elastic/eui'; import { FlyoutNavigation } from './flyout_navigation'; @@ -21,7 +22,7 @@ export default { const flyoutContextValue = { closeLeftPanel: () => window.alert('close left panel'), panels: {}, -} as unknown as ExpandableFlyoutContext; +} as unknown as ExpandableFlyoutContextValue; export const Expand: Story = () => { return ( @@ -38,7 +39,7 @@ export const Collapse: Story = () => { { ...flyoutContextValue, panels: { left: {} }, - } as unknown as ExpandableFlyoutContext + } as unknown as ExpandableFlyoutContextValue } > diff --git a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.test.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.test.tsx index 9972f0e90f350..7642dfcfd81e3 100644 --- a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { act, render } from '@testing-library/react'; +import type { ExpandableFlyoutContextValue } from '@kbn/expandable-flyout/src/context'; import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context'; import { TestProviders } from '../../../common/mock'; import { FlyoutNavigation } from './flyout_navigation'; @@ -24,7 +25,7 @@ describe('', () => { it('should render expand button', () => { const flyoutContextValue = { panels: {}, - } as unknown as ExpandableFlyoutContext; + } as unknown as ExpandableFlyoutContextValue; const { getByTestId, queryByTestId } = render( @@ -47,7 +48,7 @@ describe('', () => { panels: { left: {}, }, - } as unknown as ExpandableFlyoutContext; + } as unknown as ExpandableFlyoutContextValue; const { getByTestId, queryByTestId } = render( diff --git a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.tsx b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.tsx index 820b0ac5efcd9..cc969988a16a4 100644 --- a/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.tsx +++ b/x-pack/plugins/security_solution/public/flyout/shared/components/flyout_navigation.tsx @@ -48,7 +48,7 @@ export const FlyoutNavigation: FC = memo( const { euiTheme } = useEuiTheme(); const { closeLeftPanel, panels } = useExpandableFlyoutContext(); - const isExpanded: boolean = panels.left != null; + const isExpanded: boolean = !!panels.left; const collapseDetails = useCallback(() => closeLeftPanel(), [closeLeftPanel]); const collapseButton = useMemo( diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx index 939a450e7064e..14158e884e1bb 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx @@ -13,9 +13,9 @@ import styled from 'styled-components'; import { getEsQueryConfig } from '@kbn/data-plugin/common'; import { euiStyled } from '@kbn/kibana-react-plugin/common'; +import { createHistoryEntry } from '../../../../common/utils/global_query_string/helpers'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { timelineActions, timelineSelectors } from '../../../store/timeline'; -import { timelineDefaults } from '../../../store/timeline/defaults'; import type { State } from '../../../../common/store'; import { useKibana } from '../../../../common/lib/kibana'; import { useSourcererDataView } from '../../../../common/containers/sourcerer'; @@ -27,8 +27,9 @@ import * as i18n from './translations'; import { TimelineActionMenu } from '../action_menu'; import { AddToFavoritesButton } from '../../timeline/properties/helpers'; import { TimelineStatusInfo } from './timeline_status_info'; +import { timelineDefaults } from '../../../store/timeline/defaults'; -export interface FlyoutHeaderPanelProps { +interface FlyoutHeaderPanelProps { timelineId: string; } @@ -117,6 +118,7 @@ const FlyoutHeaderPanelComponent: React.FC = ({ timeline ); const handleClose = useCallback(() => { + createHistoryEntry(); dispatch(timelineActions.showTimeline({ id: timelineId, show: false })); focusActiveTimelineButton(); }, [dispatch, timelineId]); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx index f20283f2c3c94..390369eaf89ed 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx @@ -324,10 +324,15 @@ export const QueryTabContentComponent: React.FC = ({ [ACTION_BUTTON_COUNT] ); + // NOTE: The timeline is blank after browser FORWARD navigation (after using back button to navigate to + // the previous page from the timeline), yet we still see total count. This is because the timeline + // is not getting refreshed when using browser navigation. + const showEventsCountBadge = !isBlankTimeline && totalCount >= 0; + return ( <> - {totalCount >= 0 ? {totalCount} : null} + {showEventsCountBadge ? {totalCount} : null} { - cy.url().should('not.include', 'eventFlyout'); + cy.url().should('not.include', 'rightPanel'); expandFirstAlertExpandableFlyout(); cy.log('should serialize its state to url'); - cy.url().should('include', 'eventFlyout'); + cy.url().should('include', 'rightPanel'); 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', 'eventFlyout'); + cy.url().should('include', 'rightPanel'); 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', 'eventFlyout'); + cy.url().should('not.include', 'rightPanel'); }); });