From f47f83b6c235b2bb14b34e9ef8202494655679f0 Mon Sep 17 00:00:00 2001 From: Agustina Nahir Ruidiaz <61565784+agusruidiazgd@users.noreply.github.com> Date: Thu, 26 Jun 2025 19:39:38 +0200 Subject: [PATCH] [Security Solution]: on ESC key close the timeline flyout instead of the timeline modal (#224352) ## Summary Fixes: https://github.com/elastic/kibana/issues/190761 I've added a `handleKeyDown` to capture the `ESC` key press behavior. 1. First `ESC` finds any .euiFlyout and close it with`closeFlyout()` from `useExpandableFlyoutApi()`, then stops. 2. Next `ESC` (when no flyout) clicks `openToggleRef.current`, toggling the timeline modal closed. https://github.com/user-attachments/assets/0e42f9e9-2694-429d-8a5a-df86aa671809 ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: Elastic Machine --- .../shared/hooks/use_which_flyout.test.tsx | 63 ++++++++++++++++++- .../shared/hooks/use_which_flyout.ts | 40 +++++++----- .../timelines/components/modal/index.tsx | 5 +- .../public/timelines/wrapper/index.tsx | 14 ++++- 4 files changed, 104 insertions(+), 18 deletions(-) diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_which_flyout.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_which_flyout.test.tsx index 033fb53a5c02..045426ba0cf4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_which_flyout.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_which_flyout.test.tsx @@ -7,8 +7,13 @@ import type { RenderHookResult } from '@testing-library/react'; import { renderHook } from '@testing-library/react'; -import { useWhichFlyout } from './use_which_flyout'; +import { + isSecuritySolutionFlyoutOpen, + isTimelineFlyoutOpen, + useWhichFlyout, +} from './use_which_flyout'; import { Flyouts } from '../constants/flyouts'; +import { URL_PARAM_KEY } from '../../../../common/hooks/use_url_state'; describe('useWhichFlyout', () => { let hookResult: RenderHookResult; @@ -93,6 +98,62 @@ describe('useWhichFlyout', () => { }); }); + describe('isSecuritySolutionFlyoutOpen', () => { + it('returns false when no flyout param is present', () => { + const params = new URLSearchParams(); + expect(isSecuritySolutionFlyoutOpen(params)).toBe(false); + }); + + it('returns false when flyout param value is "()"', () => { + const params = new URLSearchParams(); + params.set(URL_PARAM_KEY.flyout, '()'); + expect(isSecuritySolutionFlyoutOpen(params)).toBe(false); + }); + + it('returns false when flyout param value is "(preview:!())"', () => { + const params = new URLSearchParams(); + params.set(URL_PARAM_KEY.flyout, '(preview:!())'); + expect(isSecuritySolutionFlyoutOpen(params)).toBe(false); + }); + + it('returns true when flyout param has any other non-empty value', () => { + const params = new URLSearchParams(); + params.set(URL_PARAM_KEY.flyout, '(id:123)'); + expect(isSecuritySolutionFlyoutOpen(params)).toBe(true); + + params.set(URL_PARAM_KEY.flyout, 'foo'); + expect(isSecuritySolutionFlyoutOpen(params)).toBe(true); + }); + }); + + describe('isTimelineFlyoutOpen', () => { + it('returns false when no timelineFlyout param is present', () => { + const params = new URLSearchParams(); + expect(isTimelineFlyoutOpen(params)).toBe(false); + }); + + it('returns false when timelineFlyout value is "()"', () => { + const params = new URLSearchParams(); + params.set(URL_PARAM_KEY.timelineFlyout, '()'); + expect(isTimelineFlyoutOpen(params)).toBe(false); + }); + + it('returns false when timelineFlyout value is "(preview:!())"', () => { + const params = new URLSearchParams(); + params.set(URL_PARAM_KEY.timelineFlyout, '(preview:!())'); + expect(isTimelineFlyoutOpen(params)).toBe(false); + }); + + it('returns true when timelineFlyout has any other non-empty value', () => { + const params = new URLSearchParams(); + params.set(URL_PARAM_KEY.timelineFlyout, '(preview:!(456))'); + expect(isTimelineFlyoutOpen(params)).toBe(true); + + params.set(URL_PARAM_KEY.timelineFlyout, 'bar'); + expect(isTimelineFlyoutOpen(params)).toBe(true); + }); + }); + describe('Timeline flyout open', () => { it('should return Timeline flyout if flyout and timelineFlyout are in the url', () => { Object.defineProperty(window, 'location', { diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_which_flyout.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_which_flyout.ts index a5bf69f88fcb..b90009cfcb1c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_which_flyout.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_which_flyout.ts @@ -8,6 +8,27 @@ import { Flyouts } from '../constants/flyouts'; import { URL_PARAM_KEY } from '../../../../common/hooks/use_url_state'; +/** + * Returns true if the SecuritySolution flyout is open. + */ +export const isSecuritySolutionFlyoutOpen = (query: URLSearchParams): boolean => { + const queryHasSecuritySolutionFlyout = query.has(URL_PARAM_KEY.flyout); + const securitySolutionFlyoutHasValue = + query.get(URL_PARAM_KEY.flyout) !== '()' && query.get(URL_PARAM_KEY.flyout) !== '(preview:!())'; + return queryHasSecuritySolutionFlyout && securitySolutionFlyoutHasValue; +}; + +/** + * Returns true if the Timeline flyout is open. + */ +export const isTimelineFlyoutOpen = (query: URLSearchParams): boolean => { + const queryHasTimelineFlyout = query.has(URL_PARAM_KEY.timelineFlyout); + const timelineFlyoutHasValue = + query.get(URL_PARAM_KEY.timelineFlyout) !== '()' && + query.get(URL_PARAM_KEY.timelineFlyout) !== '(preview:!())'; + return queryHasTimelineFlyout && timelineFlyoutHasValue; +}; + /** * Hook that returns which flyout is the user currently interacting with. * If the url contains timelineFlyout parameter and its value is not empty, we know the timeline flyout is rendered. @@ -16,27 +37,18 @@ import { URL_PARAM_KEY } from '../../../../common/hooks/use_url_state'; export const useWhichFlyout = (): string | null => { const query = new URLSearchParams(window.location.search); - const queryHasSecuritySolutionFlyout = query.has(URL_PARAM_KEY.flyout); - const securitySolutionFlyoutHasValue = - query.get(URL_PARAM_KEY.flyout) !== '()' && query.get(URL_PARAM_KEY.flyout) !== '(preview:!())'; - const isSecuritySolutionFlyoutOpen = - queryHasSecuritySolutionFlyout && securitySolutionFlyoutHasValue; + const securitySolutionFlyoutOpen = isSecuritySolutionFlyoutOpen(query); + const timelineFlyoutOpen = isTimelineFlyoutOpen(query); - const queryHasTimelineFlyout = query.has(URL_PARAM_KEY.timelineFlyout); - const timelineFlyoutHasValue = - query.get(URL_PARAM_KEY.timelineFlyout) !== '()' && - query.get(URL_PARAM_KEY.timelineFlyout) !== '(preview:!())'; - const isTimelineFlyoutOpen = queryHasTimelineFlyout && timelineFlyoutHasValue; - - if (isSecuritySolutionFlyoutOpen && isTimelineFlyoutOpen) { + if (securitySolutionFlyoutOpen && timelineFlyoutOpen) { return Flyouts.timeline; } - if (isSecuritySolutionFlyoutOpen) { + if (securitySolutionFlyoutOpen) { return Flyouts.securitySolution; } - if (isTimelineFlyoutOpen) { + if (timelineFlyoutOpen) { return Flyouts.timeline; } diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/modal/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/modal/index.tsx index 4d8c5cdf6915..8e0f7088993b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/modal/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/modal/index.tsx @@ -33,6 +33,9 @@ interface TimelineModalProps { * If true the timeline modal will be visible */ visible?: boolean; + /** + * Ref to the element opening/closing the modal + */ openToggleRef: React.MutableRefObject; } @@ -52,7 +55,7 @@ export const TimelineModal = React.memo( }); const sibling: HTMLDivElement | null = useMemo( - () => (!visible ? ref?.current : null), + () => (!visible ? ref.current : null), [visible] ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/wrapper/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/wrapper/index.tsx index bb5389193161..e3b0142bfcd9 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/wrapper/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/wrapper/index.tsx @@ -9,6 +9,7 @@ import { EuiFocusTrap, EuiWindowEvent, keys } from '@elastic/eui'; import React, { useMemo, useCallback, useRef } from 'react'; import type { AppLeaveHandler } from '@kbn/core/public'; import { useDispatch } from 'react-redux'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { TimelineModal } from '../components/modal'; import type { TimelineId } from '../../../common/types'; import { useDeepEqualSelector } from '../../common/hooks/use_selector'; @@ -16,6 +17,7 @@ import { TimelineBottomBar } from '../components/bottom_bar'; import { getTimelineShowStatusByIdSelector } from '../store/selectors'; import { useTimelineSavePrompt } from '../../common/hooks/timeline/use_timeline_save_prompt'; import { timelineActions } from '../store'; +import { isTimelineFlyoutOpen } from '../../flyout/document_details/shared/hooks/use_which_flyout'; interface TimelineWrapperProps { /** @@ -42,15 +44,23 @@ export const TimelineWrapper: React.FC = React.memo( const handleClose = useCallback(() => { dispatch(timelineActions.showTimeline({ id: timelineId, show: false })); }, [dispatch, timelineId]); + const { closeFlyout } = useExpandableFlyoutApi(); - // pressing the ESC key closes the timeline portal + // pressing the ESC key closes the timeline portal unless a flyout is opened on top of it const onKeyDown = useCallback( (ev: KeyboardEvent) => { if (ev.key === keys.ESCAPE) { + const query = new URLSearchParams(window.location.search); + const timelineFlyoutOpen = isTimelineFlyoutOpen(query); + + if (timelineFlyoutOpen) { + closeFlyout(); + return; + } handleClose(); } }, - [handleClose] + [closeFlyout, handleClose] ); useTimelineSavePrompt(timelineId, onAppLeave);