diff --git a/packages/eui/package.json b/packages/eui/package.json index 47d5322637b..a82737502b4 100644 --- a/packages/eui/package.json +++ b/packages/eui/package.json @@ -86,6 +86,7 @@ "unified": "^9.2.2", "unist-util-visit": "^2.0.3", "url-parse": "^1.5.10", + "use-sync-external-store": "^1.6.0", "uuid": "^8.3.0", "vfile": "^4.2.1" }, diff --git a/packages/eui/src/components/collapsible_nav/collapsible_nav.tsx b/packages/eui/src/components/collapsible_nav/collapsible_nav.tsx index d2216ab948a..e57ad26ef2a 100644 --- a/packages/eui/src/components/collapsible_nav/collapsible_nav.tsx +++ b/packages/eui/src/components/collapsible_nav/collapsible_nav.tsx @@ -116,6 +116,7 @@ export const EuiCollapsibleNav: FunctionComponent = ({ const flyout = ( = ({ aria-label={defaultAriaLabel} {...rest} // EuiCollapsibleNav is significantly less permissive than EuiFlyout id={flyoutID} + session={false} css={cssStyles} className={classes} size={width} diff --git a/packages/eui/src/components/flyout/README.md b/packages/eui/src/components/flyout/README.md index 9be70fc3e7d..ce0f112ff90 100644 --- a/packages/eui/src/components/flyout/README.md +++ b/packages/eui/src/components/flyout/README.md @@ -9,6 +9,12 @@ The main flyout component that serves as the entry point for all flyout function - **Standard flyouts**: Default behavior renders `EuiFlyoutComponent` - **Resizable flyouts**: `EuiFlyoutResizable` component exists but is not integrated into main routing logic +#### `session` Prop Behavior +The `session` prop controls whether a flyout participates in the session management system: +- **`session={true}`**: Explicitly opt-in to session management. The flyout will be managed as a main flyout. +- **`session={false}`**: Explicitly opt-out of session management. The flyout will render as an unmanaged standard flyout, bypassing all session logic. This is useful for wrapper components like `EuiCollapsibleNav` that manage their own lifecycle. +- **`session={undefined}`** (default): Automatically participate in sessions if one is active. If no session is active, renders as a standard flyout. + ### `src/components/flyout/flyout.component.tsx` The core flyout implementation with comprehensive functionality: - **Props**: Extensive configuration options including size, padding, positioning, focus management diff --git a/packages/eui/src/components/flyout/flyout.test.tsx b/packages/eui/src/components/flyout/flyout.test.tsx index 475c0336927..0664794764d 100644 --- a/packages/eui/src/components/flyout/flyout.test.tsx +++ b/packages/eui/src/components/flyout/flyout.test.tsx @@ -437,4 +437,116 @@ describe('EuiFlyout', () => { ).not.toBeTruthy(); }); }); + + describe('flyout routing logic', () => { + // Mock the manager hooks to control routing behavior + const mockUseHasActiveSession = jest.fn(); + const mockUseIsInManagedFlyout = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + // Mock the manager hooks + jest.doMock('./manager', () => ({ + ...jest.requireActual('./manager'), + useHasActiveSession: mockUseHasActiveSession, + useIsInManagedFlyout: mockUseIsInManagedFlyout, + })); + }); + + afterEach(() => { + jest.dontMock('./manager'); + }); + + it('routes to child flyout when session is undefined and there is an active session', () => { + // Setup: There's an active session but flyout is not in managed context + mockUseHasActiveSession.mockReturnValue(true); + mockUseIsInManagedFlyout.mockReturnValue(false); + + const { getByTestSubject } = render( + {}} + data-test-subj="child-flyout" + // session is undefined (not explicitly set) + /> + ); + + // Should render as child flyout (EuiFlyoutChild) + const flyout = getByTestSubject('child-flyout'); + expect(flyout).toBeInTheDocument(); + }); + + it('routes to main flyout when session is explicitly true', () => { + // Setup: There's an active session and flyout is not in managed context + mockUseHasActiveSession.mockReturnValue(true); + mockUseIsInManagedFlyout.mockReturnValue(false); + + const { getByTestSubject } = render( + {}} + data-test-subj="main-flyout" + session={true} // Explicitly creating a new session + flyoutMenuProps={{ title: 'Test Main Flyout' }} // Required for managed flyouts + /> + ); + + // Should render as main flyout (EuiFlyoutMain) + const flyout = getByTestSubject('main-flyout'); + expect(flyout).toBeInTheDocument(); + }); + + it('routes to main flyout when session is explicitly false and there is an active session', () => { + // Setup: There's an active session and flyout is not in managed context + mockUseHasActiveSession.mockReturnValue(true); + mockUseIsInManagedFlyout.mockReturnValue(false); + + const { getByTestSubject } = render( + {}} + data-test-subj="main-flyout" + session={false} // Explicitly not creating a new session, but still routes to main + flyoutMenuProps={{ title: 'Test Main Flyout' }} // Required for managed flyouts + /> + ); + + // Should render as main flyout (EuiFlyoutMain) + const flyout = getByTestSubject('main-flyout'); + expect(flyout).toBeInTheDocument(); + }); + + it('routes to child flyout when in managed context and there is an active session', () => { + // Setup: There's an active session and flyout is in managed context + mockUseHasActiveSession.mockReturnValue(true); + mockUseIsInManagedFlyout.mockReturnValue(true); + + const { getByTestSubject } = render( + {}} + data-test-subj="child-flyout" + session={undefined} // Not explicitly set + /> + ); + + // Should render as child flyout (EuiFlyoutChild) + const flyout = getByTestSubject('child-flyout'); + expect(flyout).toBeInTheDocument(); + }); + + it('routes to standard flyout when there is no active session', () => { + // Setup: No active session + mockUseHasActiveSession.mockReturnValue(false); + mockUseIsInManagedFlyout.mockReturnValue(false); + + const { getByTestSubject } = render( + {}} + data-test-subj="standard-flyout" + session={undefined} // Not explicitly set + /> + ); + + // Should render as standard flyout (EuiFlyoutComponent) + const flyout = getByTestSubject('standard-flyout'); + expect(flyout).toBeInTheDocument(); + }); + }); }); diff --git a/packages/eui/src/components/flyout/flyout.tsx b/packages/eui/src/components/flyout/flyout.tsx index adf0abea536..7640912e3f4 100644 --- a/packages/eui/src/components/flyout/flyout.tsx +++ b/packages/eui/src/components/flyout/flyout.tsx @@ -14,12 +14,8 @@ import { type EuiFlyoutComponentProps, } from './flyout.component'; -import { - EuiFlyoutChild, - EuiFlyoutMain, - useHasActiveSession, - useIsInManagedFlyout, -} from './manager'; +import { EuiFlyoutChild, EuiFlyoutMain, useHasActiveSession } from './manager'; +import { EuiFlyoutMenuContext } from './flyout_menu_context'; export type { EuiFlyoutSize, @@ -51,42 +47,52 @@ export const EuiFlyout = forwardRef< usePropsWithComponentDefaults('EuiFlyout', props); const hasActiveSession = useHasActiveSession(); const isUnmanagedFlyout = useRef(false); - const isInManagedFlyout = useIsInManagedFlyout(); /* - * Flyout routing logic - * 1. Main Flyout: When session={true} OR when there's an active session and this flyout - * is rendered outside of a managed flyout context. - * 2. Child Flyout: When there's an active session AND this flyout IS rendered within a - * managed flyout context. - * 3. Standard Flyout: Default fallback when neither condition is met. + * Flyout routing logic: + * - session={true} → Main flyout (creates new session) + * - session={undefined} + active session → Child flyout (auto-joins, works across React roots!) + * - session={undefined} + no session → Standard flyout + * - session={false} → Standard flyout (explicit opt-out) */ - if (session === true || (hasActiveSession && !isInManagedFlyout)) { - if (isUnmanagedFlyout.current) { - // TODO: @tkajtoch - We need to find a better way to handle the missing event. - onClose?.({} as any); - return null; + if (session !== false) { + if (session === true) { + if (isUnmanagedFlyout.current) { + // TODO: @tkajtoch - We need to find a better way to handle the missing event. + 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 ( - - ); + // Auto-join existing session as child + if (hasActiveSession && session === undefined) { + return ( + + ); + } } // TODO: if resizeable={true}, render EuiResizableFlyout. isUnmanagedFlyout.current = true; - return ; + return ( + + + + ); }); + EuiFlyout.displayName = 'EuiFlyout'; diff --git a/packages/eui/src/components/flyout/manager/flyout_main.test.tsx b/packages/eui/src/components/flyout/manager/flyout_main.test.tsx index 27b947abf77..d2c05959b22 100644 --- a/packages/eui/src/components/flyout/manager/flyout_main.test.tsx +++ b/packages/eui/src/components/flyout/manager/flyout_main.test.tsx @@ -29,19 +29,16 @@ jest.mock('./flyout_managed', () => ({ // Keep layout/ID hooks deterministic jest.mock('./hooks', () => ({ - useFlyoutManagerReducer: () => ({ + useFlyoutManager: () => ({ state: { sessions: [], flyouts: [], layoutMode: 'side-by-side' }, dispatch: jest.fn(), addFlyout: jest.fn(), closeFlyout: jest.fn(), setActiveFlyout: jest.fn(), setFlyoutWidth: jest.fn(), - }), - useFlyoutManager: () => ({ - state: { sessions: [], flyouts: [], layoutMode: 'side-by-side' }, - addFlyout: jest.fn(), - closeFlyout: jest.fn(), - setFlyoutWidth: jest.fn(), + goBack: jest.fn(), + goToFlyout: jest.fn(), + historyItems: [], }), useHasChildFlyout: () => false, useFlyoutId: (id?: string) => id ?? 'generated-id', 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 bea58e358e7..be678d38556 100644 --- a/packages/eui/src/components/flyout/manager/flyout_managed.test.tsx +++ b/packages/eui/src/components/flyout/manager/flyout_managed.test.tsx @@ -59,15 +59,11 @@ const createMockFunctions = () => ({ setFlyoutWidth: jest.fn(), goBack: jest.fn(), goToFlyout: jest.fn(), - getHistoryItems: jest.fn(() => []), + historyItems: [], }); // Mock hooks that would otherwise depend on ResizeObserver or animation timing jest.mock('./hooks', () => ({ - useFlyoutManagerReducer: () => ({ - state: createMockState(), - ...createMockFunctions(), - }), useFlyoutManager: () => ({ state: createMockState(), ...createMockFunctions(), diff --git a/packages/eui/src/components/flyout/manager/flyout_managed.tsx b/packages/eui/src/components/flyout/manager/flyout_managed.tsx index cf087edcb87..7b5d4580d8c 100644 --- a/packages/eui/src/components/flyout/manager/flyout_managed.tsx +++ b/packages/eui/src/components/flyout/manager/flyout_managed.tsx @@ -82,8 +82,13 @@ export const EuiManagedFlyout = ({ const flyoutId = useFlyoutId(id); const flyoutRef = useRef(null); - const { addFlyout, closeFlyout, setFlyoutWidth, goBack, getHistoryItems } = - useFlyoutManager(); + const { + addFlyout, + closeFlyout, + setFlyoutWidth, + goBack, + historyItems: _historyItems, + } = useFlyoutManager(); const parentSize = useParentFlyoutSize(flyoutId); const parentFlyout = useCurrentMainFlyout(); const layoutMode = useFlyoutLayoutMode(); @@ -219,8 +224,9 @@ export const EuiManagedFlyout = ({ // Note: history controls are only relevant for main flyouts const historyItems = useMemo(() => { - return level === LEVEL_MAIN ? getHistoryItems() : undefined; - }, [level, getHistoryItems]); + const result = level === LEVEL_MAIN ? _historyItems : undefined; + return result; + }, [level, _historyItems]); const backButtonProps = useMemo(() => { return level === LEVEL_MAIN ? { onClick: goBack } : undefined; diff --git a/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx b/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx index c5676faa07d..3d6b425f017 100644 --- a/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx +++ b/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx @@ -6,17 +6,29 @@ * 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 { Meta, StoryObj } from '@storybook/react'; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { Root } from 'react-dom/client'; +import { createRoot } from 'react-dom/client'; import { EuiButton, EuiCodeBlock, EuiDescriptionList, + EuiFlexGroup, + EuiFlexItem, EuiFlyoutBody, - EuiPageTemplate, - EuiPageTemplateProps, + EuiFlyoutHeader, + EuiPanel, + EuiProvider, EuiSpacer, EuiSwitch, EuiSwitchEvent, @@ -24,7 +36,7 @@ import { EuiTitle, } from '../..'; import { EuiFlyout } from '../flyout'; -import { useFlyoutManager, useCurrentSession } from './hooks'; +import { useCurrentSession, useFlyoutManager } from './hooks'; const meta: Meta = { title: 'Layout/EuiFlyout/Flyout Manager', @@ -51,6 +63,31 @@ interface FlyoutSessionProps { hasChildBackground: boolean; } +const DisplayContext: React.FC<{ title: string }> = ({ title }) => { + const flyoutManager = useFlyoutManager(); + const currentSession = useCurrentSession(); + return ( + <> + +

{title}

+
+ + + {JSON.stringify( + { + flyoutManager: flyoutManager + ? { state: flyoutManager.state } + : null, + currentSession: currentSession ? currentSession : null, + }, + null, + 2 + )} + + + ); +}; + const FlyoutSession: React.FC = React.memo((props) => { const { title, @@ -187,11 +224,66 @@ const FlyoutSession: React.FC = React.memo((props) => { FlyoutSession.displayName = 'FlyoutSession'; -const ExampleComponent = () => { - const panelled: EuiPageTemplateProps['panelled'] = undefined; - const restrictWidth: EuiPageTemplateProps['restrictWidth'] = false; - const bottomBorder: EuiPageTemplateProps['bottomBorder'] = 'extended'; +const NonSessionFlyout: React.FC<{ + flyoutType: 'overlay' | 'push'; +}> = React.memo(({ flyoutType }) => { + const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); + + const handleOpenFlyout = () => { + setIsFlyoutVisible(true); + }; + + const flyoutOnClose = useCallback(() => { + action('close non-session flyout')(); + setIsFlyoutVisible(false); + }, []); + return ( + <> + + + Open non-session flyout + + + {isFlyoutVisible && ( + + + +

Non-session Flyout

+
+
+ + +

This is the content of a non-session flyout.

+ + +
+
+
+ )} + + ); +}); + +NonSessionFlyout.displayName = 'NonSessionFlyout'; + +const MultiSessionFlyoutDemo: React.FC = () => { const [flyoutType, setFlyoutType] = useState<'overlay' | 'push'>('overlay'); const [hasChildBackground, setChildBackgroundShaded] = useState(false); @@ -199,9 +291,6 @@ const ExampleComponent = () => { setFlyoutType(e.target.checked ? 'push' : 'overlay'); }, []); - const flyoutManager = useFlyoutManager(); - const currentSession = useCurrentSession(); - const listItems = useMemo( () => [ { @@ -303,72 +392,237 @@ const ExampleComponent = () => { /> ), }, + { + title: 'Non-session flyout', + description: , + }, ], [flyoutType, hasChildBackground] ); return ( - - + - - -

Options

-
- - - - setChildBackgroundShaded((prev) => !prev)} - /> -
- - - - - -

Contexts

-
- - - {JSON.stringify( - { - flyoutManager: flyoutManager - ? { state: flyoutManager.state } - : null, - currentSession: currentSession ? currentSession : null, - }, - null, - 2 - )} - -
-
+ + setChildBackgroundShaded((prev) => !prev)} + /> + + + + + ); }; export const MultiSessionExample: StoryObj = { name: 'Multi-session example', - render: () => , - parameters: { - layout: 'fullscreen', - }, + render: () => , +}; + +const ExternalRootChildFlyout: React.FC<{ parentId: string }> = ({ + parentId, +}) => { + const [isOpen, setIsOpen] = useState(false); + + const handleToggle = () => { + setIsOpen((prev) => !prev); + }; + + const handleClose = () => { + setIsOpen(false); + }; + + return ( + + +

Root within {parentId}

+
+ + + Open child flyout + + {isOpen && ( + + + +

+ This is a child flyout rendered in a completely separate React + root! It shares the same flyout manager state as the parent. +

+ +

Parent ID: {parentId}

+
+
+
+ )} +
+ ); +}; + +const ExternalRootFlyout: React.FC<{ id: string }> = ({ id }) => { + const [isOpen, setIsOpen] = useState(false); + const buttonContainerRef = useRef(null); + const buttonRootRef = useRef(null); + + // Manage the React root lifecycle for the child flyout button + useEffect(() => { + if (!isOpen) { + // Clean up when closing + if (buttonRootRef.current) { + buttonRootRef.current.unmount(); + buttonRootRef.current = null; + } + return; + } + + // When opening, wait for flyout to render before creating nested root + const timer = setTimeout(() => { + if (buttonContainerRef.current && !buttonRootRef.current) { + const newRoot = createRoot(buttonContainerRef.current); + newRoot.render( + + + + ); + buttonRootRef.current = newRoot; + } + }, 0); + + return () => { + clearTimeout(timer); + // Don't unmount here - let the !isOpen case handle cleanup + }; + }, [isOpen, id]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (buttonRootRef.current) { + buttonRootRef.current.unmount(); + buttonRootRef.current = null; + } + }; + }, []); + + return ( + + +

{id}

+
+ + setIsOpen((prev) => !prev)}> + {isOpen ? 'Close flyout' : 'Open flyout'} + + {isOpen && ( + setIsOpen(false)} + flyoutMenuProps={{ title: `${id} flyout` }} + > + + +

+ This flyout lives in a separate React root but shares the same + manager state. Closing it here should update all other flyout + menus and history. +

+ +

+ Below is a button rendered in a separate React root that opens a + child flyout: +

+ + {/* Container for the button React root - inside the main flyout */} +
+ + + + )} + + ); +}; + +const MultiRootFlyoutDemo: React.FC = () => { + const secondaryRootRef = useRef(null); + const tertiaryRootRef = useRef(null); + const mountedRootsRef = useRef([]); + + useLayoutEffect(() => { + if ( + secondaryRootRef.current && + tertiaryRootRef.current && + mountedRootsRef.current.length === 0 + ) { + const containers = [ + { container: secondaryRootRef.current, id: 'Secondary root' }, + { container: tertiaryRootRef.current, id: 'Tertiary root' }, + ]; + + mountedRootsRef.current = containers.map(({ container, id }) => { + const root = createRoot(container); + root.render( + + + + ); + return root; + }); + } + + return () => { + mountedRootsRef.current.forEach((root) => root.unmount()); + mountedRootsRef.current = []; + }; + }, []); + + return ( + <> + +

Multiple React roots

+
+ + +

+ These flyouts are rendered in separate React roots but share the same + flyout manager state. Open/close any flyout and watch the shared state + update below. +

+
+ + + +
+ + +
+ + + + + + ); +}; + +export const MultiRootSyncExample: StoryObj = { + name: 'Multi-root sync', + render: () => , }; diff --git a/packages/eui/src/components/flyout/manager/hooks.test.tsx b/packages/eui/src/components/flyout/manager/hooks.test.tsx index dcc5b9a8a32..c86e574b8b8 100644 --- a/packages/eui/src/components/flyout/manager/hooks.test.tsx +++ b/packages/eui/src/components/flyout/manager/hooks.test.tsx @@ -7,9 +7,7 @@ */ import { renderHook } from '../../../test/rtl'; -import { act } from '@testing-library/react'; -import { useFlyoutManagerReducer, useFlyoutId } from './hooks'; -import { LEVEL_MAIN, LEVEL_CHILD } from './const'; +import { useFlyoutId } from './hooks'; // Mock the warnOnce service but keep other actual exports (e.g., useGeneratedHtmlId) jest.mock('../../../services', () => { @@ -20,352 +18,35 @@ jest.mock('../../../services', () => { }; }); -// Mock the useFlyout selector -jest.mock('./selectors', () => ({ - useFlyout: jest.fn(), - useIsFlyoutRegistered: jest.fn(), -})); +describe('useFlyoutId', () => { + it('should return a stable ID when no id is provided', () => { + const { result, rerender } = renderHook(() => useFlyoutId()); -describe('flyout manager hooks', () => { - const { - useFlyout: mockUseFlyout, - useIsFlyoutRegistered: mockUseIsFlyoutRegistered, - } = jest.requireMock('./selectors'); + const firstId = result.current; + rerender(); + const secondId = result.current; - beforeEach(() => { - mockUseFlyout.mockClear(); - mockUseIsFlyoutRegistered.mockClear(); + expect(firstId).toBe(secondId); + expect(typeof firstId).toBe('string'); + expect(firstId).toMatch(/^flyout-_generated-id-\d+$/); }); - describe('useFlyoutManagerReducer', () => { - it('should return initial state and bound action creators', () => { - const { result } = renderHook(() => useFlyoutManagerReducer()); + it('should return the provided id when given', () => { + const customId = 'my-custom-flyout-id'; + const { result } = renderHook(() => useFlyoutId(customId)); - expect(result.current.state).toEqual({ - sessions: [], - flyouts: [], - layoutMode: 'side-by-side', - }); - expect(typeof result.current.dispatch).toBe('function'); - expect(typeof result.current.addFlyout).toBe('function'); - expect(typeof result.current.closeFlyout).toBe('function'); - expect(typeof result.current.setActiveFlyout).toBe('function'); - expect(typeof result.current.setFlyoutWidth).toBe('function'); - }); - - it('should accept custom initial state', () => { - const customInitialState = { - sessions: [], - flyouts: [], - layoutMode: 'stacked' as const, - }; - - const { result } = renderHook(() => - useFlyoutManagerReducer(customInitialState) - ); - - expect(result.current.state.layoutMode).toBe('stacked'); - }); - - it('should dispatch actions correctly', () => { - const { result } = renderHook(() => useFlyoutManagerReducer()); - - act(() => { - result.current.addFlyout('main-1', 'main', LEVEL_MAIN, 'l'); - }); - - expect(result.current.state.flyouts).toHaveLength(1); - expect(result.current.state.flyouts[0]).toEqual({ - flyoutId: 'main-1', - level: LEVEL_MAIN, - size: 'l', - activityStage: 'opening', - }); - expect(result.current.state.sessions).toHaveLength(1); - }); - - it('should handle multiple actions in sequence', () => { - const { result } = renderHook(() => useFlyoutManagerReducer()); - - act(() => { - result.current.addFlyout('main-1', 'main', LEVEL_MAIN); - result.current.addFlyout('child-1', 'child', LEVEL_CHILD); - result.current.setActiveFlyout('child-1'); - result.current.setFlyoutWidth('main-1', 600); - result.current.setFlyoutWidth('child-1', 400); - }); - - expect(result.current.state.flyouts).toHaveLength(2); - 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); - }); - - it('should maintain action creator stability across renders', () => { - const { result, rerender } = renderHook(() => useFlyoutManagerReducer()); - - const initialAddFlyout = result.current.addFlyout; - const initialCloseFlyout = result.current.closeFlyout; - - rerender(); - - expect(result.current.addFlyout).toBe(initialAddFlyout); - expect(result.current.closeFlyout).toBe(initialCloseFlyout); - }); - - it('should handle complex state transitions', () => { - const { result } = renderHook(() => useFlyoutManagerReducer()); - - // Create a complex scenario - act(() => { - // Add main flyout - result.current.addFlyout('main-1', 'main', LEVEL_MAIN, 'l'); - // Add child flyout - result.current.addFlyout('child-1', 'child', LEVEL_CHILD, 'm'); - // Set child as active - result.current.setActiveFlyout('child-1'); - // Update widths - result.current.setFlyoutWidth('main-1', 600); - result.current.setFlyoutWidth('child-1', 400); - // Close child flyout - result.current.closeFlyout('child-1'); - // Close main flyout - result.current.closeFlyout('main-1'); - }); - - expect(result.current.state.flyouts).toHaveLength(0); - expect(result.current.state.sessions).toHaveLength(0); - }); + expect(result.current).toBe(customId); }); - describe('useFlyoutId', () => { - it('should return provided flyout ID when it is not registered', () => { - mockUseIsFlyoutRegistered.mockReturnValue(false); // ID is available - const { result } = renderHook(() => useFlyoutId('existing-id')); - - expect(mockUseIsFlyoutRegistered).toHaveBeenCalledWith('existing-id'); - expect(result.current).toBe('existing-id'); - }); - - it('should generate deterministic ID when no ID is provided', () => { - const { result } = renderHook(() => useFlyoutId()); - - expect(result.current).toMatch(/^flyout-/); - expect(typeof result.current).toBe('string'); - }); - - it('should generate deterministic ID when provided ID is empty', () => { - const { result } = renderHook(() => useFlyoutId('')); - - expect(result.current).toMatch(/^flyout-/); - }); - - it('should generate deterministic ID when provided ID is undefined', () => { - const { result } = renderHook(() => useFlyoutId(undefined)); - - expect(result.current).toMatch(/^flyout-/); - }); - - it('should maintain ID consistency across renders', () => { - mockUseIsFlyoutRegistered.mockReturnValue(false); // ID is available - const { result, rerender } = renderHook(() => useFlyoutId('stable-id')); - - const initialId = result.current; - rerender(); - rerender(); - - expect(result.current).toBe(initialId); - }); - - it('should handle different IDs for different components', () => { - mockUseIsFlyoutRegistered.mockReturnValue(false); // IDs are available - const { result: result1 } = renderHook(() => useFlyoutId('id-1')); - const { result: result2 } = renderHook(() => useFlyoutId('id-2')); - - expect(result1.current).toBe('id-1'); - expect(result2.current).toBe('id-2'); - }); - - it('should handle generated IDs for different components', () => { - const { result: result1 } = renderHook(() => useFlyoutId()); - const { result: result2 } = renderHook(() => useFlyoutId()); - - expect(result1.current).not.toBe(result2.current); - expect(result1.current).toMatch(/^flyout-/); - expect(result2.current).toMatch(/^flyout-/); - }); - - it('should handle ID conflicts gracefully', () => { - // Mock that the ID is already registered (conflict) - mockUseIsFlyoutRegistered.mockReturnValue(true); - - const { result } = renderHook(() => useFlyoutId('conflict-id')); - - expect(result.current).toMatch(/^flyout-/); - expect(result.current).not.toBe('conflict-id'); - }); - - it('should handle multiple ID conflicts', () => { - // Mock multiple conflicts - mockUseIsFlyoutRegistered.mockReturnValue(true); - - const { result } = renderHook(() => useFlyoutId('conflict-1')); - - expect(result.current).toMatch(/^flyout-/); - expect(result.current).not.toBe('conflict-1'); - }); - - it('should handle special characters in provided IDs', () => { - mockUseIsFlyoutRegistered.mockReturnValue(false); // IDs are available - const specialIds = [ - 'flyout-1', - 'flyout_2', - 'flyout.3', - 'flyout-4', - 'FLYOUT-5', - 'Flyout-6', - ]; - - specialIds.forEach((id) => { - const { result } = renderHook(() => useFlyoutId(id)); - expect(result.current).toBe(id); - }); - }); - - it('should handle very long IDs', () => { - mockUseIsFlyoutRegistered.mockReturnValue(false); // ID is available - const longId = 'a'.repeat(1000); - const { result } = renderHook(() => useFlyoutId(longId)); - - expect(result.current).toBe(longId); - }); - - it('should handle empty string IDs', () => { - const { result } = renderHook(() => useFlyoutId('')); - - expect(result.current).toMatch(/^flyout-/); - }); - - it('should handle null IDs', () => { - const { result } = renderHook(() => useFlyoutId(null as any)); - - expect(result.current).toMatch(/^flyout-/); - }); - - it('should maintain ID stability when input changes', () => { - // First call with no ID - generates one - const { result, rerender } = renderHook(() => useFlyoutId()); - const firstId = result.current; - - // Re-render with same input (no ID) - rerender(); - expect(result.current).toBe(firstId); - - // Re-render with different input (still no ID) - rerender(); - expect(result.current).toBe(firstId); - }); - - it('should not change ID when provided ID changes', () => { - const { result, rerender } = renderHook(({ id }) => useFlyoutId(id), { - initialProps: { id: undefined as string | undefined }, - }); - - const generatedId = result.current; - expect(generatedId).toMatch(/^flyout-/); - - // Change to provided ID - mockUseIsFlyoutRegistered.mockReturnValue(false); - rerender({ id: 'provided-id' }); - - expect(result.current).toBe(generatedId); - expect(result.current).not.toBe('provided-id'); - }); - }); - - describe('hook integration', () => { - it('should work together with reducer', () => { - mockUseIsFlyoutRegistered.mockReturnValue(false); // ID is available - const { result: reducerResult } = renderHook(() => - useFlyoutManagerReducer() - ); - const { result: idResult } = renderHook(() => useFlyoutId('test-id')); - - act(() => { - reducerResult.current.addFlyout(idResult.current, 'main', LEVEL_MAIN); - }); - - expect(reducerResult.current.state.flyouts).toHaveLength(1); - expect(reducerResult.current.state.flyouts[0].flyoutId).toBe('test-id'); - }); - - it('should handle multiple flyouts with generated IDs', () => { - const { result: reducerResult } = renderHook(() => - useFlyoutManagerReducer() - ); - const { result: idResult1 } = renderHook(() => useFlyoutId()); - const { result: idResult2 } = renderHook(() => useFlyoutId()); - - act(() => { - reducerResult.current.addFlyout(idResult1.current, 'main', LEVEL_MAIN); - reducerResult.current.addFlyout( - idResult2.current, - 'child', - LEVEL_CHILD - ); - }); - - expect(reducerResult.current.state.flyouts).toHaveLength(2); - expect(reducerResult.current.state.sessions).toHaveLength(1); - expect(reducerResult.current.state.sessions[0].childFlyoutId).toBe( - idResult2.current - ); - }); - }); - - describe('edge cases', () => { - it('should handle rapid state changes', () => { - const { result } = renderHook(() => useFlyoutManagerReducer()); - - act(() => { - // Rapidly add and remove flyouts - for (let i = 0; i < 10; i++) { - result.current.addFlyout(`flyout-${i}`, 'main', LEVEL_MAIN); - result.current.closeFlyout(`flyout-${i}`); - } - }); - - expect(result.current.state.flyouts).toHaveLength(0); - expect(result.current.state.sessions).toHaveLength(0); - }); - - it('should handle concurrent ID generation', () => { - const results = []; - for (let i = 0; i < 5; i++) { - const { result } = renderHook(() => useFlyoutId()); - results.push(result.current); - } - - // All IDs should be unique - const uniqueIds = new Set(results); - expect(uniqueIds.size).toBe(5); - - // All IDs should follow the pattern - results.forEach((id) => { - expect(id).toMatch(/^flyout-/); - }); - }); + it('should return a stable ID when id is provided', () => { + const customId = 'my-custom-flyout-id'; + const { result, rerender } = renderHook(() => useFlyoutId(customId)); - it('should handle undefined initial state gracefully', () => { - const { result } = renderHook(() => - useFlyoutManagerReducer(undefined as any) - ); + const firstId = result.current; + rerender(); + const secondId = result.current; - expect(result.current.state).toEqual({ - sessions: [], - flyouts: [], - layoutMode: 'side-by-side', - }); - }); + expect(firstId).toBe(secondId); + expect(firstId).toBe(customId); }); }); diff --git a/packages/eui/src/components/flyout/manager/hooks.ts b/packages/eui/src/components/flyout/manager/hooks.ts index 0c40e44c981..04c4dd72551 100644 --- a/packages/eui/src/components/flyout/manager/hooks.ts +++ b/packages/eui/src/components/flyout/manager/hooks.ts @@ -6,26 +6,11 @@ * Side Public License, v 1. */ -import { useCallback, useContext, useReducer, useRef } from 'react'; +import { useContext, useRef } from 'react'; import { warnOnce, useGeneratedHtmlId } from '../../../services'; -import { flyoutManagerReducer, initialState } from './reducer'; -import { - addFlyout as addFlyoutAction, - closeFlyout as closeFlyoutAction, - setActiveFlyout as setActiveFlyoutAction, - setFlyoutWidth as setFlyoutWidthAction, - goBack as goBackAction, - goToFlyout as goToFlyoutAction, -} from './actions'; -import { - type EuiFlyoutLevel, - type EuiFlyoutManagerState, - type FlyoutManagerApi, -} from './types'; import { EuiFlyoutManagerContext } from './provider'; -import { LEVEL_MAIN } from './const'; import { useIsFlyoutRegistered } from './selectors'; // Ensure uniqueness across multiple hook instances, including in test envs @@ -49,67 +34,6 @@ export { useIsInManagedFlyout } from './context'; // Convenience selector for a flyout's activity stage export type { EuiFlyoutActivityStage } from './types'; -/** - * Hook that provides the flyout manager reducer and bound action creators. - * Accepts an optional initial state (mainly for tests or custom setups). - */ -export function useFlyoutManagerReducer( - initial: EuiFlyoutManagerState = initialState -): FlyoutManagerApi { - const [state, dispatch] = useReducer(flyoutManagerReducer, initial); - - const addFlyout = useCallback( - ( - flyoutId: string, - title: string, - level: EuiFlyoutLevel = LEVEL_MAIN, - size?: string - ) => dispatch(addFlyoutAction(flyoutId, title, level, size)), - [] - ); - const closeFlyout = useCallback( - (flyoutId: string) => dispatch(closeFlyoutAction(flyoutId)), - [] - ); - const setActiveFlyout = useCallback( - (flyoutId: string | null) => dispatch(setActiveFlyoutAction(flyoutId)), - [] - ); - const setFlyoutWidth = useCallback( - (flyoutId: string, width: number) => - 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, - dispatch, - addFlyout, - closeFlyout, - setActiveFlyout, - setFlyoutWidth, - goBack, - goToFlyout, - getHistoryItems, - }; -} - /** Access the flyout manager context (state and actions). */ export const useFlyoutManager = () => useContext(EuiFlyoutManagerContext); diff --git a/packages/eui/src/components/flyout/manager/provider.test.tsx b/packages/eui/src/components/flyout/manager/provider.test.tsx index 472d6dd1e2b..50263dbb719 100644 --- a/packages/eui/src/components/flyout/manager/provider.test.tsx +++ b/packages/eui/src/components/flyout/manager/provider.test.tsx @@ -15,8 +15,11 @@ import { EuiFlyoutManagerContext, useFlyoutManager, } from './provider'; +import { _resetFlyoutManagerStore } from './store'; describe('EuiFlyoutManager', () => { + afterEach(_resetFlyoutManagerStore); + it('renders', () => { const { container } = render( diff --git a/packages/eui/src/components/flyout/manager/provider.tsx b/packages/eui/src/components/flyout/manager/provider.tsx index d99583cfd56..c3dbae2c07b 100644 --- a/packages/eui/src/components/flyout/manager/provider.tsx +++ b/packages/eui/src/components/flyout/manager/provider.tsx @@ -6,10 +6,11 @@ * Side Public License, v 1. */ -import React, { createContext, useContext } from 'react'; -import { useFlyoutManagerReducer } from './hooks'; +import React, { createContext, useContext, useMemo } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { useApplyFlyoutLayoutMode } from './layout_mode'; import { FlyoutManagerApi } from './types'; +import { getFlyoutManagerStore } from './store'; /** * React context that exposes the Flyout Manager API (state + actions). @@ -24,7 +25,14 @@ export const EuiFlyoutManagerContext = createContext( export const EuiFlyoutManager: React.FC<{ children: React.ReactNode }> = ({ children, }) => { - const api = useFlyoutManagerReducer(); + const { getState, subscribe, ...rest } = getFlyoutManagerStore(); + const state = useSyncExternalStore(subscribe, getState, getState); + + const api: FlyoutManagerApi = useMemo( + () => ({ state, ...rest }), + [state, rest] + ); + return ( {children} diff --git a/packages/eui/src/components/flyout/manager/selectors.test.tsx b/packages/eui/src/components/flyout/manager/selectors.test.tsx index 51fc6228872..1a0eaeaba2f 100644 --- a/packages/eui/src/components/flyout/manager/selectors.test.tsx +++ b/packages/eui/src/components/flyout/manager/selectors.test.tsx @@ -9,547 +9,178 @@ import React from 'react'; import { renderHook } from '../../../test/rtl'; import { - useSession, - useHasActiveSession, - useIsFlyoutActive, - useFlyout, - useCurrentSession, - useCurrentMainFlyout, - useCurrentChildFlyout, useFlyoutWidth, useParentFlyoutSize, useHasChildFlyout, } from './selectors'; -import { EuiFlyoutManager, useFlyoutManager } from './provider'; -import { useFlyoutManagerReducer } from './hooks'; +import { useFlyoutManager } from './provider'; import { LEVEL_MAIN, LEVEL_CHILD } from './const'; -// Mock the hooks module to avoid circular dependencies -jest.mock('./hooks', () => ({ - useFlyoutManagerReducer: jest.fn(), -})); - // Mock the provider context jest.mock('./provider', () => ({ EuiFlyoutManager: ({ children }: { children: React.ReactNode }) => children, useFlyoutManager: jest.fn(), })); -// Test wrapper component -const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { - return {children}; -}; - -// Mock data -const mockState = { - sessions: [ - { mainFlyoutId: 'main-1', childFlyoutId: 'child-1' }, - { mainFlyoutId: 'main-2', childFlyoutId: null }, - ], - flyouts: [ - { flyoutId: 'main-1', level: LEVEL_MAIN, size: 'l', width: 600 }, - { flyoutId: 'child-1', level: LEVEL_CHILD, size: 'm', width: 400 }, - { flyoutId: 'main-2', level: LEVEL_MAIN, size: 's', width: 300 }, - ], - layoutMode: 'side-by-side' as const, -}; - -const mockApi = { - state: mockState, - dispatch: jest.fn(), - addFlyout: jest.fn(), - closeFlyout: jest.fn(), - setActiveFlyout: jest.fn(), - setFlyoutWidth: jest.fn(), -}; +const mockUseFlyoutManager = useFlyoutManager as jest.MockedFunction< + typeof useFlyoutManager +>; -describe('flyout manager selectors', () => { +describe('Flyout Manager Selectors', () => { beforeEach(() => { - jest.clearAllMocks(); - const mockUseFlyoutManagerReducer = useFlyoutManagerReducer as jest.Mock; - const mockUseFlyoutManager = useFlyoutManager as jest.Mock; - mockUseFlyoutManagerReducer.mockReturnValue(mockApi); - mockUseFlyoutManager.mockReturnValue(mockApi); - }); - - describe('useSession', () => { - it('should return session when flyout ID matches main', () => { - const { result } = renderHook(() => useSession('main-1'), { - wrapper: TestWrapper, - }); - - expect(result.current).toEqual({ - mainFlyoutId: 'main-1', - childFlyoutId: 'child-1', - }); - }); - - it('should return session when flyout ID matches child', () => { - const { result } = renderHook(() => useSession('child-1'), { - wrapper: TestWrapper, - }); - - expect(result.current).toEqual({ - mainFlyoutId: 'main-1', - childFlyoutId: 'child-1', - }); - }); - - it('should return null when flyout ID does not match any session', () => { - const { result } = renderHook(() => useSession('non-existent'), { - wrapper: TestWrapper, - }); - - expect(result.current).toBeNull(); - }); - - it('should return null when no flyout ID is provided', () => { - const { result } = renderHook(() => useSession(), { - wrapper: TestWrapper, - }); - - expect(result.current).toBeNull(); - }); - - it('should return null when flyout ID is null', () => { - const { result } = renderHook(() => useSession(null), { - wrapper: TestWrapper, - }); - - // The selector treats null as a literal value to search for - // It finds the session where childFlyoutId: null matches flyoutId: null - expect(result.current).toEqual({ - mainFlyoutId: 'main-2', - childFlyoutId: null, - }); - }); - }); - - describe('useHasActiveSession', () => { - it('should return true when there are active sessions', () => { - const { result } = renderHook(() => useHasActiveSession(), { - wrapper: TestWrapper, - }); - - expect(result.current).toBe(true); - }); - - it('should return false when there are no sessions', () => { - const emptyState = { ...mockState, sessions: [] }; - (useFlyoutManagerReducer as jest.Mock).mockReturnValue({ - ...mockApi, - state: emptyState, - }); - (useFlyoutManager as jest.Mock).mockReturnValue({ - ...mockApi, - state: emptyState, - }); - - const { result } = renderHook(() => useHasActiveSession(), { - wrapper: TestWrapper, - }); - - expect(result.current).toBe(false); - }); - }); - - describe('useIsFlyoutActive', () => { - it('should return true when flyout is main in current session', () => { - const { result } = renderHook(() => useIsFlyoutActive('main-2'), { - wrapper: TestWrapper, - }); - - expect(result.current).toBe(true); - }); - - it('should return true when flyout is child in current session', () => { - const { result } = renderHook(() => useIsFlyoutActive('child-1'), { - wrapper: TestWrapper, - }); - - // child-1 is not in the current session (main-2 with no child) - // It's in the previous session (main-1 with child-1) - expect(result.current).toBe(false); - }); - - it('should return false when flyout is not in current session', () => { - const { result } = renderHook(() => useIsFlyoutActive('non-existent'), { - wrapper: TestWrapper, - }); - - expect(result.current).toBe(false); - }); - - it('should return false when flyout is in previous session', () => { - const { result } = renderHook(() => useIsFlyoutActive('main-1'), { - wrapper: TestWrapper, - }); - - expect(result.current).toBe(false); - }); - }); - - describe('useFlyout', () => { - it('should return flyout when ID exists', () => { - const { result } = renderHook(() => useFlyout('main-1'), { - wrapper: TestWrapper, - }); - - expect(result.current).toEqual({ - flyoutId: 'main-1', - level: LEVEL_MAIN, - size: 'l', - width: 600, - }); - }); - - it('should return null when flyout ID does not exist', () => { - const { result } = renderHook(() => useFlyout('non-existent'), { - wrapper: TestWrapper, - }); - - expect(result.current).toBeNull(); - }); - - it('should return null when no flyout ID is provided', () => { - const { result } = renderHook(() => useFlyout(), { - wrapper: TestWrapper, - }); - - expect(result.current).toBeNull(); - }); - - it('should return null when flyout ID is null', () => { - const { result } = renderHook(() => useFlyout(null), { - wrapper: TestWrapper, - }); - - expect(result.current).toBeNull(); - }); - }); - - describe('useCurrentSession', () => { - it('should return the most recent session', () => { - const { result } = renderHook(() => useCurrentSession(), { - wrapper: TestWrapper, - }); - - expect(result.current).toEqual({ - mainFlyoutId: 'main-2', - childFlyoutId: null, - }); - }); - - it('should return null when no sessions exist', () => { - const emptyState = { ...mockState, sessions: [] }; - (useFlyoutManagerReducer as jest.Mock).mockReturnValue({ - ...mockApi, - state: emptyState, - }); - (useFlyoutManager as jest.Mock).mockReturnValue({ - ...mockApi, - state: emptyState, - }); - - const { result } = renderHook(() => useCurrentSession(), { - wrapper: TestWrapper, - }); - - expect(result.current).toBeNull(); - }); - }); - - describe('useCurrentMainFlyout', () => { - it('should return the main flyout of current session', () => { - const { result } = renderHook(() => useCurrentMainFlyout(), { - wrapper: TestWrapper, - }); - - expect(result.current).toEqual({ - flyoutId: 'main-2', - level: LEVEL_MAIN, - size: 's', - width: 300, - }); - }); - - it('should return null when no current session exists', () => { - const emptyState = { ...mockState, sessions: [] }; - (useFlyoutManagerReducer as jest.Mock).mockReturnValue({ - ...mockApi, - state: emptyState, - }); - (useFlyoutManager as jest.Mock).mockReturnValue({ - ...mockApi, - state: emptyState, - }); - - const { result } = renderHook(() => useCurrentMainFlyout(), { - wrapper: TestWrapper, - }); - - expect(result.current).toBeNull(); - }); - }); - - describe('useCurrentChildFlyout', () => { - it('should return the child flyout of current session', () => { - // Change current session to one with a child - const stateWithChildCurrent = { - ...mockState, - sessions: [ - { mainFlyoutId: 'main-2', childFlyoutId: null }, - { mainFlyoutId: 'main-1', childFlyoutId: 'child-1' }, // Make this the current session - ], - }; - (useFlyoutManagerReducer as jest.Mock).mockReturnValue({ - ...mockApi, - state: stateWithChildCurrent, - }); - (useFlyoutManager as jest.Mock).mockReturnValue({ - ...mockApi, - state: stateWithChildCurrent, - }); - - const { result } = renderHook(() => useCurrentChildFlyout(), { - wrapper: TestWrapper, - }); - - expect(result.current).toEqual({ - flyoutId: 'child-1', - level: LEVEL_CHILD, - size: 'm', - width: 400, - }); - }); - - it('should return null when current session has no child', () => { - const { result } = renderHook(() => useCurrentChildFlyout(), { - wrapper: TestWrapper, - }); - - expect(result.current).toBeNull(); - }); - - it('should return null when no current session exists', () => { - const emptyState = { ...mockState, sessions: [] }; - (useFlyoutManagerReducer as jest.Mock).mockReturnValue({ - ...mockApi, - state: emptyState, - }); - (useFlyoutManager as jest.Mock).mockReturnValue({ - ...mockApi, - state: emptyState, - }); - - const { result } = renderHook(() => useCurrentChildFlyout(), { - wrapper: TestWrapper, - }); - - expect(result.current).toBeNull(); - }); + mockUseFlyoutManager.mockClear(); }); describe('useFlyoutWidth', () => { - it('should return flyout width when it exists', () => { - const { result } = renderHook(() => useFlyoutWidth('main-1'), { - wrapper: TestWrapper, - }); - - expect(result.current).toBe(600); - }); - - it('should return undefined when flyout has no width', () => { - const stateWithoutWidth = { - ...mockState, - flyouts: [{ flyoutId: 'main-1', level: LEVEL_MAIN, size: 'l' }], - }; - (useFlyoutManagerReducer as jest.Mock).mockReturnValue({ - ...mockApi, - state: stateWithoutWidth, - }); - (useFlyoutManager as jest.Mock).mockReturnValue({ - ...mockApi, - state: stateWithoutWidth, - }); - - const { result } = renderHook(() => useFlyoutWidth('main-1'), { - wrapper: TestWrapper, - }); - - expect(result.current).toBeUndefined(); - }); - - it('should return undefined when flyout does not exist', () => { - const { result } = renderHook(() => useFlyoutWidth('non-existent'), { - wrapper: TestWrapper, - }); + it('should return undefined when flyout is not found', () => { + mockUseFlyoutManager.mockReturnValue({ + state: { + sessions: [], + flyouts: [], + layoutMode: 'side-by-side', + }, + dispatch: jest.fn(), + addFlyout: jest.fn(), + closeFlyout: jest.fn(), + setActiveFlyout: jest.fn(), + setFlyoutWidth: jest.fn(), + goBack: jest.fn(), + goToFlyout: jest.fn(), + historyItems: [], + }); + + const { result } = renderHook(() => + useFlyoutWidth('non-existent-flyout') + ); expect(result.current).toBeUndefined(); }); - it('should return undefined when no flyout ID is provided', () => { - const { result } = renderHook(() => useFlyoutWidth(), { - wrapper: TestWrapper, - }); - - expect(result.current).toBeUndefined(); + it('should return the width when flyout exists', () => { + mockUseFlyoutManager.mockReturnValue({ + state: { + sessions: [], + flyouts: [ + { + flyoutId: 'test-flyout', + level: LEVEL_MAIN, + size: 'm', + activityStage: 'active', + width: 500, + }, + ], + layoutMode: 'side-by-side', + }, + dispatch: jest.fn(), + addFlyout: jest.fn(), + closeFlyout: jest.fn(), + setActiveFlyout: jest.fn(), + setFlyoutWidth: jest.fn(), + goBack: jest.fn(), + goToFlyout: jest.fn(), + historyItems: [], + }); + + const { result } = renderHook(() => useFlyoutWidth('test-flyout')); + + expect(result.current).toBe(500); }); }); describe('useParentFlyoutSize', () => { - it('should return parent flyout size for child flyout', () => { - const { result } = renderHook(() => useParentFlyoutSize('child-1'), { - wrapper: TestWrapper, - }); - - expect(result.current).toBe('l'); - }); - - it('should return undefined when child flyout has no parent', () => { - const { result } = renderHook(() => useParentFlyoutSize('non-existent'), { - wrapper: TestWrapper, - }); - - expect(result.current).toBeUndefined(); - }); - - it('should return undefined when parent flyout has no size', () => { - const stateWithoutSize = { - ...mockState, - flyouts: [ - { flyoutId: 'main-1', level: LEVEL_MAIN }, - { flyoutId: 'child-1', level: LEVEL_CHILD }, - ], - }; - (useFlyoutManagerReducer as jest.Mock).mockReturnValue({ - ...mockApi, - state: stateWithoutSize, - }); - (useFlyoutManager as jest.Mock).mockReturnValue({ - ...mockApi, - state: stateWithoutSize, - }); - - const { result } = renderHook(() => useParentFlyoutSize('child-1'), { - wrapper: TestWrapper, - }); + it('should return undefined when no parent flyout exists', () => { + mockUseFlyoutManager.mockReturnValue({ + state: { + sessions: [], + flyouts: [], + layoutMode: 'side-by-side', + }, + dispatch: jest.fn(), + addFlyout: jest.fn(), + closeFlyout: jest.fn(), + setActiveFlyout: jest.fn(), + setFlyoutWidth: jest.fn(), + goBack: jest.fn(), + goToFlyout: jest.fn(), + historyItems: [], + }); + + const { result } = renderHook(() => useParentFlyoutSize('child-flyout')); expect(result.current).toBeUndefined(); }); }); describe('useHasChildFlyout', () => { - it('should return true when main flyout has a child', () => { - const { result } = renderHook(() => useHasChildFlyout('main-1'), { - wrapper: TestWrapper, - }); - - expect(result.current).toBe(true); - }); - - it('should return false when main flyout has no child', () => { - const { result } = renderHook(() => useHasChildFlyout('main-2'), { - wrapper: TestWrapper, - }); + it('should return false when no child flyout exists', () => { + mockUseFlyoutManager.mockReturnValue({ + state: { + sessions: [], + flyouts: [ + { + flyoutId: 'parent-flyout', + level: LEVEL_MAIN, + size: 'm', + activityStage: 'active', + }, + ], + layoutMode: 'side-by-side', + }, + dispatch: jest.fn(), + addFlyout: jest.fn(), + closeFlyout: jest.fn(), + setActiveFlyout: jest.fn(), + setFlyoutWidth: jest.fn(), + goBack: jest.fn(), + goToFlyout: jest.fn(), + historyItems: [], + }); + + const { result } = renderHook(() => useHasChildFlyout('parent-flyout')); expect(result.current).toBe(false); }); - it('should return false when flyout ID does not exist', () => { - const { result } = renderHook(() => useHasChildFlyout('non-existent'), { - wrapper: TestWrapper, - }); - - expect(result.current).toBe(false); - }); - - it('should return false when flyout is not a main flyout', () => { - const { result } = renderHook(() => useHasChildFlyout('child-1'), { - wrapper: TestWrapper, - }); + it('should return true when child flyout exists', () => { + mockUseFlyoutManager.mockReturnValue({ + state: { + sessions: [ + { + mainFlyoutId: 'parent-flyout', + childFlyoutId: 'child-flyout', + title: 'Parent Flyout', + }, + ], + flyouts: [ + { + flyoutId: 'parent-flyout', + level: LEVEL_MAIN, + size: 'm', + activityStage: 'active', + }, + { + flyoutId: 'child-flyout', + level: LEVEL_CHILD, + size: 'm', + activityStage: 'active', + }, + ], + layoutMode: 'side-by-side', + }, + dispatch: jest.fn(), + addFlyout: jest.fn(), + closeFlyout: jest.fn(), + setActiveFlyout: jest.fn(), + setFlyoutWidth: jest.fn(), + goBack: jest.fn(), + goToFlyout: jest.fn(), + historyItems: [], + }); + + const { result } = renderHook(() => useHasChildFlyout('parent-flyout')); - // The selector checks if the flyout ID has a session with a child - // Since child-1 is in a session with childFlyoutId: 'child-1', it returns true expect(result.current).toBe(true); }); }); - - describe('edge cases and error handling', () => { - it('should handle empty flyouts array', () => { - const emptyState = { ...mockState, flyouts: [] }; - (useFlyoutManagerReducer as jest.Mock).mockReturnValue({ - ...mockApi, - state: emptyState, - }); - (useFlyoutManager as jest.Mock).mockReturnValue({ - ...mockApi, - state: emptyState, - }); - - const { result: flyoutResult } = renderHook(() => useFlyout('main-1'), { - wrapper: TestWrapper, - }); - const { result: widthResult } = renderHook( - () => useFlyoutWidth('main-1'), - { - wrapper: TestWrapper, - } - ); - - expect(flyoutResult.current).toBeNull(); - expect(widthResult.current).toBeUndefined(); - }); - - it('should handle malformed flyout data gracefully', () => { - const malformedState = { - ...mockState, - flyouts: [ - { flyoutId: 'main-1' }, // Missing required properties - ], - }; - (useFlyoutManagerReducer as jest.Mock).mockReturnValue({ - ...mockApi, - state: malformedState, - }); - (useFlyoutManager as jest.Mock).mockReturnValue({ - ...mockApi, - state: malformedState, - }); - - const { result } = renderHook(() => useFlyout('main-1'), { - wrapper: TestWrapper, - }); - - expect(result.current).toEqual({ flyoutId: 'main-1' }); - }); - - it('should handle sessions with missing flyout references', () => { - const invalidState = { - ...mockState, - sessions: [ - { mainFlyoutId: 'main-1', childFlyoutId: 'non-existent-child' }, - ], - flyouts: [{ flyoutId: 'main-1', level: LEVEL_MAIN }], - }; - (useFlyoutManagerReducer as jest.Mock).mockReturnValue({ - ...mockApi, - state: invalidState, - }); - (useFlyoutManager as jest.Mock).mockReturnValue({ - ...mockApi, - state: invalidState, - }); - - const { result } = renderHook(() => useSession('non-existent-child'), { - wrapper: TestWrapper, - }); - - expect(result.current).toEqual({ - mainFlyoutId: 'main-1', - childFlyoutId: 'non-existent-child', - }); - }); - }); }); diff --git a/packages/eui/src/components/flyout/manager/store.test.ts b/packages/eui/src/components/flyout/manager/store.test.ts new file mode 100644 index 00000000000..00ebf4a4a77 --- /dev/null +++ b/packages/eui/src/components/flyout/manager/store.test.ts @@ -0,0 +1,171 @@ +/* + * 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 { getFlyoutManagerStore, _resetFlyoutManagerStore } from './store'; +import { LEVEL_MAIN } from './const'; + +describe('Flyout Manager Store', () => { + beforeEach(() => { + _resetFlyoutManagerStore(); + }); + + afterEach(() => { + _resetFlyoutManagerStore(); + }); + + describe('singleton behavior', () => { + it('should return the same instance on multiple calls', () => { + const store1 = getFlyoutManagerStore(); + const store2 = getFlyoutManagerStore(); + + expect(store1).toBe(store2); + }); + + it('should create a new instance after reset', () => { + const store1 = getFlyoutManagerStore(); + _resetFlyoutManagerStore(); + const store2 = getFlyoutManagerStore(); + + expect(store1).not.toBe(store2); + }); + }); + + describe('historyItems stability', () => { + it('should maintain stable references when sessions do not change', () => { + const store = getFlyoutManagerStore(); + + // Add a main flyout (creates a session) + store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN); + + const firstHistoryItems = store.historyItems; + + // Perform an action that does NOT change sessions (only width) + store.setFlyoutWidth('flyout-1', 500); + + const secondHistoryItems = store.historyItems; + + // References should be the same since sessions didn't change + expect(secondHistoryItems).toBe(firstHistoryItems); + }); + + it('should update references when sessions change', () => { + const store = getFlyoutManagerStore(); + + // Add first flyout + store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN); + const firstHistoryItems = store.historyItems; + + // Add second flyout (creates a new session) + store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN); + const secondHistoryItems = store.historyItems; + + // References should be different since sessions changed + expect(secondHistoryItems).not.toBe(firstHistoryItems); + + // Should have one history item (the first session) + expect(secondHistoryItems).toHaveLength(1); + expect(secondHistoryItems[0].title).toBe('First Flyout'); + }); + + it('should create stable onClick handlers within the same session state', () => { + const store = getFlyoutManagerStore(); + + // Add two flyouts to create history + store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN); + store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN); + + const firstHistoryItems = store.historyItems; + const firstOnClick = firstHistoryItems[0].onClick; + + // Access history items again without changing sessions + store.setFlyoutWidth('flyout-2', 400); + const secondHistoryItems = store.historyItems; + const secondOnClick = secondHistoryItems[0].onClick; + + // The entire array should be the same reference + expect(secondHistoryItems).toBe(firstHistoryItems); + // The onClick handler should also be the same reference + expect(secondOnClick).toBe(firstOnClick); + }); + + it('should properly compute history items with correct titles', () => { + const store = getFlyoutManagerStore(); + + // Create multiple sessions + store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN); + store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN); + store.addFlyout('flyout-3', 'Third Flyout', LEVEL_MAIN); + + const historyItems = store.historyItems; + + // Should have 2 history items (all previous sessions, in reverse order) + expect(historyItems).toHaveLength(2); + expect(historyItems[0].title).toBe('Second Flyout'); + expect(historyItems[1].title).toBe('First Flyout'); + }); + + it('should have functional onClick handlers', () => { + const store = getFlyoutManagerStore(); + + // Create two sessions + store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN); + store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN); + + const historyItems = store.historyItems; + + // Click the history item to go back to first flyout + historyItems[0].onClick(); + + // Should have navigated back - history should now be empty + expect(store.historyItems).toHaveLength(0); + expect(store.getState().sessions).toHaveLength(1); + expect(store.getState().sessions[0].mainFlyoutId).toBe('flyout-1'); + }); + }); + + describe('store subscription', () => { + it('should notify subscribers when state changes', () => { + const store = getFlyoutManagerStore(); + const listener = jest.fn(); + + const unsubscribe = store.subscribe(listener); + + store.addFlyout('flyout-1', 'Test Flyout', LEVEL_MAIN); + + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + }); + + it('should not notify unsubscribed listeners', () => { + const store = getFlyoutManagerStore(); + const listener = jest.fn(); + + const unsubscribe = store.subscribe(listener); + unsubscribe(); + + store.addFlyout('flyout-1', 'Test Flyout', LEVEL_MAIN); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('should notify all subscribers', () => { + const store = getFlyoutManagerStore(); + const listener1 = jest.fn(); + const listener2 = jest.fn(); + + store.subscribe(listener1); + store.subscribe(listener2); + + store.addFlyout('flyout-1', 'Test Flyout', LEVEL_MAIN); + + expect(listener1).toHaveBeenCalledTimes(1); + expect(listener2).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/eui/src/components/flyout/manager/store.ts b/packages/eui/src/components/flyout/manager/store.ts new file mode 100644 index 00000000000..983b7d600fc --- /dev/null +++ b/packages/eui/src/components/flyout/manager/store.ts @@ -0,0 +1,136 @@ +/* + * 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 type { EuiFlyoutLevel, EuiFlyoutManagerState } from './types'; +import type { Action } from './actions'; +import { + addFlyout as addFlyoutAction, + closeFlyout as closeFlyoutAction, + setActiveFlyout as setActiveFlyoutAction, + setFlyoutWidth as setFlyoutWidthAction, + goBack as goBackAction, + goToFlyout as goToFlyoutAction, +} from './actions'; +import { flyoutManagerReducer, initialState } from './reducer'; + +type Listener = () => void; + +export interface FlyoutManagerStore { + getState: () => EuiFlyoutManagerState; + subscribe: (listener: Listener) => () => void; + dispatch: (action: Action) => void; + // Convenience bound action creators + addFlyout: ( + flyoutId: string, + title: string, + level?: EuiFlyoutLevel, + size?: string + ) => void; + closeFlyout: (flyoutId: string) => void; + setActiveFlyout: (flyoutId: string | null) => void; + setFlyoutWidth: (flyoutId: string, width: number) => void; + goBack: () => void; + goToFlyout: (flyoutId: string) => void; + historyItems: Array<{ + title: string; + onClick: () => void; + }>; +} + +function createStore( + initial: EuiFlyoutManagerState = initialState +): FlyoutManagerStore { + let currentState: EuiFlyoutManagerState = initial; + const listeners = new Set(); + + const getState = () => currentState; + + const subscribe = (listener: Listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }; + + // The onClick handlers won't execute until after store is fully assigned. + // eslint-disable-next-line prefer-const -- Forward declaration requires 'let' not 'const' + let store: FlyoutManagerStore; + + const computeHistoryItems = (): Array<{ + title: string; + onClick: () => void; + }> => { + const currentSessionIndex = currentState.sessions.length - 1; + const previousSessions = currentState.sessions.slice( + 0, + currentSessionIndex + ); + return previousSessions.reverse().map(({ title, mainFlyoutId }) => ({ + title, + onClick: () => { + store.dispatch(goToFlyoutAction(mainFlyoutId)); + }, + })); + }; + + const dispatch = (action: Action) => { + const nextState = flyoutManagerReducer(currentState, action); + if (nextState !== currentState) { + const previousSessions = currentState.sessions; + currentState = nextState; + + // Recompute history items eagerly if sessions changed + // This ensures stable references and avoids stale closures + if (nextState.sessions !== previousSessions) { + store.historyItems = computeHistoryItems(); + } + + listeners.forEach((l) => { + l(); + }); + } + }; + + store = { + getState, + subscribe, + dispatch, + addFlyout: (flyoutId, title, level, size) => + dispatch(addFlyoutAction(flyoutId, title, level, size)), + closeFlyout: (flyoutId) => dispatch(closeFlyoutAction(flyoutId)), + setActiveFlyout: (flyoutId) => dispatch(setActiveFlyoutAction(flyoutId)), + setFlyoutWidth: (flyoutId, width) => + dispatch(setFlyoutWidthAction(flyoutId, width)), + goBack: () => dispatch(goBackAction()), + goToFlyout: (flyoutId) => dispatch(goToFlyoutAction(flyoutId)), + historyItems: computeHistoryItems(), // Initialize with current state + }; + + return store; +} + +// Module-level singleton. A necessary trade-off to avoid global namespace pollution or the need for a third-party library. +let storeInstance: FlyoutManagerStore | null = null; + +/** + * Returns a singleton store instance shared across all React roots within the same JS context. + * Uses module-level singleton to ensure deduplication even if modules are loaded twice. + */ +export function getFlyoutManagerStore(): FlyoutManagerStore { + if (!storeInstance) { + storeInstance = createStore(); + } + return storeInstance; +} + +/** + * For testing purposes - allows resetting the store + */ +export function _resetFlyoutManagerStore(): void { + storeInstance = null; +} diff --git a/packages/eui/src/components/flyout/manager/types.ts b/packages/eui/src/components/flyout/manager/types.ts index 28d7dfbdbad..dc652486db4 100644 --- a/packages/eui/src/components/flyout/manager/types.ts +++ b/packages/eui/src/components/flyout/manager/types.ts @@ -80,7 +80,7 @@ export interface FlyoutManagerApi { setFlyoutWidth: (flyoutId: string, width: number) => void; goBack: () => void; goToFlyout: (flyoutId: string) => void; - getHistoryItems: () => Array<{ + historyItems: Array<{ title: string; onClick: () => void; }>; diff --git a/yarn.lock b/yarn.lock index c4f8deb9fe2..96f9cb4d709 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7494,6 +7494,7 @@ __metadata: unified: "npm:^9.2.2" unist-util-visit: "npm:^2.0.3" url-parse: "npm:^1.5.10" + use-sync-external-store: "npm:^1.6.0" uuid: "npm:^8.3.0" vfile: "npm:^4.2.1" webpack: "npm:^5.74.0" @@ -40872,6 +40873,15 @@ __metadata: languageName: node linkType: hard +"use-sync-external-store@npm:^1.6.0": + version: 1.6.0 + resolution: "use-sync-external-store@npm:1.6.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/35e1179f872a53227bdf8a827f7911da4c37c0f4091c29b76b1e32473d1670ebe7bcd880b808b7549ba9a5605c233350f800ffab963ee4a4ee346ee983b6019b + languageName: node + linkType: hard + "use@npm:^2.0.0": version: 2.0.2 resolution: "use@npm:2.0.2"