diff --git a/packages/kbn-expandable-flyout/index.ts b/packages/kbn-expandable-flyout/index.ts index 5a9094d6dbac9..a050e97c99459 100644 --- a/packages/kbn-expandable-flyout/index.ts +++ b/packages/kbn-expandable-flyout/index.ts @@ -13,7 +13,5 @@ export { type ExpandableFlyoutContext, } from './src/context'; -export type { ExpandableFlyoutApi } from './src/context'; - export type { ExpandableFlyoutProps } from './src'; export type { FlyoutPanelProps, PanelPath } from './src/types'; diff --git a/packages/kbn-expandable-flyout/src/actions.ts b/packages/kbn-expandable-flyout/src/actions.ts index aa8e813f8a845..b6fb96738be49 100644 --- a/packages/kbn-expandable-flyout/src/actions.ts +++ b/packages/kbn-expandable-flyout/src/actions.ts @@ -26,7 +26,7 @@ export type Action = payload: { right?: FlyoutPanelProps; left?: FlyoutPanelProps; - preview?: FlyoutPanelProps; + preview?: FlyoutPanelProps[]; }; } | { 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..321d59d556981 100644 --- a/packages/kbn-expandable-flyout/src/context.tsx +++ b/packages/kbn-expandable-flyout/src/context.tsx @@ -8,43 +8,50 @@ import React, { createContext, + FC, + PropsWithChildren, useCallback, useContext, - useEffect, - useImperativeHandle, useMemo, useReducer, + useState, } from 'react'; +import type { FlyoutPanelProps } from './types'; import { ActionType } from './actions'; import { reducer, State } from './reducer'; -import type { FlyoutPanelProps } from './types'; import { initialState } from './reducer'; +import { useRightPanel } from './hooks/use_right_panel'; +import { useLeftPanel } from './hooks/use_left_panel'; +import { usePreviewPanel } from './hooks/use_preview_panel'; 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; + openFlyout: ( + panels: { + left?: FlyoutPanelProps; + right?: FlyoutPanelProps; + preview?: FlyoutPanelProps[]; + }, + persistInUrl?: boolean + ) => void; /** * Replaces the current right panel with a new one */ - openRightPanel: (panel: FlyoutPanelProps) => void; + openRightPanel: (panel: FlyoutPanelProps, persistInUrl?: boolean) => void; /** * Replaces the current left panel with a new one */ - openLeftPanel: (panel: FlyoutPanelProps) => void; + openLeftPanel: (panel: FlyoutPanelProps, persistInUrl?: boolean) => void; /** * Add a new preview panel to the list of current preview panels */ - openPreviewPanel: (panel: FlyoutPanelProps) => void; + openPreviewPanel: (panel: FlyoutPanelProps, persistInUrl?: boolean) => void; /** * Closes right panel */ @@ -60,7 +67,7 @@ export interface ExpandableFlyoutContext { /** * Go back to previous preview panel */ - previousPreviewPanel: () => void; + previousPreviewPanel: (persistInUrl?: boolean) => void; /** * Close all panels and closes flyout */ @@ -71,102 +78,123 @@ export const ExpandableFlyoutContext = createContext & { - 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) => { +export const ExpandableFlyoutProvider: FC> = ({ children }) => { + const [urlState, setUrlState] = useState(true); 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 { rightPanelState, setRightPanelState } = useRightPanel(); + const { leftPanelState, setLeftPanelState } = useLeftPanel(); + const { previewPanelState, setPreviewPanelState } = usePreviewPanel(); const openPanels = useCallback( - ({ - right, - left, - preview, - }: { - right?: FlyoutPanelProps; - left?: FlyoutPanelProps; - preview?: FlyoutPanelProps; - }) => dispatch({ type: ActionType.openFlyout, payload: { left, right, preview } }), - [dispatch] + ( + { + right, + left, + preview, + }: { + right?: FlyoutPanelProps; + left?: FlyoutPanelProps; + preview?: FlyoutPanelProps[]; + }, + persistInUrl: boolean = true + ) => { + setUrlState(persistInUrl); + console.log('persistInUrl', persistInUrl); + if (persistInUrl) { + console.log('url right', right); + setRightPanelState(right); + setLeftPanelState(left); + setPreviewPanelState(preview); + } else { + console.log('reducer right', right); + dispatch({ type: ActionType.openFlyout, payload: { left, right, preview } }); + } + }, + [dispatch, setRightPanelState, setLeftPanelState, setPreviewPanelState] ); const openRightPanel = useCallback( - (panel: FlyoutPanelProps) => dispatch({ type: ActionType.openRightPanel, payload: panel }), - [] + (panel: FlyoutPanelProps, persistInUrl: boolean = true) => { + setUrlState(persistInUrl); + if (persistInUrl) { + setRightPanelState(panel); + } else { + dispatch({ type: ActionType.openRightPanel, payload: panel }); + } + }, + [setRightPanelState] ); const openLeftPanel = useCallback( - (panel: FlyoutPanelProps) => dispatch({ type: ActionType.openLeftPanel, payload: panel }), - [] + (panel: FlyoutPanelProps, persistInUrl: boolean = true) => { + setUrlState(persistInUrl); + if (persistInUrl) { + setLeftPanelState(panel); + } else { + dispatch({ type: ActionType.openPreviewPanel, payload: panel }); + } + }, + [setLeftPanelState] ); const openPreviewPanel = useCallback( - (panel: FlyoutPanelProps) => dispatch({ type: ActionType.openPreviewPanel, payload: panel }), - [] + (panel: FlyoutPanelProps, persistInUrl: boolean = true) => { + setUrlState(persistInUrl); + if (persistInUrl) { + setPreviewPanelState([...(previewPanelState ?? []), panel]); + } else { + dispatch({ type: ActionType.openPreviewPanel, payload: panel }); + } + }, + [previewPanelState, setPreviewPanelState] ); - const closeRightPanel = useCallback(() => dispatch({ type: ActionType.closeRightPanel }), []); + const closeRightPanel = useCallback(() => { + setRightPanelState(undefined); + dispatch({ type: ActionType.closeRightPanel }); + }, [setRightPanelState]); - const closeLeftPanel = useCallback(() => dispatch({ type: ActionType.closeLeftPanel }), []); + const closeLeftPanel = useCallback(() => { + setLeftPanelState(undefined); + dispatch({ type: ActionType.closeLeftPanel }); + }, [setLeftPanelState]); - const closePreviewPanel = useCallback(() => dispatch({ type: ActionType.closePreviewPanel }), []); + const closePreviewPanel = useCallback(() => { + setPreviewPanelState(undefined); + dispatch({ type: ActionType.closePreviewPanel }); + }, [setPreviewPanelState]); const previousPreviewPanel = useCallback( - () => dispatch({ type: ActionType.previousPreviewPanel }), - [] + (persistInUrl: boolean = true) => { + setUrlState(persistInUrl); + if (persistInUrl) { + setPreviewPanelState(previewPanelState?.slice(0, previewPanelState.length - 1)); + } else { + dispatch({ type: ActionType.previousPreviewPanel }); + } + }, + [previewPanelState, setPreviewPanelState] ); const closePanels = useCallback(() => { + setRightPanelState(undefined); + setLeftPanelState(undefined); + setPreviewPanelState(undefined); dispatch({ type: ActionType.closeFlyout }); - onClosePanels(); - }, [onClosePanels]); - - useImperativeHandle( - ref, - () => { - return { - openFlyout: openPanels, - getState: () => state, - }; - }, - [openPanels, state] - ); + }, [setRightPanelState, setLeftPanelState, setPreviewPanelState]); const contextValue = useMemo( () => ({ - panels: state, + panels: urlState + ? { + right: rightPanelState, + left: leftPanelState, + preview: previewPanelState, + } + : state, openFlyout: openPanels, openRightPanel, openLeftPanel, @@ -178,6 +206,10 @@ export const ExpandableFlyoutProvider = React.forwardRef< previousPreviewPanel, }), [ + urlState, + rightPanelState, + leftPanelState, + previewPanelState, state, openPanels, openRightPanel, @@ -196,7 +228,7 @@ export const ExpandableFlyoutProvider = React.forwardRef< {children} ); -}); +}; /** * Retrieve context's properties 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..0f2f38076dfd0 --- /dev/null +++ b/packages/kbn-expandable-flyout/src/hooks/use_left_panel.ts @@ -0,0 +1,24 @@ +/* + * 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, but with a namespace to avoid collisions with other values in the URL. + * @returns + */ +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..ff1e56695ffa2 --- /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, but with a namespace to avoid collisions with other values in the URL. + */ +export const usePreviewPanel = () => { + const [previewPanelState, setPreviewPanelState] = useUrlState( + EXPANDABLE_FLYOUT_URL_KEY, + 'preview' + ); + + return { previewPanelState, setPreviewPanelState } 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..aa8375316234a --- /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, but with a namespace to avoid collisions with other values 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.tsx b/packages/kbn-expandable-flyout/src/index.tsx index 17613be6859b7..524c8a1696f65 100644 --- a/packages/kbn-expandable-flyout/src/index.tsx +++ b/packages/kbn-expandable-flyout/src/index.tsx @@ -69,7 +69,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,7 +86,7 @@ export const ExpandableFlyout: React.FC = ({ showPreview, }); - const hideFlyout = !left && !right && !preview.length; + const hideFlyout = !left && !right && !preview?.length; if (hideFlyout) { return null; } 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..70825b75a4413 100644 --- a/packages/kbn-url-state/README.md +++ b/packages/kbn-url-state/README.md @@ -2,44 +2,34 @@ This package provides: -- 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. +- 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. -## 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. ### 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..67a1444fb1bd8 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,26 @@ 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' }; - - const { result } = renderHook(() => useSyncToUrl(key, jest.fn())); - - act(() => { - result.current(valueToSerialize); - }); - - expect(window.history.replaceState).toHaveBeenCalledWith( - { path: expect.any(String) }, - '', - '/?testKey=%28test%3Avalue%29' - ); - }); - - it('should should not alter the location hash', () => { - const key = 'testKey'; - const valueToSerialize = { test: 'value' }; - window.location.hash = '#should_be_there'; - - const { result } = renderHook(() => useSyncToUrl(key, jest.fn())); - - act(() => { - result.current(valueToSerialize); - }); - - expect(window.history.replaceState).toHaveBeenCalledWith( - { path: expect.any(String) }, - '', - '/#should_be_there?testKey=%28test%3Avalue%29' - ); - }); - - 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())); - - act(() => { - unmount(); - }); - - expect(window.history.replaceState).toHaveBeenCalledWith( - { path: expect.any(String) }, - '', - expect.any(String) - ); - }); - - it('should clear the value from the query string when history back or forward is pressed', () => { - const key = 'testKey'; - const restore = jest.fn(); - - // Location should have a key to clear - window.location.search = `?${key}=${encode({ test: 'value' })}`; - - renderHook(() => useSyncToUrl(key, restore, true)); + it('should update the URL when the state changes', () => { + const { result } = renderHook(() => useUrlState('namespace', 'test')); act(() => { - window.dispatchEvent(new Event('popstate')); + result.current[1]('foo'); + jest.runAllTimers(); }); - expect(window.history.replaceState).toHaveBeenCalledTimes(1); - expect(window.history.replaceState).toHaveBeenCalledWith({ path: expect.any(String) }, '', '/'); + expect(window.history.pushState).toHaveBeenCalledWith({}, '', '?namespace=(test:foo)'); }); }); diff --git a/packages/kbn-url-state/index.ts b/packages/kbn-url-state/index.ts index 73568222fb4c0..3c5599f332215 100644 --- a/packages/kbn-url-state/index.ts +++ b/packages/kbn-url-state/index.ts @@ -6,4 +6,109 @@ * 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 newUrl = `?${stringify(searchParams, { encode: false })}`; + 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/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index b080981713dec..741eba0a057f6 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -70,8 +70,14 @@ export const allowedExperimentalValues = Object.freeze({ * Enables top charts on Alerts Page */ alertsPageChartsEnabled: true, + /** + * Enables the alert type column in KPI visualizations on Alerts Page + */ alertTypeEnabled: false, - + /** + * Enables expandable flyout in create rule page, alert preview + */ + expandableFlyoutInCreateRuleEnabled: false, /* * Enables new Set of filters on the Alerts page. * diff --git a/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.tsx b/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.tsx index 80e3e0f9f5641..d5b40da469f85 100644 --- a/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.tsx @@ -24,6 +24,7 @@ import { getMappedNonEcsValue } from '../../../../timelines/components/timeline/ import type { TimelineItem, TimelineNonEcsData } from '../../../../../common/search_strategy'; import type { ColumnHeaderOptions, OnRowSelected } from '../../../../../common/types/timeline'; import { TimelineId } from '../../../../../common/types'; +import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental_features'; type Props = EuiDataGridCellValueElementProps & { columnHeaders: ColumnHeaderOptions[]; @@ -73,6 +74,9 @@ const RowActionComponent = ({ const dispatch = useDispatch(); const [isSecurityFlyoutEnabled] = useUiSetting$(ENABLE_EXPANDABLE_FLYOUT_SETTING); + const isExpandableFlyoutInCreateRuleEnabled = useIsExperimentalFeatureEnabled( + 'expandableFlyoutInCreateRuleEnabled' + ); const columnValues = useMemo( () => @@ -89,6 +93,13 @@ const RowActionComponent = ({ [columnHeaders, timelineNonEcsData] ); + let showExpandableFlyout: boolean; + if (tableId === TableId.rulePreview) { + showExpandableFlyout = isSecurityFlyoutEnabled && isExpandableFlyoutInCreateRuleEnabled; + } else { + showExpandableFlyout = isSecurityFlyoutEnabled; + } + const handleOnEventDetailPanelOpened = useCallback(() => { const updatedExpandedDetail: ExpandedDetailType = { panelView: 'eventDetail', @@ -98,19 +109,20 @@ const RowActionComponent = ({ }, }; - // TODO remove when https://github.com/elastic/security-team/issues/7760 is merged - // excluding rule preview page as some sections in new flyout are not applicable when user is creating a new rule - if (isSecurityFlyoutEnabled && tableId !== TableId.rulePreview) { - openFlyout({ - right: { - id: DocumentDetailsRightPanelKey, - params: { - id: eventId, - indexName, - scopeId: tableId, + if (showExpandableFlyout) { + openFlyout( + { + right: { + id: DocumentDetailsRightPanelKey, + params: { + id: eventId, + indexName, + scopeId: tableId, + }, }, }, - }); + tableId !== TableId.rulePreview + ); } // TODO remove when https://github.com/elastic/security-team/issues/7462 is merged // support of old flyout in cases page @@ -133,7 +145,7 @@ const RowActionComponent = ({ }) ); } - }, [dispatch, eventId, indexName, isSecurityFlyoutEnabled, openFlyout, tabType, tableId]); + }, [dispatch, eventId, indexName, openFlyout, tabType, tableId, showExpandableFlyout]); const Action = controlColumn.rowCellRender; 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/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/common/utils/timeline/use_show_timeline.test.tsx b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx index 30f9483169b13..07c87aec59da6 100644 --- a/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx @@ -83,7 +83,7 @@ describe('use show timeline', () => { }); it('hides timeline for blacklist routes', async () => { - mockUseLocation.mockReturnValueOnce({ pathname: '/rules/create' }); + mockUseLocation.mockReturnValueOnce({ pathname: '/rules/add_rules' }); await act(async () => { const { result, waitForNextUpdate } = renderHook(() => useShowTimeline()); await waitForNextUpdate(); 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/components/investigation_guide.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide.test.tsx index 95b4e0a600594..7ba849e1fec1b 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide.test.tsx @@ -18,6 +18,7 @@ jest.mock('../../shared/hooks/use_investigation_guide'); const NO_DATA_TEXT = "There's no investigation guide for this rule. Edit the rule's settingsExternal link(opens in a new tab or window) to add one."; +const PREVIEW_MESSAGE = 'Investigation guide is not available in alert preview.'; const renderInvestigationGuide = (context: LeftPanelContext = mockContextValue) => ( @@ -76,4 +77,15 @@ describe('', () => { const { getByTestId } = render(renderInvestigationGuide()); expect(getByTestId(INVESTIGATION_GUIDE_TEST_ID)).toHaveTextContent(NO_DATA_TEXT); }); + + it('should render preview message when flyout is in preview', () => { + (useInvestigationGuide as jest.Mock).mockReturnValue({ + loading: false, + error: true, + }); + const { getByTestId } = render( + renderInvestigationGuide({ ...mockContextValue, isPreview: true }) + ); + expect(getByTestId(INVESTIGATION_GUIDE_TEST_ID)).toHaveTextContent(PREVIEW_MESSAGE); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide.tsx index bffe966b944b2..d061cbb25c4c8 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide.tsx @@ -18,7 +18,7 @@ import { FlyoutLoading } from '../../../shared/components/flyout_loading'; * Renders a message saying the guide hasn't been set up or the full investigation guide. */ export const InvestigationGuide: React.FC = () => { - const { dataFormattedForFieldBrowser } = useLeftPanelContext(); + const { dataFormattedForFieldBrowser, isPreview } = useLeftPanelContext(); const { loading, error, basicAlertData, ruleNote } = useInvestigationGuide({ dataFormattedForFieldBrowser, @@ -26,7 +26,12 @@ export const InvestigationGuide: React.FC = () => { return (
- {loading ? ( + {isPreview ? ( + + ) : loading ? ( ) : !error && basicAlertData.ruleId && ruleNote ? ( { const NO_DATA_MESSAGE = "There are no response actions defined for this event. To add some, edit the rule's settings and set up response actionsExternal link(opens in a new tab or window)."; +const PREVIEW_MESSAGE = 'Response is not available in alert preview.'; const defaultContextValue = { dataAsNestedObject: { @@ -139,4 +140,10 @@ describe('', () => { expect(wrapper.getByTestId(RESPONSE_DETAILS_TEST_ID)).toHaveTextContent(NO_DATA_MESSAGE); }); + + it('should render preview message if flyout is in preview', () => { + const wrapper = renderResponseDetails({ ...defaultContextValue, isPreview: true }); + expect(wrapper.getByTestId(RESPONSE_DETAILS_TEST_ID)).toBeInTheDocument(); + expect(wrapper.getByTestId(RESPONSE_DETAILS_TEST_ID)).toHaveTextContent(PREVIEW_MESSAGE); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/response_details.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/response_details.tsx index 9e2ab547e9af6..8caaad7225057 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/response_details.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/response_details.tsx @@ -24,7 +24,7 @@ const ExtendedFlyoutWrapper = styled.div` * Automated response actions results, displayed in the document details expandable flyout left section under the Insights tab, Response tab */ export const ResponseDetails: React.FC = () => { - const { searchHit, dataAsNestedObject } = useLeftPanelContext(); + const { searchHit, dataAsNestedObject, isPreview } = useLeftPanelContext(); const endpointResponseActionsEnabled = useIsExperimentalFeatureEnabled( 'endpointResponseActionsEnabled' ); @@ -40,19 +40,28 @@ export const ResponseDetails: React.FC = () => { return (
- -
- -
-
- + {isPreview ? ( + + ) : ( + <> + +
+ +
+
+ - - {endpointResponseActionsEnabled ? responseActionsView?.content : osqueryView?.content} - + + {endpointResponseActionsEnabled ? responseActionsView?.content : osqueryView?.content} + + + )}
); }; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/context.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/context.tsx index 6dd0f65af4922..52bf509462699 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/context.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/context.tsx @@ -8,6 +8,7 @@ import type { BrowserFields, TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; import React, { createContext, memo, useContext, useMemo } from 'react'; import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; +import { TableId } from '@kbn/securitysolution-data-table'; import { useEventDetails } from '../shared/hooks/use_event_details'; import { FlyoutError } from '../../shared/components/flyout_error'; import { FlyoutLoading } from '../../shared/components/flyout_loading'; @@ -54,6 +55,10 @@ export interface LeftPanelContext { * Retrieves searchHit values for the provided field */ getFieldsData: GetFieldsData; + /** + * Boolean to indicate whether it is a preview flyout + */ + isPreview: boolean; } export const LeftPanelContext = createContext(undefined); @@ -97,6 +102,7 @@ export const LeftPanelProvider = memo( searchHit, investigationFields: maybeRule?.investigation_fields?.field_names ?? [], getFieldsData, + isPreview: scopeId === TableId.rulePreview, } : undefined, [ diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/mocks/mock_context.ts b/x-pack/plugins/security_solution/public/flyout/document_details/left/mocks/mock_context.ts index 4233a28c1164e..4892d7d4dfa08 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/mocks/mock_context.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/mocks/mock_context.ts @@ -25,4 +25,5 @@ export const mockContextValue: LeftPanelContext = { searchHit: mockSearchHit, dataAsNestedObject: mockDataAsNestedObject, investigationFields: [], + isPreview: false, }; 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..a36bcacca8b42 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,7 +5,7 @@ * 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'; @@ -78,13 +78,10 @@ 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 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 ( <> +const renderAnalyzerPreview = (context = panelContextValue) => render( - + @@ -117,7 +117,7 @@ describe('AnalyzerPreviewContainer', () => { ).toHaveTextContent(NO_ANALYZER_MESSAGE); }); - it('should navigate to left section Visualize tab when clicking on title', () => { + it('should navigate to analyzer in timeline when clicking on title', () => { (isInvestigateInResolverActionEnabled as jest.Mock).mockReturnValue(true); (useAlertPrevalenceFromProcessTree as jest.Mock).mockReturnValue({ loading: false, @@ -136,4 +136,24 @@ describe('AnalyzerPreviewContainer', () => { getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(ANALYZER_PREVIEW_TEST_ID)).click(); expect(investigateInTimelineAlertClick).toHaveBeenCalled(); }); + + it('should not navigate to analyzer when in preview and clicking on title', () => { + (isInvestigateInResolverActionEnabled as jest.Mock).mockReturnValue(true); + (useAlertPrevalenceFromProcessTree as jest.Mock).mockReturnValue({ + loading: false, + error: false, + alertIds: ['alertid'], + statsNodes: mock.mockStatsNodes, + }); + (useInvestigateInTimeline as jest.Mock).mockReturnValue({ + investigateInTimelineAlertClick: jest.fn(), + }); + + const { queryByTestId } = renderAnalyzerPreview({ ...panelContextValue, isPreview: true }); + expect( + queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(ANALYZER_PREVIEW_TEST_ID)) + ).not.toBeInTheDocument(); + const { investigateInTimelineAlertClick } = useInvestigateInTimeline({}); + expect(investigateInTimelineAlertClick).not.toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview_container.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview_container.tsx index ac8e21d3fde06..843abeb7018e6 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview_container.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview_container.tsx @@ -27,7 +27,7 @@ const timelineId = 'timeline-1'; * Analyzer preview under Overview, Visualizations. It shows a tree representation of analyzer. */ export const AnalyzerPreviewContainer: React.FC = () => { - const { dataAsNestedObject } = useRightPanelContext(); + const { dataAsNestedObject, isPreview } = useRightPanelContext(); // decide whether to show the analyzer preview or not const isEnabled = isInvestigateInResolverActionEnabled(dataAsNestedObject); @@ -64,17 +64,18 @@ export const AnalyzerPreviewContainer: React.FC = () => { /> ), iconType: 'timeline', - ...(isEnabled && { - link: { - callback: goToAnalyzerTab, - tooltip: ( - - ), - }, - }), + ...(isEnabled && + !isPreview && { + link: { + callback: goToAnalyzerTab, + tooltip: ( + + ), + }, + }), }} data-test-subj={ANALYZER_PREVIEW_TEST_ID} > 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 7b99e426c8abc..5f4a1ef3283eb 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 @@ -111,6 +111,15 @@ describe('', () => { expect(getByTestId(RULE_SUMMARY_BUTTON_TEST_ID)).toHaveAttribute('disabled'); }); + it('should render rule preview button as disabled if flyout is in preview', () => { + const { getByTestId } = renderDescription({ + ...panelContextValue([{ ...ruleUuid, values: [] }, ruleName, ruleDescription]), + isPreview: true, + }); + expect(getByTestId(RULE_SUMMARY_BUTTON_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(RULE_SUMMARY_BUTTON_TEST_ID)).toHaveAttribute('disabled'); + }); + it('should open preview panel when clicking on button', () => { const panelContext = panelContextValue([ruleUuid, ruleDescription, ruleName]); 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 5c65d9231eef0..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 @@ -31,7 +31,8 @@ import { * If the document is an alert we show the rule description. If the document is of another type, we show -. */ export const Description: FC = () => { - const { dataFormattedForFieldBrowser, scopeId, eventId, indexName } = useRightPanelContext(); + const { dataFormattedForFieldBrowser, scopeId, eventId, indexName, isPreview } = + useRightPanelContext(); const { isAlert, ruleDescription, ruleName, ruleId } = useBasicDataFromDetailsData( dataFormattedForFieldBrowser ); @@ -46,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', @@ -75,7 +74,7 @@ export const Description: FC = () => { defaultMessage: 'Show rule summary', } )} - disabled={isEmpty(ruleName) || isEmpty(ruleId)} + disabled={isEmpty(ruleName) || isEmpty(ruleId) || isPreview} > { ), - [ruleName, openRulePreview, ruleId] + [ruleName, openRulePreview, ruleId, isPreview] ); const alertRuleDescription = 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..49ef8891f1020 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 @@ -17,7 +17,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'); @@ -58,7 +58,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 +73,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 f192a84211a47..f13777dd63582 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 @@ -11,8 +11,8 @@ import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context'; import { RightPanelContext } from '../context'; import { RISK_SCORE_VALUE_TEST_ID, - SEVERITY_VALUE_TEST_ID, FLYOUT_HEADER_TITLE_TEST_ID, + STATUS_BUTTON_TEST_ID, } from './test_ids'; import { HeaderTitle } from './header_title'; import moment from 'moment-timezone'; @@ -55,7 +55,8 @@ describe('', () => { expect(getByTestId(FLYOUT_HEADER_TITLE_TEST_ID)).toBeInTheDocument(); expect(getByTestId(RISK_SCORE_VALUE_TEST_ID)).toBeInTheDocument(); - expect(getByTestId(SEVERITY_VALUE_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(SEVERITY_TITLE_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(STATUS_BUTTON_TEST_ID)).toBeInTheDocument(); }); it('should render rule name in the title if document is an alert', () => { @@ -82,4 +83,32 @@ describe('', () => { expect(getByTestId(FLYOUT_HEADER_TITLE_TEST_ID)).toHaveTextContent('Event details'); }); + + it('should not render document status if document is not an alert', () => { + const contextValue = { + ...mockContextValue, + dataFormattedForFieldBrowser: [ + { + category: 'kibana', + field: 'kibana.alert.rule.name', + values: [], + originalValue: [], + isObjectArray: false, + }, + ], + } as unknown as RightPanelContext; + + const { queryByTestId } = renderHeader(contextValue); + expect(queryByTestId(STATUS_BUTTON_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should not render document status if flyout is open in preview', () => { + const contextValue = { + ...mockContextValue, + isPreview: true, + } as unknown as RightPanelContext; + + const { queryByTestId } = renderHeader(contextValue); + expect(queryByTestId(STATUS_BUTTON_TEST_ID)).not.toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/header_title.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/header_title.tsx index ac52136e3afae..c8dfa6e847412 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/header_title.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/header_title.tsx @@ -25,33 +25,40 @@ import { FlyoutTitle } from '../../../shared/components/flyout_title'; * Document details flyout right section header */ export const HeaderTitle: FC = memo(() => { - const { dataFormattedForFieldBrowser, eventId, scopeId } = useRightPanelContext(); + const { dataFormattedForFieldBrowser, eventId, scopeId, isPreview } = useRightPanelContext(); const { isAlert, ruleName, timestamp, ruleId } = useBasicDataFromDetailsData( dataFormattedForFieldBrowser ); const ruleTitle = useMemo( - () => ( - + () => + isPreview ? ( - - ), - [ruleName, ruleId, eventId, scopeId] + ) : ( + + + + ), + [ruleName, ruleId, eventId, scopeId, isPreview] ); const eventTitle = ( @@ -76,9 +83,11 @@ export const HeaderTitle: FC = memo(() => {
- - - + {isAlert && !isPreview && ( + + + + )} diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields.tsx index 717cf9856651e..806a43f9f5497 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields.tsx @@ -15,6 +15,7 @@ import { convertHighlightedFieldsToTableRow } from '../../shared/utils/highlight import { useRuleWithFallback } from '../../../../detection_engine/rule_management/logic/use_rule_with_fallback'; import { useBasicDataFromDetailsData } from '../../../../timelines/components/side_panel/event_details/helpers'; import { HighlightedFieldsCell } from './highlighted_fields_cell'; +import { SecurityCellActionType } from '../../../../actions/constants'; import { CellActionsMode, SecurityCellActions, @@ -42,6 +43,10 @@ export interface HighlightedFieldsTableRow { * Maintain backwards compatibility // TODO remove when possible */ scopeId: string; + /** + * Boolean to indicate this field is shown in a preview + */ + isPreview: boolean; }; } @@ -71,6 +76,7 @@ const columns: Array> = [ field: string; values: string[] | null | undefined; scopeId: string; + isPreview: boolean; }) => ( > = [ visibleCellActions={6} sourcererScopeId={getSourcererScopeId(description.scopeId)} metadata={{ scopeId: description.scopeId }} + disabledActionTypes={ + description.isPreview + ? [SecurityCellActionType.FILTER, SecurityCellActionType.TOGGLE_COLUMN] + : [] + } > @@ -93,7 +104,7 @@ const columns: Array> = [ * Component that displays the highlighted fields in the right panel under the Investigation section. */ export const HighlightedFields: FC = () => { - const { dataFormattedForFieldBrowser, scopeId } = useRightPanelContext(); + const { dataFormattedForFieldBrowser, scopeId, isPreview } = useRightPanelContext(); const { ruleId } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser); const { loading, rule: maybeRule } = useRuleWithFallback(ruleId); @@ -102,8 +113,8 @@ export const HighlightedFields: FC = () => { investigationFields: maybeRule?.investigation_fields?.field_names ?? [], }); const items = useMemo( - () => convertHighlightedFieldsToTableRow(highlightedFields, scopeId), - [highlightedFields, scopeId] + () => convertHighlightedFieldsToTableRow(highlightedFields, scopeId, isPreview), + [highlightedFields, scopeId, isPreview] ); return ( diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/investigation_guide.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/investigation_guide.test.tsx index f774fe67e179b..d2444248d4202 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/investigation_guide.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/investigation_guide.test.tsx @@ -24,6 +24,7 @@ import { LeftPanelInvestigationTab, DocumentDetailsLeftPanelKey } from '../../le jest.mock('../../shared/hooks/use_investigation_guide'); const NO_DATA_MESSAGE = 'Investigation guideThere’s no investigation guide for this rule.'; +const PREVIEW_MESSAGE = 'Investigation guide is not available in alert preview.'; const renderInvestigationGuide = () => render( @@ -97,6 +98,21 @@ describe('', () => { expect(getByTestId(INVESTIGATION_GUIDE_TEST_ID)).toHaveTextContent(NO_DATA_MESSAGE); }); + it('should render preview message when flyout is in preview', () => { + const { queryByTestId, getByTestId } = render( + + + + + + + + ); + + expect(queryByTestId(INVESTIGATION_GUIDE_BUTTON_TEST_ID)).not.toBeInTheDocument(); + expect(getByTestId(INVESTIGATION_GUIDE_TEST_ID)).toHaveTextContent(PREVIEW_MESSAGE); + }); + it('should navigate to investigation guide when clicking on button', () => { (useInvestigationGuide as jest.Mock).mockReturnValue({ loading: false, diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/investigation_guide.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/investigation_guide.tsx index 04c73baad9d78..427a9987de870 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/investigation_guide.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/investigation_guide.tsx @@ -24,7 +24,8 @@ import { */ export const InvestigationGuide: React.FC = () => { const { openLeftPanel } = useExpandableFlyoutContext(); - const { eventId, indexName, scopeId, dataFormattedForFieldBrowser } = useRightPanelContext(); + const { eventId, indexName, scopeId, dataFormattedForFieldBrowser, isPreview } = + useRightPanelContext(); const { loading, error, basicAlertData, ruleNote } = useInvestigationGuide({ dataFormattedForFieldBrowser, @@ -56,7 +57,12 @@ export const InvestigationGuide: React.FC = () => { - {loading ? ( + {isPreview ? ( + + ) : loading ? ( { 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 ebff6c0e704fe..2a2609b4749e0 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 @@ -13,6 +13,8 @@ import { RightPanelContext } from '../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 panelContextValue = {} as unknown as RightPanelContext; @@ -47,4 +49,18 @@ describe('', () => { getByTestId(RESPONSE_SECTION_HEADER_TEST_ID).click(); expect(getByTestId(RESPONSE_SECTION_CONTENT_TEST_ID)).toBeInTheDocument(); }); + + it('should render preview message if flyout is in preview', () => { + const { getByTestId } = render( + + + + + + + + ); + getByTestId(RESPONSE_SECTION_HEADER_TEST_ID).click(); + expect(getByTestId(RESPONSE_SECTION_CONTENT_TEST_ID)).toHaveTextContent(PREVIEW_MESSAGE); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/response_section.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/response_section.tsx index 96a0b070020e2..6b7ca6e282246 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/response_section.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/response_section.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { ResponseButton } from './response_button'; import { ExpandableSection } from './expandable_section'; +import { useRightPanelContext } from '../context'; import { RESPONSE_SECTION_TEST_ID } from './test_ids'; export interface ResponseSectionProps { /** @@ -22,6 +23,7 @@ export interface ResponseSectionProps { * Most bottom section of the overview tab. It contains a summary of the response tab. */ export const ResponseSection: VFC = ({ expanded = false }) => { + const { isPreview } = useRightPanelContext(); return ( = ({ expanded = false }) } data-test-subj={RESPONSE_SECTION_TEST_ID} > - + {isPreview ? ( + + ) : ( + + )} ); }; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview.tsx index 63f07cb7ab1f3..08c15480bb7ed 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview.tsx @@ -37,7 +37,7 @@ const ValueContainer: FC<{ text?: ReactElement }> = ({ text, children }) => ( * Renders session preview under Visualizations section in the flyout right EuiPanel */ export const SessionPreview: FC = () => { - const { eventId, scopeId } = useRightPanelContext(); + const { eventId, scopeId, isPreview } = useRightPanelContext(); const { processName, userName, startAt, ruleName, ruleId, workdir, command } = useProcessData(); const { euiTheme } = useEuiTheme(); @@ -100,13 +100,13 @@ export const SessionPreview: FC = () => { fieldType={'string'} isAggregatable={false} isDraggable={false} - linkValue={ruleId} + linkValue={!isPreview ? ruleId : null} value={ruleName} /> ) ); - }, [ruleName, ruleId, scopeId, eventId]); + }, [ruleName, ruleId, scopeId, eventId, isPreview]); const commandFragment = useMemo(() => { return ( diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.test.tsx index cfd5bcc525700..80d9c81a064e8 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.test.tsx @@ -40,10 +40,10 @@ const sessionViewConfig = { sessionStartTime: 'sessionStartTime', }; -const renderSessionPreview = () => +const renderSessionPreview = (context = panelContextValue) => render( - + @@ -121,4 +121,31 @@ describe('SessionPreviewContainer', () => { ).not.toHaveTextContent(NO_DATA_MESSAGE); expect(queryByTestId(SESSION_PREVIEW_TEST_ID)).not.toBeInTheDocument(); }); + + it('should not render link to session viewer if flyout is open in preview', () => { + (useSessionPreview as jest.Mock).mockReturnValue(sessionViewConfig); + (useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => true }); + + const { getByTestId, queryByTestId } = renderSessionPreview({ + ...panelContextValue, + isPreview: true, + }); + + expect(getByTestId(SESSION_PREVIEW_TEST_ID)).toBeInTheDocument(); + expect( + queryByTestId(EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID(SESSION_PREVIEW_TEST_ID)) + ).not.toBeInTheDocument(); + expect( + getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID(SESSION_PREVIEW_TEST_ID)) + ).toBeInTheDocument(); + expect( + getByTestId(EXPANDABLE_PANEL_CONTENT_TEST_ID(SESSION_PREVIEW_TEST_ID)) + ).toBeInTheDocument(); + expect( + getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID(SESSION_PREVIEW_TEST_ID)) + ).toBeInTheDocument(); + expect( + queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(SESSION_PREVIEW_TEST_ID)) + ).not.toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.tsx index 10250e74c383c..d0214b725a44e 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/session_preview_container.tsx @@ -29,7 +29,7 @@ const timelineId = 'timeline-1'; * Checks if the SessionView component is available, if so render it or else render an error message */ export const SessionPreviewContainer: FC = () => { - const { dataAsNestedObject, getFieldsData } = useRightPanelContext(); + const { dataAsNestedObject, getFieldsData, isPreview } = useRightPanelContext(); // decide whether to show the session view or not const sessionViewConfig = useSessionPreview({ getFieldsData }); @@ -122,17 +122,18 @@ export const SessionPreviewContainer: FC = () => { /> ), iconType: 'timeline', - ...(isEnabled && { - link: { - callback: goToSessionViewTab, - tooltip: ( - - ), - }, - }), + ...(isEnabled && + !isPreview && { + link: { + callback: goToSessionViewTab, + tooltip: ( + + ), + }, + }), }} data-test-subj={SESSION_PREVIEW_TEST_ID} > diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/context.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/context.tsx index b46645aaf883c..7311a030b2175 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/context.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/context.tsx @@ -8,6 +8,7 @@ import type { BrowserFields, TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; import React, { createContext, memo, useContext, useMemo } from 'react'; import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; +import { TableId } from '@kbn/securitysolution-data-table'; import { useEventDetails } from '../shared/hooks/use_event_details'; import { FlyoutError } from '../../shared/components/flyout_error'; @@ -59,6 +60,10 @@ export interface RightPanelContext { * Retrieves searchHit values for the provided field */ getFieldsData: GetFieldsData; + /** + * Boolean to indicate whether it is a preview flyout + */ + isPreview: boolean; } export const RightPanelContext = createContext(undefined); @@ -104,6 +109,7 @@ export const RightPanelProvider = memo( investigationFields: maybeRule?.investigation_fields?.field_names ?? [], refetchFlyoutData, getFieldsData, + isPreview: scopeId === TableId.rulePreview, } : undefined, [ diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/footer.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/footer.tsx index 49ad491efa4d1..995beea63a70f 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/footer.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/footer.tsx @@ -15,10 +15,17 @@ import { useHostIsolationTools } from '../../../timelines/components/side_panel/ import { DEFAULT_DARK_MODE } from '../../../../common/constants'; import { useUiSetting } from '../../../common/lib/kibana'; +interface PanelFooterProps { + /** + * Boolean that indicates whether flyout is in preview and action should be hidden + */ + isPreview: boolean; +} + /** * */ -export const PanelFooter: FC = () => { +export const PanelFooter: FC = ({ isPreview }) => { const { closeFlyout, openRightPanel } = useExpandableFlyoutContext(); const { eventId, @@ -48,7 +55,7 @@ export const PanelFooter: FC = () => { [eventId, indexName, openRightPanel, scopeId, showHostIsolationPanel] ); - return ( + return !isPreview ? ( { refetchFlyoutData={refetchFlyoutData} /> - ); + ) : null; }; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/index.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/index.tsx index 39abe1f818a96..08142a0ef08ba 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/index.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/index.tsx @@ -37,7 +37,7 @@ export interface RightPanelProps extends FlyoutPanelProps { */ export const RightPanel: FC> = memo(({ path }) => { const { openRightPanel } = useExpandableFlyoutContext(); - const { eventId, getFieldsData, indexName, scopeId } = useRightPanelContext(); + const { eventId, getFieldsData, indexName, scopeId, isPreview } = useRightPanelContext(); // for 8.10, we only render the flyout in its expandable mode if the document viewed is of type signal const documentIsSignal = getField(getFieldsData('event.kind')) === EventKind.signal; @@ -72,7 +72,7 @@ export const RightPanel: FC> = memo(({ path }) => { setSelectedTabId={setSelectedTabId} /> - + ); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/mocks/mock_context.ts b/x-pack/plugins/security_solution/public/flyout/document_details/right/mocks/mock_context.ts index 2bb599a657591..086c272bee359 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/mocks/mock_context.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/mocks/mock_context.ts @@ -26,4 +26,5 @@ export const mockContextValue: RightPanelContext = { searchHit: mockSearchHit, investigationFields: [], refetchFlyoutData: jest.fn(), + isPreview: false, }; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/tabs/json_tab.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/tabs/json_tab.tsx index 6d329811f7228..313e8f952e1a3 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/tabs/json_tab.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/tabs/json_tab.tsx @@ -23,7 +23,7 @@ const FLYOUT_FOOTER_HEIGHT = 72; * Json view displayed in the document details expandable flyout right section */ export const JsonTab: FC = memo(() => { - const { searchHit } = useRightPanelContext(); + const { searchHit, isPreview } = useRightPanelContext(); const jsonValue = JSON.stringify(searchHit, null, 2); const flexGroupElement = useRef(null); @@ -31,19 +31,20 @@ export const JsonTab: FC = memo(() => { useEffect(() => { const topPosition = flexGroupElement?.current?.getBoundingClientRect().top || 0; + const footerOffset = isPreview ? 0 : FLYOUT_FOOTER_HEIGHT; const height = window.innerHeight - topPosition - COPY_TO_CLIPBOARD_BUTTON_HEIGHT - FLYOUT_BODY_PADDING - - FLYOUT_FOOTER_HEIGHT; + footerOffset; if (height === 0) { return; } setEditorHeight(height); - }, [setEditorHeight]); + }, [setEditorHeight, isPreview]); return ( ; - -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/utils/highlighted_fields_helpers.test.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils/highlighted_fields_helpers.test.ts index 3992966878192..1565837f90fc2 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils/highlighted_fields_helpers.test.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils/highlighted_fields_helpers.test.ts @@ -11,6 +11,7 @@ import { } from './highlighted_fields_helpers'; const scopeId = 'scopeId'; +const isPreview = false; describe('convertHighlightedFieldsToTableRow', () => { it('should convert highlighted fields to a table row', () => { @@ -19,13 +20,14 @@ describe('convertHighlightedFieldsToTableRow', () => { values: ['host-1'], }, }; - expect(convertHighlightedFieldsToTableRow(highlightedFields, scopeId)).toEqual([ + expect(convertHighlightedFieldsToTableRow(highlightedFields, scopeId, isPreview)).toEqual([ { field: 'host.name', description: { field: 'host.name', values: ['host-1'], scopeId: 'scopeId', + isPreview, }, }, ]); @@ -38,13 +40,14 @@ describe('convertHighlightedFieldsToTableRow', () => { values: ['host-1'], }, }; - expect(convertHighlightedFieldsToTableRow(highlightedFields, scopeId)).toEqual([ + expect(convertHighlightedFieldsToTableRow(highlightedFields, scopeId, isPreview)).toEqual([ { field: 'host.name-override', description: { field: 'host.name-override', values: ['host-1'], scopeId: 'scopeId', + isPreview, }, }, ]); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils/highlighted_fields_helpers.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils/highlighted_fields_helpers.ts index d41ff1b75f28a..6cf1ec9291efe 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils/highlighted_fields_helpers.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils/highlighted_fields_helpers.ts @@ -16,7 +16,8 @@ import type { HighlightedFieldsTableRow } from '../../right/components/highlight */ export const convertHighlightedFieldsToTableRow = ( highlightedFields: UseHighlightedFieldsResult, - scopeId: string + scopeId: string, + isPreview: boolean ): HighlightedFieldsTableRow[] => { const fieldNames = Object.keys(highlightedFields); return fieldNames.map((fieldName) => { @@ -30,6 +31,7 @@ export const convertHighlightedFieldsToTableRow = ( field, values, scopeId, + isPreview, }, }; }); diff --git a/x-pack/plugins/security_solution/public/flyout/index.tsx b/x-pack/plugins/security_solution/public/flyout/index.tsx index 1c8ac6c4cc2c9..d964c9afd18d3 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'; @@ -84,42 +80,20 @@ const expandableFlyoutDocumentsPanels: ExpandableFlyoutProps['registeredPanels'] }, ]; -const OuterProviders: FC = ({ children }) => { - return {children}; -}; - -const InnerProviders: FC = ({ children }) => { - const [flyoutRef, handleFlyoutChangedOrClosed] = useSecurityFlyoutUrlSync(); - - return ( - - {children} - - ); -}; - export const SecuritySolutionFlyoutContextProvider: FC = ({ children }) => ( - - {children} - + {children} ); SecuritySolutionFlyoutContextProvider.displayName = 'SecuritySolutionFlyoutContextProvider'; -export const SecuritySolutionFlyout = memo(() => { - const [_flyoutRef, handleFlyoutChangedOrClosed] = useSecurityFlyoutUrlSync(); +const handleFlyoutChangedOrClosed = () => {}; - return ( - - ); -}); +export const SecuritySolutionFlyout = memo(() => ( + +)); SecuritySolutionFlyout.displayName = 'SecuritySolutionFlyout'; diff --git a/x-pack/plugins/security_solution/public/rules/links.ts b/x-pack/plugins/security_solution/public/rules/links.ts index e50a9aa670812..5564a8b9b4e2a 100644 --- a/x-pack/plugins/security_solution/public/rules/links.ts +++ b/x-pack/plugins/security_solution/public/rules/links.ts @@ -65,7 +65,7 @@ export const links: LinkItem = { title: CREATE_NEW_RULE, path: RULES_CREATE_PATH, skipUrlState: true, - hideTimeline: true, + hideTimeline: false, }, ], }, 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 985d373950cc8..9a9eac07bb806 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 @@ -24,6 +24,7 @@ import { useDispatch, useSelector } from 'react-redux'; import styled from 'styled-components'; import { getEsQueryConfig } from '@kbn/data-plugin/common'; +import { createHistoryEntry } from '../../../../common/utils/global_query_string/helpers'; import { InputsModelId } from '../../../../common/store/inputs/constants'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { TimelineTabs, TimelineId } from '../../../../../common/types/timeline'; @@ -136,6 +137,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 a5d941ca6456e..c7e2526518bbe 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 @@ -333,10 +333,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}