diff --git a/packages/eui/changelogs/upcoming/9003.md b/packages/eui/changelogs/upcoming/9003.md new file mode 100644 index 00000000000..614ff500158 --- /dev/null +++ b/packages/eui/changelogs/upcoming/9003.md @@ -0,0 +1,2 @@ +- Updated `EuiFlyout` with new `onActive` callback and enable stack managed history controls. +- Updated `EuiFlyoutMenu` with new prop `historyItems` and refactored props for back button. diff --git a/packages/eui/src/components/flyout/flyout.component.tsx b/packages/eui/src/components/flyout/flyout.component.tsx index a09918fdc97..1963c99cf45 100644 --- a/packages/eui/src/components/flyout/flyout.component.tsx +++ b/packages/eui/src/components/flyout/flyout.component.tsx @@ -75,6 +75,7 @@ import { useEuiFlyoutOpenState, type EuiFlyoutOpenState, } from './use_open_state'; +import type { EuiFlyoutCloseEvent } from './types'; interface _EuiFlyoutComponentProps { /** @@ -89,14 +90,14 @@ interface _EuiFlyoutComponentProps { * * Use this callback to toggle your internal `isOpen` flyout state. */ - onClose: (event?: MouseEvent | TouchEvent | KeyboardEvent) => void; + onClose: (event?: EuiFlyoutCloseEvent) => void; /** * An optional callback function fired when the flyout begins closing. * * Use in case you need to support any extra logic that relies on the flyout * closing state. In most cases this callback doesn't need to be handled. */ - onClosing?: (event?: MouseEvent | TouchEvent | KeyboardEvent) => void; + onClosing?: (event?: EuiFlyoutCloseEvent) => void; /** * Defines the width of the panel. * Pass a predefined size of `s | m | l`, or pass any number/string compatible with the CSS `width` attribute @@ -384,9 +385,9 @@ export const EuiFlyoutComponent = forwardRef( } const siblingFlyoutId = - currentSession.main === flyoutId - ? currentSession.child - : currentSession.main; + currentSession.mainFlyoutId === flyoutId + ? currentSession.childFlyoutId + : currentSession.mainFlyoutId; return { siblingFlyoutId, @@ -398,9 +399,11 @@ export const EuiFlyoutComponent = forwardRef( // Destructure for easier use const { siblingFlyoutId } = flyoutIdentity; - const hasChildFlyout = currentSession?.child != null; + const hasChildFlyout = currentSession?.childFlyoutId != null; const isChildFlyout = - isInManagedContext && hasChildFlyout && currentSession?.child === id; + isInManagedContext && + hasChildFlyout && + currentSession?.childFlyoutId === id; const shouldCloseOnEscape = useMemo(() => { // Regular flyout - always close on ESC diff --git a/packages/eui/src/components/flyout/flyout.tsx b/packages/eui/src/components/flyout/flyout.tsx index 005584d3eab..adf0abea536 100644 --- a/packages/eui/src/components/flyout/flyout.tsx +++ b/packages/eui/src/components/flyout/flyout.tsx @@ -39,6 +39,7 @@ export type EuiFlyoutProps = Omit< 'as' > & { session?: boolean; + onActive?: () => void; as?: T; }; @@ -46,10 +47,8 @@ export const EuiFlyout = forwardRef< HTMLDivElement | HTMLElement, EuiFlyoutProps<'div' | 'nav'> >((props, ref) => { - const { session, as, onClose, ...rest } = usePropsWithComponentDefaults( - 'EuiFlyout', - props - ); + const { session, as, onClose, onActive, ...rest } = + usePropsWithComponentDefaults('EuiFlyout', props); const hasActiveSession = useHasActiveSession(); const isUnmanagedFlyout = useRef(false); const isInManagedFlyout = useIsInManagedFlyout(); @@ -68,12 +67,21 @@ export const EuiFlyout = forwardRef< onClose?.({} as any); return null; } - return ; + return ( + + ); } // Else if this flyout is a child of a session AND within a managed flyout context, render EuiChildFlyout. if (hasActiveSession && isInManagedFlyout) { - return ; + return ( + + ); } // TODO: if resizeable={true}, render EuiResizableFlyout. diff --git a/packages/eui/src/components/flyout/flyout_menu.stories.tsx b/packages/eui/src/components/flyout/flyout_menu.stories.tsx index 8e23524d5d2..44e571933d2 100644 --- a/packages/eui/src/components/flyout/flyout_menu.stories.tsx +++ b/packages/eui/src/components/flyout/flyout_menu.stories.tsx @@ -10,45 +10,46 @@ import React, { useState } from 'react'; import { action } from '@storybook/addon-actions'; import { Meta, StoryObj } from '@storybook/react'; -import { EuiButton, EuiButtonEmpty, EuiButtonIcon } from '../button'; +import { EuiButton } from '../button'; import { EuiSpacer } from '../spacer'; import { EuiText } from '../text'; import { EuiFlyout } from './flyout'; import { EuiFlyoutBody } from './flyout_body'; import { EuiFlyoutMenu, EuiFlyoutMenuProps } from './flyout_menu'; -import { EuiIcon } from '../icon'; -import { EuiPopover } from '../popover'; -import { EuiListGroup, EuiListGroupItem } from '../list_group'; interface Args extends EuiFlyoutMenuProps { - showBackButton: boolean; showCustomActions: boolean; - showPopover: boolean; + showHistoryItems: boolean; } const meta: Meta = { title: 'Layout/EuiFlyout/EuiFlyoutMenu', component: EuiFlyoutMenu, argTypes: { + showBackButton: { control: 'boolean' }, showCustomActions: { control: 'boolean' }, + 'aria-label': { table: { disable: true } }, + backButtonProps: { table: { disable: true } }, customActions: { table: { disable: true } }, - showPopover: { control: 'boolean' }, - backButton: { table: { disable: true } }, - popover: { table: { disable: true } }, + historyItems: { table: { disable: true } }, }, args: { hideCloseButton: false, showBackButton: true, showCustomActions: true, - showPopover: true, + showHistoryItems: true, }, }; export default meta; const MenuBarFlyout = (args: Args) => { - const { showCustomActions, hideCloseButton, showBackButton, showPopover } = - args; + const { + hideCloseButton, + showBackButton, + showCustomActions, + showHistoryItems, + } = args; const [isFlyoutOpen, setIsFlyoutOpen] = useState(true); const openFlyout = () => setIsFlyoutOpen(true); @@ -56,57 +57,20 @@ const MenuBarFlyout = (args: Args) => { setIsFlyoutOpen(false); }; - /* Back button */ - - // TODO: back button should be internalized in EuiFlyoutMenu when historyItems are passed - const backButton = ( - - Back - - ); - - /* History popover */ - - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const handlePopoverButtonClick = () => { - setIsPopoverOpen(!isPopoverOpen); + const backButtonProps = { + onClick: () => { + action('back button')('click'); + }, }; - const historyItems = [ - { config: { mainTitle: 'First item' } }, - { config: { mainTitle: 'Second item' } }, - { config: { mainTitle: 'Third item' } }, - ]; - - // TODO: history popover should be internalized in EuiFlyoutMenu when historyItems are passed - const historyPopover = ( - - } - isOpen={isPopoverOpen} - onClick={handlePopoverButtonClick} - closePopover={() => setIsPopoverOpen(false)} - panelPaddingSize="xs" - anchorPosition="downLeft" - > - - {historyItems.map((item, index) => ( - { - action(`Clicked ${item.config.mainTitle}`)(); - setIsPopoverOpen(false); - }} - > - {item.config.mainTitle} - - ))} - - - ); + const historyItems = showHistoryItems + ? ['First item', 'Second item', 'Third item'].map((title) => ({ + title, + onClick: () => { + action('history item')(`${title} clicked`); + }, + })) + : undefined; const customActions = ['gear', 'broom'].map((iconType) => ({ iconType, @@ -133,8 +97,9 @@ const MenuBarFlyout = (args: Args) => { flyoutMenuProps={{ title: 'Flyout title', hideCloseButton, - backButton: showBackButton ? backButton : undefined, - popover: showPopover ? historyPopover : undefined, + showBackButton, + backButtonProps, + historyItems, customActions: showCustomActions ? customActions : undefined, }} > diff --git a/packages/eui/src/components/flyout/flyout_menu.tsx b/packages/eui/src/components/flyout/flyout_menu.tsx index 1f33dc63441..2b448969598 100644 --- a/packages/eui/src/components/flyout/flyout_menu.tsx +++ b/packages/eui/src/components/flyout/flyout_menu.tsx @@ -7,22 +7,43 @@ */ import classNames from 'classnames'; -import React, { FunctionComponent, HTMLAttributes, useContext } from 'react'; +import React, { + FunctionComponent, + HTMLAttributes, + useContext, + useState, +} from 'react'; + import { useEuiMemoizedStyles, useGeneratedHtmlId } from '../../services'; -import { EuiButtonIcon } from '../button'; -import { CommonProps } from '../common'; +import { EuiButtonEmpty, EuiButtonIcon, EuiButtonProps } from '../button'; +import { CommonProps, PropsForAnchor } from '../common'; import { EuiFlexGroup, EuiFlexItem } from '../flex'; +import { EuiListGroup, EuiListGroupItem } from '../list_group'; +import { EuiPopover } from '../popover'; import { EuiTitle } from '../title'; import { EuiFlyoutCloseButton } from './_flyout_close_button'; import { euiFlyoutMenuStyles } from './flyout_menu.styles'; import { EuiFlyoutMenuContext } from './flyout_menu_context'; +import type { EuiFlyoutCloseEvent } from './types'; +import { EuiI18n, useEuiI18n } from '../i18n'; + +type EuiFlyoutMenuBackButtonProps = Pick< + PropsForAnchor, + 'aria-label' | 'data-test-subj' | 'onClick' +>; + +type EuiFlyoutHistoryItem = { + title: string; + onClick: () => void; +}; export type EuiFlyoutMenuProps = CommonProps & HTMLAttributes & { - backButton?: React.ReactNode; - popover?: React.ReactNode; title?: React.ReactNode; hideCloseButton?: boolean; + showBackButton?: boolean; + backButtonProps?: EuiFlyoutMenuBackButtonProps; + historyItems?: EuiFlyoutHistoryItem[]; customActions?: Array<{ iconType: string; onClick: () => void; @@ -30,12 +51,63 @@ export type EuiFlyoutMenuProps = CommonProps & }>; }; +const BackButton: React.FC = (props) => { + return ( + + + + ); +}; + +const HistoryPopover: React.FC<{ + items: EuiFlyoutHistoryItem[]; +}> = ({ items }) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const handlePopoverButtonClick = () => { + setIsPopoverOpen(!isPopoverOpen); + }; + + return ( + + } + isOpen={isPopoverOpen} + onClick={handlePopoverButtonClick} + closePopover={() => setIsPopoverOpen(false)} + panelPaddingSize="xs" + anchorPosition="downLeft" + > + + {items.map((item, index) => ( + { + item.onClick(); + setIsPopoverOpen(false); + }} + > + {item.title} + + ))} + + + ); +}; + export const EuiFlyoutMenu: FunctionComponent = ({ className, - backButton, - popover, title, hideCloseButton, + historyItems = [], + showBackButton, + backButtonProps, customActions, ...rest }) => { @@ -54,9 +126,7 @@ export const EuiFlyoutMenu: FunctionComponent = ({ ); } - const handleClose = ( - event: MouseEvent | TouchEvent | KeyboardEvent | undefined - ) => { + const handleClose = (event: EuiFlyoutCloseEvent | undefined) => { onClose?.(event); }; @@ -76,8 +146,18 @@ export const EuiFlyoutMenu: FunctionComponent = ({ gutterSize="none" responsive={false} > - {backButton && {backButton}} - {popover && {popover}} + {showBackButton && ( + + + + )} + + {historyItems.length > 0 && ( + + + + )} + {titleNode && {titleNode}} diff --git a/packages/eui/src/components/flyout/manager/__mocks__/index.ts b/packages/eui/src/components/flyout/manager/__mocks__/index.ts new file mode 100644 index 00000000000..0f2da3f13a7 --- /dev/null +++ b/packages/eui/src/components/flyout/manager/__mocks__/index.ts @@ -0,0 +1,82 @@ +/* + * 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 { LEVEL_MAIN } from '../const'; + +/** + * Centralized test utilities for flyout manager tests. + */ + +export const mockCloseFlyout = jest.fn(); + +export const createMockFunctions = () => ({ + dispatch: jest.fn(), + addFlyout: jest.fn(), + closeFlyout: mockCloseFlyout, + setActiveFlyout: jest.fn(), + setFlyoutWidth: jest.fn(), + goBack: jest.fn(), + goToFlyout: jest.fn(), + getHistoryItems: jest.fn(() => []), +}); + +export const createMockState = () => ({ + sessions: [], + flyouts: [], + layoutMode: 'side-by-side' as const, +}); + +/** + * Factory for creating flyout manager API mock. + */ +export const createFlyoutManagerMock = () => ({ + state: createMockState(), + ...createMockFunctions(), +}); + +/** + * Factory for creating flyout manager reducer mock. + */ +export const createFlyoutManagerReducerMock = () => ({ + state: createMockState(), + ...createMockFunctions(), +}); + +/** + * Helper for creating dynamic test state. + */ +export const createTestState = ( + overrides: Partial> = {} +) => ({ + ...createMockState(), + ...overrides, +}); + +/** + * Helper for creating test session data. + */ +export const createTestSession = ( + main: string, + title: string, + child: string | null = null +) => ({ + main, + title, + child, +}); + +/** + * Helper for creating test flyout data. + */ +export const createTestFlyout = ( + flyoutId: string, + level: string = LEVEL_MAIN +) => ({ + flyoutId, + level, +}); diff --git a/packages/eui/src/components/flyout/manager/actions.ts b/packages/eui/src/components/flyout/manager/actions.ts index 4e4d4e69a8e..160ef1ea7ea 100644 --- a/packages/eui/src/components/flyout/manager/actions.ts +++ b/packages/eui/src/components/flyout/manager/actions.ts @@ -31,6 +31,10 @@ export const ACTION_SET_WIDTH = `${PREFIX}/setWidth` as const; export const ACTION_SET_LAYOUT_MODE = `${PREFIX}/setLayoutMode` as const; /** Dispatched to update a flyout's activity stage (e.g., opening -> active). */ export const ACTION_SET_ACTIVITY_STAGE = `${PREFIX}/setActivityStage` as const; +/** Dispatched to go back one session (remove current session). */ +export const ACTION_GO_BACK = `${PREFIX}/goBack` as const; +/** Dispatched to navigate to a specific flyout (remove all sessions after it). */ +export const ACTION_GO_TO_FLYOUT = `${PREFIX}/goToFlyout` as const; /** * Add a flyout to manager state. The manager will create or update @@ -76,6 +80,17 @@ export interface SetActivityStageAction extends BaseAction { activityStage: EuiFlyoutActivityStage; } +/** Go back one session (remove current session from stack). */ +export interface GoBackAction extends BaseAction { + type: typeof ACTION_GO_BACK; +} + +/** Navigate to a specific flyout (remove all sessions after it). */ +export interface GoToFlyoutAction extends BaseAction { + type: typeof ACTION_GO_TO_FLYOUT; + flyoutId: string; +} + /** Union of all flyout manager actions. */ export type Action = | AddFlyoutAction @@ -83,7 +98,9 @@ export type Action = | SetActiveFlyoutAction | SetWidthAction | SetLayoutModeAction - | SetActivityStageAction; + | SetActivityStageAction + | GoBackAction + | GoToFlyoutAction; /** * Register a flyout with the manager. @@ -145,3 +162,14 @@ export const setActivityStage = ( flyoutId, activityStage, }); + +/** Go back one session (remove current session from stack). */ +export const goBack = (): GoBackAction => ({ + type: ACTION_GO_BACK, +}); + +/** Navigate to a specific flyout (remove all sessions after it). */ +export const goToFlyout = (flyoutId: string): GoToFlyoutAction => ({ + type: ACTION_GO_TO_FLYOUT, + flyoutId, +}); diff --git a/packages/eui/src/components/flyout/manager/activity_stage.ts b/packages/eui/src/components/flyout/manager/activity_stage.ts index da8a9dd92da..63374402f08 100644 --- a/packages/eui/src/components/flyout/manager/activity_stage.ts +++ b/packages/eui/src/components/flyout/manager/activity_stage.ts @@ -73,8 +73,9 @@ export const useFlyoutActivityStage = ({ let next: EuiFlyoutActivityStage | null = null; if (s === STAGE_ACTIVE && !isActive) next = STAGE_CLOSING; - else if (s === STAGE_INACTIVE && isActive) next = STAGE_RETURNING; - else if ( + else if (s === STAGE_INACTIVE && isActive) { + next = STAGE_RETURNING; + } else if ( level === LEVEL_MAIN && isActive && s === STAGE_ACTIVE && diff --git a/packages/eui/src/components/flyout/manager/flyout_managed.test.tsx b/packages/eui/src/components/flyout/manager/flyout_managed.test.tsx index e66f78e06a0..38a844f9f33 100644 --- a/packages/eui/src/components/flyout/manager/flyout_managed.test.tsx +++ b/packages/eui/src/components/flyout/manager/flyout_managed.test.tsx @@ -11,9 +11,9 @@ import React from 'react'; import { act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; + import { render } from '../../../test/rtl'; import { requiredProps } from '../../../test/required_props'; - import { EuiManagedFlyout } from './flyout_managed'; import { EuiFlyoutManager } from './provider'; import { @@ -44,36 +44,71 @@ jest.mock('../flyout.component', () => { }; }); +// Shared mock functions - must be defined in module scope for Jest +const mockCloseFlyout = jest.fn(); +const createMockState = () => ({ + sessions: [], + flyouts: [], + layoutMode: 'side-by-side' as const, +}); +const createMockFunctions = () => ({ + dispatch: jest.fn(), + addFlyout: jest.fn(), + closeFlyout: mockCloseFlyout, + setActiveFlyout: jest.fn(), + setFlyoutWidth: jest.fn(), + goBack: jest.fn(), + goToFlyout: jest.fn(), + getHistoryItems: jest.fn(() => []), +}); + // Mock hooks that would otherwise depend on ResizeObserver or animation timing jest.mock('./hooks', () => ({ useFlyoutManagerReducer: () => ({ - state: { sessions: [], flyouts: [], layoutMode: 'side-by-side' }, - dispatch: jest.fn(), - addFlyout: jest.fn(), - closeFlyout: jest.fn(), - setActiveFlyout: jest.fn(), - setFlyoutWidth: jest.fn(), + state: createMockState(), + ...createMockFunctions(), }), useFlyoutManager: () => ({ - state: { sessions: [], flyouts: [], layoutMode: 'side-by-side' }, - addFlyout: jest.fn(), - closeFlyout: jest.fn(), - setFlyoutWidth: jest.fn(), + state: createMockState(), + ...createMockFunctions(), }), useIsFlyoutActive: () => true, useHasChildFlyout: () => false, useParentFlyoutSize: () => 'm', useFlyoutLayoutMode: () => 'side-by-side', useFlyoutId: (id?: string) => id ?? 'generated-id', + useCurrentSession: () => null, +})); + +jest.mock('./selectors', () => ({ + useIsFlyoutRegistered: () => false, + useIsFlyoutActive: () => true, + useHasChildFlyout: () => false, + useParentFlyoutSize: () => 'm', + useCurrentSession: () => null, + useSession: () => null, + useHasActiveSession: () => false, + useFlyout: () => null, + useCurrentMainFlyout: () => null, + useCurrentChildFlyout: () => null, + useFlyoutWidth: () => null, })); // Mock validation helpers to be deterministic jest.mock('./validation', () => ({ - validateManagedFlyoutSize: jest.fn(() => undefined), - validateSizeCombination: jest.fn(() => undefined), - validateFlyoutTitle: jest.fn(() => undefined), createValidationErrorMessage: jest.fn((e: any) => String(e)), isNamedSize: jest.fn(() => true), + validateFlyoutTitle: jest.fn(() => undefined), + validateManagedFlyoutSize: jest.fn(() => undefined), + validateSizeCombination: jest.fn(() => undefined), +})); + +jest.mock('./provider', () => ({ + ...jest.requireActual('./provider'), + useFlyoutManager: () => ({ + state: createMockState(), + ...createMockFunctions(), + }), })); // Mock resize observer hook to return a fixed width @@ -85,6 +120,10 @@ describe('EuiManagedFlyout', () => { const renderInProvider = (ui: React.ReactElement) => render({ui}); + beforeEach(() => { + jest.clearAllMocks(); + }); + it('renders and sets managed data attributes', () => { const { getByTestSubject } = renderInProvider( { expect(el).toHaveAttribute(PROPERTY_LEVEL, LEVEL_MAIN); }); - it('calls onClose prop when onClose is invoked', () => { + it('calls the unregister callback prop when onClose', () => { const onClose = jest.fn(); - const { getByTestSubject } = renderInProvider( + const { getByTestSubject, unmount } = renderInProvider( ); @@ -111,7 +150,15 @@ describe('EuiManagedFlyout', () => { userEvent.click(getByTestSubject('managed-flyout')); }); - expect(onClose).toHaveBeenCalledTimes(1); + // The onClose should be called when the flyout is clicked + expect(onClose).toHaveBeenCalled(); + + // The closeFlyout should be called when the component unmounts (cleanup) + act(() => { + unmount(); + }); + + expect(mockCloseFlyout).toHaveBeenCalledWith('close-me'); }); it('registers child flyout and sets data-level child', () => { @@ -233,4 +280,59 @@ describe('EuiManagedFlyout', () => { expect(getByTestSubject('managed-flyout')).toBeInTheDocument(); }); }); + + describe('onClose callback behavior', () => { + it('does not call onClose callback during component cleanup/unmount', () => { + const onClose = jest.fn(); + + const { unmount } = renderInProvider( + + ); + + // Initially onClose should not be called + expect(onClose).not.toHaveBeenCalled(); + + // Unmount the component to trigger cleanup + act(() => { + unmount(); + }); + + // onClose should NOT be called during cleanup (intentional design) + expect(onClose).not.toHaveBeenCalled(); + expect(mockCloseFlyout).toHaveBeenCalledWith('cleanup-test'); + }); + + it('does not call onClose multiple times (double-firing prevention)', () => { + const onClose = jest.fn(); + + const { getByTestSubject, unmount } = renderInProvider( + + ); + + // First call via direct onClick + act(() => { + userEvent.click(getByTestSubject('managed-flyout')); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + + // Unmount should not call onClose again due to double-firing prevention + act(() => { + unmount(); + }); + + // Should still be called only once + expect(onClose).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/eui/src/components/flyout/manager/flyout_managed.tsx b/packages/eui/src/components/flyout/manager/flyout_managed.tsx index d3a385f149f..4d5febc5654 100644 --- a/packages/eui/src/components/flyout/manager/flyout_managed.tsx +++ b/packages/eui/src/components/flyout/manager/flyout_managed.tsx @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { useEuiMemoizedStyles } from '../../../services'; import { useResizeObserver } from '../../observer/resize_observer'; import { @@ -14,9 +14,11 @@ import { } from '../flyout.component'; import { EuiFlyoutMenuProps } from '../flyout_menu'; import { EuiFlyoutMenuContext } from '../flyout_menu_context'; +import type { EuiFlyoutCloseEvent } from '../types'; import { useFlyoutActivityStage } from './activity_stage'; import { LEVEL_CHILD, + LEVEL_MAIN, PROPERTY_FLYOUT, PROPERTY_LAYOUT_MODE, PROPERTY_LEVEL, @@ -25,12 +27,14 @@ import { EuiFlyoutIsManagedProvider } from './context'; import { euiManagedFlyoutStyles } from './flyout_managed.styles'; import { useFlyoutManager as _useFlyoutManager, + useCurrentSession, useFlyoutId, useFlyoutLayoutMode, useIsFlyoutActive, useParentFlyoutSize, } from './hooks'; -import { EuiFlyoutLevel } from './types'; +import { useIsFlyoutRegistered } from './selectors'; +import type { EuiFlyoutLevel } from './types'; import { createValidationErrorMessage, isNamedSize, @@ -46,7 +50,8 @@ import { */ export interface EuiManagedFlyoutProps extends EuiFlyoutComponentProps { level: EuiFlyoutLevel; - flyoutMenuProps?: EuiFlyoutMenuProps; + flyoutMenuProps?: Omit; + onActive?: () => void; } const useFlyoutManager = () => { @@ -67,6 +72,7 @@ const useFlyoutManager = () => { export const EuiManagedFlyout = ({ id, onClose: onCloseProp, + onActive: onActiveProp, level, size, css: customCss, @@ -77,14 +83,10 @@ export const EuiManagedFlyout = ({ const flyoutId = useFlyoutId(id); const flyoutRef = useRef(null); - const { addFlyout, closeFlyout, setFlyoutWidth } = useFlyoutManager(); - - const isActive = useIsFlyoutActive(flyoutId); + const { addFlyout, closeFlyout, setFlyoutWidth, goBack, getHistoryItems } = + useFlyoutManager(); const parentSize = useParentFlyoutSize(flyoutId); - - // Get layout mode for responsive behavior const layoutMode = useFlyoutLayoutMode(); - const styles = useEuiMemoizedStyles(euiManagedFlyoutStyles); // Validate size @@ -115,14 +117,77 @@ export const EuiManagedFlyout = ({ throw new Error(createValidationErrorMessage(titleError)); } - // Register/unregister with flyout manager context + const isActive = useIsFlyoutActive(flyoutId); + const currentSession = useCurrentSession(); + const flyoutExistsInManager = useIsFlyoutRegistered(flyoutId); + + // Stabilize the onClose callback + const onCloseCallbackRef = useRef< + ((e?: EuiFlyoutCloseEvent) => void) | undefined + >(); + onCloseCallbackRef.current = (e) => { + if (onCloseProp) { + const event = e || new MouseEvent('click'); + onCloseProp(event); + } + }; + + // Stabilize the onActive callback + const onActiveCallbackRef = useRef<(() => void) | undefined>(); + onActiveCallbackRef.current = onActiveProp; + + // Track if flyout was ever registered to avoid false positives on initial mount + const wasRegisteredRef = useRef(false); + + // Register with flyout manager context when open, remove when closed useEffect(() => { if (isOpen) { addFlyout(flyoutId, title!, level, size as string); + } else { + closeFlyout(flyoutId); + // Reset navigation tracking when explicitly closed via isOpen=false + wasRegisteredRef.current = false; + } + }, [isOpen, flyoutId, title, level, size, addFlyout, closeFlyout]); + + // Detect when flyout has been removed from manager state (e.g., via Back button) + // and trigger onClose callback to notify the parent component + useEffect(() => { + if (isOpen && flyoutExistsInManager) { + wasRegisteredRef.current = true; + } - return () => closeFlyout(flyoutId); + // If flyout was previously registered, is marked as open, but no longer exists in manager state, + // it was removed via navigation (Back button) - trigger close callback + if (wasRegisteredRef.current && isOpen && !flyoutExistsInManager) { + onCloseCallbackRef.current?.(new MouseEvent('navigation')); + wasRegisteredRef.current = false; // Reset to avoid repeated calls } - }, [isOpen, flyoutId, size, title, level, addFlyout, closeFlyout]); + }, [flyoutExistsInManager, isOpen, flyoutId]); + + // Monitor current session changes and fire onActive callback when this flyout becomes active + useEffect(() => { + if (!onActiveCallbackRef.current || !currentSession) { + return; + } + + // Make sure callback is only fired for the flyout that changed + const mainChanged = + level === LEVEL_MAIN && currentSession.mainFlyoutId === flyoutId; + const childChanged = + level === LEVEL_CHILD && currentSession.childFlyoutId === flyoutId; + + if (mainChanged || childChanged) { + onActiveCallbackRef.current(); + } + }, [currentSession, flyoutId, level]); + + useEffect(() => { + return () => { + // Only remove from manager on component unmount, don't trigger close callback + closeFlyout(flyoutId); + }; + }, [closeFlyout, flyoutId]); // Track width changes for flyouts const { width } = useResizeObserver( @@ -130,9 +195,9 @@ export const EuiManagedFlyout = ({ 'width' ); - const onClose = (event?: MouseEvent | TouchEvent | KeyboardEvent) => { - onCloseProp(event); - closeFlyout(flyoutId); + // Pass the stabilized onClose callback to the flyout menu context + const onClose = (e?: EuiFlyoutCloseEvent) => { + onCloseCallbackRef.current?.(e); }; // Update width in manager state when it changes @@ -147,8 +212,22 @@ export const EuiManagedFlyout = ({ level, }); + // Note: history controls are only relevant for main flyouts + const historyItems = useMemo(() => { + return level === LEVEL_MAIN ? getHistoryItems() : undefined; + }, [level, getHistoryItems]); + + const backButtonProps = useMemo(() => { + return level === LEVEL_MAIN ? { onClick: goBack } : undefined; + }, [level, goBack]); + + const showBackButton = historyItems ? historyItems.length > 0 : false; + const flyoutMenuProps = { ..._flyoutMenuProps, + historyItems, + showBackButton, + backButtonProps, title, }; diff --git a/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx b/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx new file mode 100644 index 00000000000..237d051bd37 --- /dev/null +++ b/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx @@ -0,0 +1,334 @@ +/* + * 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 { Meta, StoryObj } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import React, { useState, useCallback, useMemo } from 'react'; + +import { + EuiButton, + EuiCodeBlock, + EuiDescriptionList, + EuiFlyoutBody, + EuiPageTemplate, + EuiPageTemplateProps, + EuiSpacer, + EuiSwitch, + EuiSwitchEvent, + EuiText, + EuiTitle, +} from '../..'; +import { EuiFlyout } from '../flyout'; +import { useFlyoutManager, useCurrentSession } from './hooks'; + +const meta: Meta = { + title: 'Layout/EuiFlyout/Flyout Manager', + component: EuiFlyout, +}; + +export default meta; + +interface FlyoutSessionProps { + title: string; + mainSize: 's' | 'm' | 'l' | 'fill'; + mainMaxWidth?: number; + childSize?: 's' | 'm' | 'fill'; + childMaxWidth?: number; + flyoutType: 'overlay' | 'push'; + childBackgroundShaded?: boolean; +} + +const FlyoutSession: React.FC = React.memo((props) => { + const { + title, + mainSize, + childSize, + mainMaxWidth, + childMaxWidth, + flyoutType, + } = props; + + const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); + const [isChildFlyoutVisible, setIsChildFlyoutVisible] = useState(false); + + // Handlers for "Open" buttons + + const handleOpenMainFlyout = () => { + setIsFlyoutVisible(true); + }; + + const handleOpenChildFlyout = () => { + setIsChildFlyoutVisible(true); + }; + + // Callbacks for state synchronization + + const mainFlyoutOnActive = useCallback(() => { + action('activate main flyout')(title); + }, [title]); + + const childFlyoutOnActive = useCallback(() => { + action('activate child flyout')(title); + }, [title]); + + const mainFlyoutOnClose = useCallback(() => { + action('close main flyout')(title); + setIsFlyoutVisible(false); + setIsChildFlyoutVisible(false); + }, [title]); + + const childFlyoutOnClose = useCallback(() => { + action('close child flyout')(title); + setIsChildFlyoutVisible(false); + }, [title]); + + // Render + + return ( + <> + + + Open {title} + + + + + +

This is the content of {title}.

+ + + {childSize && ( + + Open child flyout + + )} +
+
+ {childSize && ( + + + +

This is the content of the child flyout of {title}.

+ + +
+
+
+ )} +
+ + ); +}); + +FlyoutSession.displayName = 'FlyoutSession'; + +const ExampleComponent = () => { + const panelled: EuiPageTemplateProps['panelled'] = undefined; + const restrictWidth: EuiPageTemplateProps['restrictWidth'] = false; + const bottomBorder: EuiPageTemplateProps['bottomBorder'] = 'extended'; + + const [flyoutType, setFlyoutType] = useState<'overlay' | 'push'>('overlay'); + + const handleFlyoutTypeToggle = useCallback((e: EuiSwitchEvent) => { + setFlyoutType(e.target.checked ? 'push' : 'overlay'); + }, []); + + const flyoutManager = useFlyoutManager(); + const currentSession = useCurrentSession(); + + const listItems = useMemo( + () => [ + { + title: 'Session A: main size = s, child size = s', + description: ( + + ), + }, + { + title: 'Session B: main size = m, child size = s', + description: ( + + ), + }, + { + title: 'Session C: main size = s, child size = fill', + description: ( + + ), + }, + { + title: 'Session D: main size = fill, child size = s', + description: ( + + ), + }, + { + title: 'Session E: main size = fill', + description: ( + + ), + }, + { + title: 'Session F: main size = s, child size = fill (maxWidth 1000px)', + description: ( + + ), + }, + { + title: 'Session G: main size = fill (maxWidth 1000px), child size = s', + description: ( + + ), + }, + ], + [flyoutType] + ); + + return ( + + + + +

Options

+
+ + + {/* FIXME add option to set child flyout background style to "shaded" */} +
+ + + + + +

Contexts

+
+ + + {JSON.stringify( + { + flyoutManager: flyoutManager + ? { state: flyoutManager.state } + : null, + currentSession: currentSession ? currentSession : null, + }, + null, + 2 + )} + +
+
+ ); +}; + +export const MultiSessionExample: StoryObj = { + name: 'Multi-session example', + render: () => , + parameters: { + layout: 'fullscreen', + }, +}; diff --git a/packages/eui/src/components/flyout/manager/hooks.test.tsx b/packages/eui/src/components/flyout/manager/hooks.test.tsx index 26fc9870935..dcc5b9a8a32 100644 --- a/packages/eui/src/components/flyout/manager/hooks.test.tsx +++ b/packages/eui/src/components/flyout/manager/hooks.test.tsx @@ -96,7 +96,7 @@ describe('flyout manager hooks', () => { }); expect(result.current.state.flyouts).toHaveLength(2); - expect(result.current.state.sessions[0].child).toBe('child-1'); + expect(result.current.state.sessions[0].childFlyoutId).toBe('child-1'); expect(result.current.state.flyouts[0].width).toBe(600); expect(result.current.state.flyouts[1].width).toBe(400); }); @@ -317,7 +317,7 @@ describe('flyout manager hooks', () => { expect(reducerResult.current.state.flyouts).toHaveLength(2); expect(reducerResult.current.state.sessions).toHaveLength(1); - expect(reducerResult.current.state.sessions[0].child).toBe( + expect(reducerResult.current.state.sessions[0].childFlyoutId).toBe( idResult2.current ); }); diff --git a/packages/eui/src/components/flyout/manager/hooks.ts b/packages/eui/src/components/flyout/manager/hooks.ts index 96717827cb2..0c40e44c981 100644 --- a/packages/eui/src/components/flyout/manager/hooks.ts +++ b/packages/eui/src/components/flyout/manager/hooks.ts @@ -16,6 +16,8 @@ import { closeFlyout as closeFlyoutAction, setActiveFlyout as setActiveFlyoutAction, setFlyoutWidth as setFlyoutWidthAction, + goBack as goBackAction, + goToFlyout as goToFlyoutAction, } from './actions'; import { type EuiFlyoutLevel, @@ -78,6 +80,22 @@ export function useFlyoutManagerReducer( dispatch(setFlyoutWidthAction(flyoutId, width)), [] ); + const goBack = useCallback(() => dispatch(goBackAction()), []); + const goToFlyout = useCallback( + (flyoutId: string) => dispatch(goToFlyoutAction(flyoutId)), + [] + ); + const getHistoryItems = useCallback(() => { + const currentSessionIndex = state.sessions.length - 1; + const previousSessions = state.sessions.slice(0, currentSessionIndex); + + return previousSessions + .reverse() + .map(({ title, mainFlyoutId: mainFlyoutId }) => ({ + title: title, + onClick: () => goToFlyout(mainFlyoutId), + })); + }, [state.sessions, goToFlyout]); return { state, @@ -86,6 +104,9 @@ export function useFlyoutManagerReducer( closeFlyout, setActiveFlyout, setFlyoutWidth, + goBack, + goToFlyout, + getHistoryItems, }; } diff --git a/packages/eui/src/components/flyout/manager/layout_mode.test.tsx b/packages/eui/src/components/flyout/manager/layout_mode.test.tsx index e175d45232e..bc31c8e8887 100644 --- a/packages/eui/src/components/flyout/manager/layout_mode.test.tsx +++ b/packages/eui/src/components/flyout/manager/layout_mode.test.tsx @@ -119,8 +119,8 @@ describe('layout_mode', () => { }); mockUseCurrentSession.mockReturnValue({ - main: 'main-1', - child: 'child-1', + mainFlyoutId: 'main-1', + childFlyoutId: 'child-1', }); mockUseCurrentMainFlyout.mockReturnValue({ @@ -354,8 +354,8 @@ describe('layout_mode', () => { }); mockUseCurrentSession.mockReturnValue({ - main: 'main-1', - child: null, + mainFlyoutId: 'main-1', + childFlyoutId: null, }); // Reset the mock to return undefined for widths since no child exists @@ -454,8 +454,8 @@ describe('layout_mode', () => { // Set up session with both main and child flyouts mockUseCurrentSession.mockReturnValue({ - main: 'main-1', - child: 'child-1', + mainFlyoutId: 'main-1', + childFlyoutId: 'child-1', }); // Set up flyout objects @@ -536,8 +536,8 @@ describe('layout_mode', () => { // Set up a scenario where the layout mode should remain the same mockUseCurrentSession.mockReturnValue({ - main: 'main-1', - child: null, // No child flyout + mainFlyoutId: 'main-1', + childFlyoutId: null, // No child flyout }); const mockDispatch = jest.fn(); @@ -649,8 +649,8 @@ describe('layout_mode', () => { }); mockUseCurrentSession.mockReturnValue({ - main: 'main-1', - child: 'child-1', + mainFlyoutId: 'main-1', + childFlyoutId: 'child-1', }); const mockDispatch = jest.fn(); @@ -690,8 +690,8 @@ describe('layout_mode', () => { }); mockUseCurrentSession.mockReturnValue({ - main: 'main-1', - child: 'child-1', + mainFlyoutId: 'main-1', + childFlyoutId: 'child-1', }); const mockDispatch = jest.fn(); @@ -731,8 +731,8 @@ describe('layout_mode', () => { }); mockUseCurrentSession.mockReturnValue({ - main: 'main-1', - child: 'child-1', + mainFlyoutId: 'main-1', + childFlyoutId: 'child-1', }); const mockDispatch = jest.fn(); @@ -772,8 +772,8 @@ describe('layout_mode', () => { }); mockUseCurrentSession.mockReturnValue({ - main: 'main-1', - child: 'child-1', + mainFlyoutId: 'main-1', + childFlyoutId: 'child-1', }); const mockDispatch = jest.fn(); @@ -813,8 +813,8 @@ describe('layout_mode', () => { }); mockUseCurrentSession.mockReturnValue({ - main: 'main-1', - child: 'child-1', + mainFlyoutId: 'main-1', + childFlyoutId: 'child-1', }); const mockDispatch = jest.fn(); @@ -848,8 +848,8 @@ describe('layout_mode', () => { mockUseCurrentChildFlyout.mockReturnValue(null); mockUseCurrentSession.mockReturnValue({ - main: 'main-1', - child: null, + mainFlyoutId: 'main-1', + childFlyoutId: null, }); const mockDispatch = jest.fn(); diff --git a/packages/eui/src/components/flyout/manager/layout_mode.ts b/packages/eui/src/components/flyout/manager/layout_mode.ts index e1d04a4e33a..2184223ade3 100644 --- a/packages/eui/src/components/flyout/manager/layout_mode.ts +++ b/packages/eui/src/components/flyout/manager/layout_mode.ts @@ -29,8 +29,8 @@ export const useApplyFlyoutLayoutMode = () => { const context = useFlyoutManager(); const currentSession = useCurrentSession(); - const parentFlyoutId = currentSession?.main; - const childFlyoutId = currentSession?.child; + const parentFlyoutId = currentSession?.mainFlyoutId; + const childFlyoutId = currentSession?.childFlyoutId; const parentFlyout = useCurrentMainFlyout(); const childFlyout = useCurrentChildFlyout(); diff --git a/packages/eui/src/components/flyout/manager/reducer.test.ts b/packages/eui/src/components/flyout/manager/reducer.test.ts index a47abb82b42..4e95ad459fe 100644 --- a/packages/eui/src/components/flyout/manager/reducer.test.ts +++ b/packages/eui/src/components/flyout/manager/reducer.test.ts @@ -21,6 +21,8 @@ import { setFlyoutWidth, setLayoutMode, setActivityStage, + goBack, + goToFlyout, } from './actions'; import { LAYOUT_MODE_SIDE_BY_SIDE, @@ -66,9 +68,9 @@ describe('flyoutManagerReducer', () => { expect(newState.sessions).toHaveLength(1); expect(newState.sessions[0]).toEqual({ - main: 'main-1', + mainFlyoutId: 'main-1', + childFlyoutId: null, title: 'main', - child: null, }); }); @@ -85,7 +87,7 @@ describe('flyoutManagerReducer', () => { expect(state.flyouts).toHaveLength(2); expect(state.sessions).toHaveLength(1); - expect(state.sessions[0].child).toBe('child-1'); + expect(state.sessions[0].childFlyoutId).toBe('child-1'); }); it('should ignore duplicate flyout IDs', () => { @@ -127,14 +129,14 @@ describe('flyoutManagerReducer', () => { expect(state.sessions).toHaveLength(2); expect(state.sessions[0]).toEqual({ - main: 'main-1', + mainFlyoutId: 'main-1', + childFlyoutId: 'child-1', title: 'main', - child: 'child-1', }); expect(state.sessions[1]).toEqual({ - main: 'main-2', + mainFlyoutId: 'main-2', + childFlyoutId: null, title: 'main', - child: null, }); }); }); @@ -177,7 +179,7 @@ describe('flyoutManagerReducer', () => { expect(state.flyouts).toHaveLength(1); expect(state.flyouts[0].flyoutId).toBe('main-1'); - expect(state.sessions[0].child).toBe(null); + expect(state.sessions[0].childFlyoutId).toBe(null); }); it('should handle closing non-existent flyout', () => { @@ -206,7 +208,7 @@ describe('flyoutManagerReducer', () => { const action = setActiveFlyout('child-1'); state = flyoutManagerReducer(state, action); - expect(state.sessions[0].child).toBe('child-1'); + expect(state.sessions[0].childFlyoutId).toBe('child-1'); }); it('should clear active child flyout when null is passed', () => { @@ -223,7 +225,7 @@ describe('flyoutManagerReducer', () => { const action = setActiveFlyout(null); state = flyoutManagerReducer(state, action); - expect(state.sessions[0].child).toBe(null); + expect(state.sessions[0].childFlyoutId).toBe(null); }); it('should do nothing when no sessions exist', () => { @@ -354,6 +356,225 @@ describe('flyoutManagerReducer', () => { }); }); + describe('ACTION_GO_BACK', () => { + it('should remove the current session and its flyouts', () => { + // Setup: create two sessions + let state = flyoutManagerReducer( + initialState, + addFlyout('main-1', 'Session A', LEVEL_MAIN) + ); + state = flyoutManagerReducer( + state, + addFlyout('main-2', 'Session B', LEVEL_MAIN) + ); + + expect(state.sessions).toHaveLength(2); + expect(state.flyouts).toHaveLength(2); + + // Go back (should remove Session B) + const action = goBack(); + state = flyoutManagerReducer(state, action); + + expect(state.sessions).toHaveLength(1); + expect(state.sessions[0].mainFlyoutId).toBe('main-1'); + expect(state.sessions[0].title).toBe('Session A'); + expect(state.flyouts).toHaveLength(1); + expect(state.flyouts[0].flyoutId).toBe('main-1'); + }); + + it('should remove current session with child flyout', () => { + // Setup: create session with child + let state = flyoutManagerReducer( + initialState, + addFlyout('main-1', 'Session A', LEVEL_MAIN) + ); + state = flyoutManagerReducer( + state, + addFlyout('main-2', 'Session B', LEVEL_MAIN) + ); + state = flyoutManagerReducer( + state, + addFlyout('child-2', 'Child B', LEVEL_CHILD) + ); + + expect(state.sessions).toHaveLength(2); + expect(state.sessions[1].childFlyoutId).toBe('child-2'); + expect(state.flyouts).toHaveLength(3); + + // Go back (should remove Session B and its child) + const action = goBack(); + state = flyoutManagerReducer(state, action); + + expect(state.sessions).toHaveLength(1); + expect(state.sessions[0].mainFlyoutId).toBe('main-1'); + expect(state.flyouts).toHaveLength(1); + expect(state.flyouts[0].flyoutId).toBe('main-1'); + }); + + it('should do nothing when no sessions exist', () => { + const action = goBack(); + const newState = flyoutManagerReducer(initialState, action); + + expect(newState).toEqual(initialState); + }); + + it('should remove the last session when only one exists', () => { + // Setup: create single session + let state = flyoutManagerReducer( + initialState, + addFlyout('main-1', 'Session A', LEVEL_MAIN) + ); + + expect(state.sessions).toHaveLength(1); + expect(state.flyouts).toHaveLength(1); + + // Go back (should remove the only session) + const action = goBack(); + state = flyoutManagerReducer(state, action); + + expect(state.sessions).toHaveLength(0); + expect(state.flyouts).toHaveLength(0); + }); + }); + + describe('ACTION_GO_TO_FLYOUT', () => { + it('should remove all sessions after the target session', () => { + // Setup: create three sessions + let state = flyoutManagerReducer( + initialState, + addFlyout('main-1', 'Session A', LEVEL_MAIN) + ); + state = flyoutManagerReducer( + state, + addFlyout('main-2', 'Session B', LEVEL_MAIN) + ); + state = flyoutManagerReducer( + state, + addFlyout('main-3', 'Session C', LEVEL_MAIN) + ); + + expect(state.sessions).toHaveLength(3); + expect(state.flyouts).toHaveLength(3); + + // Navigate to Session A (should remove B and C) + const action = goToFlyout('main-1'); + state = flyoutManagerReducer(state, action); + + expect(state.sessions).toHaveLength(1); + expect(state.sessions[0].mainFlyoutId).toBe('main-1'); + expect(state.sessions[0].title).toBe('Session A'); + expect(state.flyouts).toHaveLength(1); + expect(state.flyouts[0].flyoutId).toBe('main-1'); + }); + + it('should remove sessions with child flyouts', () => { + // Setup: create sessions with children + let state = flyoutManagerReducer( + initialState, + addFlyout('main-1', 'Session A', LEVEL_MAIN) + ); + state = flyoutManagerReducer( + state, + addFlyout('main-2', 'Session B', LEVEL_MAIN) + ); + state = flyoutManagerReducer( + state, + addFlyout('child-2', 'Child B', LEVEL_CHILD) + ); + state = flyoutManagerReducer( + state, + addFlyout('main-3', 'Session C', LEVEL_MAIN) + ); + state = flyoutManagerReducer( + state, + addFlyout('child-3', 'Child C', LEVEL_CHILD) + ); + + expect(state.sessions).toHaveLength(3); + expect(state.flyouts).toHaveLength(5); + + // Navigate to Session A (should remove B, child-2, C, child-3) + const action = goToFlyout('main-1'); + state = flyoutManagerReducer(state, action); + + expect(state.sessions).toHaveLength(1); + expect(state.sessions[0].mainFlyoutId).toBe('main-1'); + expect(state.flyouts).toHaveLength(1); + expect(state.flyouts[0].flyoutId).toBe('main-1'); + }); + + it('should handle navigating to middle session', () => { + // Setup: create three sessions + let state = flyoutManagerReducer( + initialState, + addFlyout('main-1', 'Session A', LEVEL_MAIN) + ); + state = flyoutManagerReducer( + state, + addFlyout('main-2', 'Session B', LEVEL_MAIN) + ); + state = flyoutManagerReducer( + state, + addFlyout('main-3', 'Session C', LEVEL_MAIN) + ); + + // Navigate to Session B (should remove only C) + const action = goToFlyout('main-2'); + state = flyoutManagerReducer(state, action); + + expect(state.sessions).toHaveLength(2); + expect(state.sessions[0].mainFlyoutId).toBe('main-1'); + expect(state.sessions[1].mainFlyoutId).toBe('main-2'); + expect(state.flyouts).toHaveLength(2); + expect(state.flyouts.map((f) => f.flyoutId)).toEqual([ + 'main-1', + 'main-2', + ]); + }); + + it('should do nothing when target flyout does not exist', () => { + // Setup: create session + let state = flyoutManagerReducer( + initialState, + addFlyout('main-1', 'Session A', LEVEL_MAIN) + ); + + const originalState = { ...state }; + + // Try to navigate to non-existent flyout + const action = goToFlyout('non-existent'); + state = flyoutManagerReducer(state, action); + + expect(state).toEqual(originalState); + }); + + it('should do nothing when navigating to the current (last) session', () => { + // Setup: create two sessions + let state = flyoutManagerReducer( + initialState, + addFlyout('main-1', 'Session A', LEVEL_MAIN) + ); + state = flyoutManagerReducer( + state, + addFlyout('main-2', 'Session B', LEVEL_MAIN) + ); + + // Navigate to the current session (should do nothing) + const action = goToFlyout('main-2'); + const originalState = { ...state }; + state = flyoutManagerReducer(state, action); + + expect(state).toEqual(originalState); + }); + + it('should handle empty state gracefully', () => { + const action = goToFlyout('main-1'); + const newState = flyoutManagerReducer(initialState, action); + + expect(newState).toEqual(initialState); + }); + }); + describe('default case', () => { it('should return current state for unknown actions', () => { const unknownAction = { type: 'UNKNOWN_ACTION' } as any; @@ -381,11 +602,11 @@ describe('flyoutManagerReducer', () => { addFlyout('child-1', 'child', LEVEL_CHILD, 'm') ); expect(state.flyouts).toHaveLength(2); - expect(state.sessions[0].child).toBe('child-1'); + expect(state.sessions[0].childFlyoutId).toBe('child-1'); // 3. Set child as active state = flyoutManagerReducer(state, setActiveFlyout('child-1')); - expect(state.sessions[0].child).toBe('child-1'); + expect(state.sessions[0].childFlyoutId).toBe('child-1'); // 4. Update widths state = flyoutManagerReducer(state, setFlyoutWidth('main-1', 600)); @@ -404,7 +625,7 @@ describe('flyoutManagerReducer', () => { // 6. Close child flyout state = flyoutManagerReducer(state, closeFlyout('child-1')); expect(state.flyouts).toHaveLength(1); - expect(state.sessions[0].child).toBe(null); + expect(state.sessions[0].childFlyoutId).toBe(null); // 7. Close main flyout state = flyoutManagerReducer(state, closeFlyout('main-1')); @@ -433,21 +654,21 @@ describe('flyoutManagerReducer', () => { expect(state.sessions).toHaveLength(2); expect(state.sessions[0]).toEqual({ - main: 'main-1', + mainFlyoutId: 'main-1', + childFlyoutId: 'child-1', title: 'main', - child: 'child-1', }); expect(state.sessions[1]).toEqual({ - main: 'main-2', + mainFlyoutId: 'main-2', + childFlyoutId: null, title: 'main', - child: null, }); // Close first session's main flyout state = flyoutManagerReducer(state, closeFlyout('main-1')); expect(state.sessions).toHaveLength(1); - expect(state.sessions[0].main).toBe('main-2'); + expect(state.sessions[0].mainFlyoutId).toBe('main-2'); }); }); }); diff --git a/packages/eui/src/components/flyout/manager/reducer.ts b/packages/eui/src/components/flyout/manager/reducer.ts index 592a2432624..f92ab141855 100644 --- a/packages/eui/src/components/flyout/manager/reducer.ts +++ b/packages/eui/src/components/flyout/manager/reducer.ts @@ -13,6 +13,8 @@ import { ACTION_SET_LAYOUT_MODE, ACTION_SET_WIDTH, ACTION_SET_ACTIVITY_STAGE, + ACTION_GO_BACK, + ACTION_GO_TO_FLYOUT, Action, } from './actions'; import { LAYOUT_MODE_SIDE_BY_SIDE, LEVEL_MAIN, STAGE_OPENING } from './const'; @@ -65,10 +67,11 @@ export function flyoutManagerReducer( if (level === LEVEL_MAIN) { const newSession: FlyoutSession = { - main: flyoutId, + mainFlyoutId: flyoutId, title: title, - child: null, + childFlyoutId: null, }; + return { ...state, sessions: [...state.sessions, newSession], @@ -85,7 +88,7 @@ export function flyoutManagerReducer( updatedSessions[currentSessionIndex] = { ...updatedSessions[currentSessionIndex], - child: flyoutId, + childFlyoutId: flyoutId, }; return { ...state, sessions: updatedSessions, flyouts: newFlyouts }; @@ -107,14 +110,14 @@ export function flyoutManagerReducer( if (removedFlyout.level === LEVEL_MAIN) { // Find the session that contains this main flyout const sessionToRemove = state.sessions.find( - (session) => session.main === action.flyoutId + (session) => session.mainFlyoutId === action.flyoutId ); if (sessionToRemove) { // Remove all flyouts associated with this session (main + child) const flyoutsToRemove = new Set([action.flyoutId]); - if (sessionToRemove.child) { - flyoutsToRemove.add(sessionToRemove.child); + if (sessionToRemove.childFlyoutId) { + flyoutsToRemove.add(sessionToRemove.childFlyoutId); } const newFlyouts = state.flyouts.filter( @@ -122,7 +125,7 @@ export function flyoutManagerReducer( ); const newSessions = state.sessions.filter( - (session) => session.main !== action.flyoutId + (session) => session.mainFlyoutId !== action.flyoutId ); return { ...state, sessions: newSessions, flyouts: newFlyouts }; @@ -141,10 +144,12 @@ export function flyoutManagerReducer( const updatedSessions = [...state.sessions]; const currentSessionIndex = updatedSessions.length - 1; - if (updatedSessions[currentSessionIndex].child === action.flyoutId) { + if ( + updatedSessions[currentSessionIndex].childFlyoutId === action.flyoutId + ) { updatedSessions[currentSessionIndex] = { ...updatedSessions[currentSessionIndex], - child: null, + childFlyoutId: null, }; } @@ -163,7 +168,7 @@ export function flyoutManagerReducer( updatedSessions[currentSessionIndex] = { ...updatedSessions[currentSessionIndex], - child: action.flyoutId, + childFlyoutId: action.flyoutId, }; return { ...state, sessions: updatedSessions }; @@ -195,6 +200,63 @@ export function flyoutManagerReducer( return { ...state, flyouts: updatedFlyouts }; } + // Go back one session (remove current session from stack) + case ACTION_GO_BACK: { + if (state.sessions.length === 0) { + return state; + } + + const currentSessionIndex = state.sessions.length - 1; + const currentSession = state.sessions[currentSessionIndex]; + + // Close all flyouts in the current session + const flyoutsToRemove = new Set([currentSession.mainFlyoutId]); + if (currentSession.childFlyoutId) { + flyoutsToRemove.add(currentSession.childFlyoutId); + } + + const newFlyouts = state.flyouts.filter( + (f) => !flyoutsToRemove.has(f.flyoutId) + ); + + const newSessions = state.sessions.slice(0, currentSessionIndex); + + return { ...state, sessions: newSessions, flyouts: newFlyouts }; + } + + // Navigate to a specific flyout (remove all sessions after it) + case ACTION_GO_TO_FLYOUT: { + const { flyoutId } = action; + + // Find the session containing the target flyout + const targetSessionIndex = state.sessions.findIndex( + (session) => session.mainFlyoutId === flyoutId + ); + + if (targetSessionIndex === -1) { + return state; // Target flyout not found + } + + // Close all sessions after the target session + const sessionsToClose = state.sessions.slice(targetSessionIndex + 1); + const flyoutsToRemove = new Set(); + + sessionsToClose.forEach((session) => { + flyoutsToRemove.add(session.mainFlyoutId); + if (session.childFlyoutId) { + flyoutsToRemove.add(session.childFlyoutId); + } + }); + + const newFlyouts = state.flyouts.filter( + (f) => !flyoutsToRemove.has(f.flyoutId) + ); + + const newSessions = state.sessions.slice(0, targetSessionIndex + 1); + + return { ...state, sessions: newSessions, flyouts: newFlyouts }; + } + default: return state; } diff --git a/packages/eui/src/components/flyout/manager/selectors.test.tsx b/packages/eui/src/components/flyout/manager/selectors.test.tsx index a618bcb2a8a..51fc6228872 100644 --- a/packages/eui/src/components/flyout/manager/selectors.test.tsx +++ b/packages/eui/src/components/flyout/manager/selectors.test.tsx @@ -44,8 +44,8 @@ const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { // Mock data const mockState = { sessions: [ - { main: 'main-1', child: 'child-1' }, - { main: 'main-2', child: null }, + { mainFlyoutId: 'main-1', childFlyoutId: 'child-1' }, + { mainFlyoutId: 'main-2', childFlyoutId: null }, ], flyouts: [ { flyoutId: 'main-1', level: LEVEL_MAIN, size: 'l', width: 600 }, @@ -79,7 +79,10 @@ describe('flyout manager selectors', () => { wrapper: TestWrapper, }); - expect(result.current).toEqual({ main: 'main-1', child: 'child-1' }); + expect(result.current).toEqual({ + mainFlyoutId: 'main-1', + childFlyoutId: 'child-1', + }); }); it('should return session when flyout ID matches child', () => { @@ -87,7 +90,10 @@ describe('flyout manager selectors', () => { wrapper: TestWrapper, }); - expect(result.current).toEqual({ main: 'main-1', child: 'child-1' }); + expect(result.current).toEqual({ + mainFlyoutId: 'main-1', + childFlyoutId: 'child-1', + }); }); it('should return null when flyout ID does not match any session', () => { @@ -112,8 +118,11 @@ describe('flyout manager selectors', () => { }); // The selector treats null as a literal value to search for - // It finds the session where child: null matches flyoutId: null - expect(result.current).toEqual({ main: 'main-2', child: null }); + // It finds the session where childFlyoutId: null matches flyoutId: null + expect(result.current).toEqual({ + mainFlyoutId: 'main-2', + childFlyoutId: null, + }); }); }); @@ -226,7 +235,10 @@ describe('flyout manager selectors', () => { wrapper: TestWrapper, }); - expect(result.current).toEqual({ main: 'main-2', child: null }); + expect(result.current).toEqual({ + mainFlyoutId: 'main-2', + childFlyoutId: null, + }); }); it('should return null when no sessions exist', () => { @@ -287,8 +299,8 @@ describe('flyout manager selectors', () => { const stateWithChildCurrent = { ...mockState, sessions: [ - { main: 'main-2', child: null }, - { main: 'main-1', child: 'child-1' }, // Make this the current session + { mainFlyoutId: 'main-2', childFlyoutId: null }, + { mainFlyoutId: 'main-1', childFlyoutId: 'child-1' }, // Make this the current session ], }; (useFlyoutManagerReducer as jest.Mock).mockReturnValue({ @@ -459,7 +471,7 @@ describe('flyout manager selectors', () => { }); // The selector checks if the flyout ID has a session with a child - // Since child-1 is in a session with child: 'child-1', it returns true + // Since child-1 is in a session with childFlyoutId: 'child-1', it returns true expect(result.current).toBe(true); }); }); @@ -516,7 +528,9 @@ describe('flyout manager selectors', () => { it('should handle sessions with missing flyout references', () => { const invalidState = { ...mockState, - sessions: [{ main: 'main-1', child: 'non-existent-child' }], + sessions: [ + { mainFlyoutId: 'main-1', childFlyoutId: 'non-existent-child' }, + ], flyouts: [{ flyoutId: 'main-1', level: LEVEL_MAIN }], }; (useFlyoutManagerReducer as jest.Mock).mockReturnValue({ @@ -533,8 +547,8 @@ describe('flyout manager selectors', () => { }); expect(result.current).toEqual({ - main: 'main-1', - child: 'non-existent-child', + mainFlyoutId: 'main-1', + childFlyoutId: 'non-existent-child', }); }); }); diff --git a/packages/eui/src/components/flyout/manager/selectors.ts b/packages/eui/src/components/flyout/manager/selectors.ts index 436d3c53f33..baab124a43b 100644 --- a/packages/eui/src/components/flyout/manager/selectors.ts +++ b/packages/eui/src/components/flyout/manager/selectors.ts @@ -17,7 +17,7 @@ export const useSession = (flyoutId?: string | null) => { return ( context.state.sessions.find( - (s) => s.main === flyoutId || s.child === flyoutId + (s) => s.mainFlyoutId === flyoutId || s.childFlyoutId === flyoutId ) || null ); }; @@ -29,7 +29,8 @@ export const useHasActiveSession = () => !!useCurrentSession(); export const useIsFlyoutActive = (flyoutId: string) => { const currentSession = useCurrentSession(); return ( - currentSession?.main === flyoutId || currentSession?.child === flyoutId + currentSession?.mainFlyoutId === flyoutId || + currentSession?.childFlyoutId === flyoutId ); }; @@ -59,14 +60,14 @@ export const useCurrentSession = () => { /** The registered state of the current session's main flyout, if present. */ export const useCurrentMainFlyout = () => { const currentSession = useCurrentSession(); - const mainFlyoutId = currentSession?.main; + const mainFlyoutId = currentSession?.mainFlyoutId; return useFlyout(mainFlyoutId); }; /** The registered state of the current session's child flyout, if present. */ export const useCurrentChildFlyout = () => { const currentSession = useCurrentSession(); - const childFlyoutId = currentSession?.child; + const childFlyoutId = currentSession?.childFlyoutId; return useFlyout(childFlyoutId); }; @@ -77,12 +78,12 @@ export const useFlyoutWidth = (flyoutId?: string | null) => /** The configured size of the parent (main) flyout for a given child flyout ID. */ export const useParentFlyoutSize = (childFlyoutId: string) => { const session = useSession(childFlyoutId); - const parentFlyout = useFlyout(session?.main); + const parentFlyout = useFlyout(session?.mainFlyoutId); return parentFlyout?.size; }; /** True if the provided `flyoutId` is the main flyout and it currently has a child. */ export const useHasChildFlyout = (flyoutId: string) => { const session = useSession(flyoutId); - return !!session?.child; + return !!session?.childFlyoutId; }; diff --git a/packages/eui/src/components/flyout/manager/types.ts b/packages/eui/src/components/flyout/manager/types.ts index f0f41aeee6f..28d7dfbdbad 100644 --- a/packages/eui/src/components/flyout/manager/types.ts +++ b/packages/eui/src/components/flyout/manager/types.ts @@ -48,9 +48,12 @@ export interface EuiManagedFlyoutState { } export interface FlyoutSession { - main: string; + /** ID of the main flyout for this session */ + mainFlyoutId: string; + /** ID of the child flyout for this session */ + childFlyoutId: string | null; + /** Title of the main flyout in this session */ title: string; - child: string | null; } export interface EuiFlyoutManagerState { @@ -75,4 +78,10 @@ export interface FlyoutManagerApi { closeFlyout: (flyoutId: string) => void; setActiveFlyout: (flyoutId: string | null) => void; setFlyoutWidth: (flyoutId: string, width: number) => void; + goBack: () => void; + goToFlyout: (flyoutId: string) => void; + getHistoryItems: () => Array<{ + title: string; + onClick: () => void; + }>; } diff --git a/packages/eui/src/components/flyout/types.ts b/packages/eui/src/components/flyout/types.ts new file mode 100644 index 00000000000..3fa59729c19 --- /dev/null +++ b/packages/eui/src/components/flyout/types.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 type EuiFlyoutCloseEvent = MouseEvent | TouchEvent | KeyboardEvent; diff --git a/packages/eui/src/components/flyout/use_open_state.ts b/packages/eui/src/components/flyout/use_open_state.ts index 80bfcbfe290..644e064e40d 100644 --- a/packages/eui/src/components/flyout/use_open_state.ts +++ b/packages/eui/src/components/flyout/use_open_state.ts @@ -7,7 +7,9 @@ */ import { AnimationEventHandler, useCallback, useEffect, useState } from 'react'; -import { EuiFlyoutProps } from './flyout'; +import type { EuiFlyoutProps } from './flyout'; +import { useIsInManagedFlyout } from './manager'; +import type { EuiFlyoutCloseEvent } from './types'; export type EuiFlyoutOpenState = 'opening' | 'open' | 'closing' | 'closed'; @@ -25,6 +27,7 @@ export const useEuiFlyoutOpenState = ({ const [openState, setOpenState] = useState( isOpen ? 'open' : 'closed' ); + const isInManagedFlyout = useIsInManagedFlyout(); useEffect(() => { // Check for matching state @@ -46,11 +49,12 @@ export const useEuiFlyoutOpenState = ({ }, [isOpen]); useEffect(() => { - if (openState === 'closed') { + // For managed flyouts, don't auto-call onClose - let the manager handle it + if (openState === 'closed' && !isInManagedFlyout) { onClose(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [openState]); + }, [openState, isInManagedFlyout]); const onAnimationEnd = useCallback(() => { if (openState === 'closing') { @@ -63,7 +67,7 @@ export const useEuiFlyoutOpenState = ({ }, [openState, setOpenState]); const closeFlyout = useCallback( - (event?: MouseEvent | TouchEvent | KeyboardEvent) => { + (event?: EuiFlyoutCloseEvent) => { if (openState === 'closed' || openState === 'closing') { return; }