diff --git a/packages/eui/changelogs/upcoming/9413.md b/packages/eui/changelogs/upcoming/9413.md new file mode 100644 index 00000000000..340e2bbb0c4 --- /dev/null +++ b/packages/eui/changelogs/upcoming/9413.md @@ -0,0 +1,7 @@ +- Added `historyKey` prop (type `symbol`) to `EuiFlyout` and the flyout manager API to support scoped flyout history. + - Only flyouts sharing the same `Symbol` reference share Back button navigation and history entries; omitting `historyKey` gives each session its own isolated history group. + - `ACTION_CLOSE_ALL` now closes only the current history group rather than all open flyouts. + +**Breaking changes** + +- The positional signature of `FlyoutManagerApi.addFlyout` and the flyout store's `addFlyout` now includes `historyKey` before the `iconType`/`minWidth` arguments. Call sites that pass arguments positionally must be updated (or switched to named parameters) to account for this new parameter ordering. \ No newline at end of file diff --git a/packages/eui/src/components/flyout/flyout.test.tsx b/packages/eui/src/components/flyout/flyout.test.tsx index 9d8c5c133d5..abcf449ec46 100644 --- a/packages/eui/src/components/flyout/flyout.test.tsx +++ b/packages/eui/src/components/flyout/flyout.test.tsx @@ -919,6 +919,27 @@ describe('EuiFlyout', () => { const childFlyout = getByTestSubject('child-flyout'); expect(childFlyout).not.toHaveAttribute('data-managed-flyout-level'); }); + + it('accepts historyKey prop with session="start" and renders without error', () => { + const sharedKey = Symbol(); + const { getByRole } = render( + + {}} + flyoutMenuProps={{ title: 'Main Flyout' }} + aria-label="Test flyout" + > + Content + + + ); + const dialog = getByRole('dialog'); + expect(dialog).toBeInTheDocument(); + expect(dialog).toHaveAttribute('aria-label', 'Test flyout'); + expect(dialog).toHaveAttribute('data-managed-flyout-level', 'main'); + }); }); describe('ref forwarding', () => { diff --git a/packages/eui/src/components/flyout/flyout.tsx b/packages/eui/src/components/flyout/flyout.tsx index 647a8d799b5..5b24c730389 100644 --- a/packages/eui/src/components/flyout/flyout.tsx +++ b/packages/eui/src/components/flyout/flyout.tsx @@ -54,6 +54,11 @@ export type EuiFlyoutProps = Omit< | typeof SESSION_START | typeof SESSION_INHERIT | typeof SESSION_NEVER; + /** + * Optional Symbol to scope flyout history. Only flyouts that receive the same Symbol reference share Back button and history; omit to get a unique group per session. + * @default undefined (each session gets a unique key and does not share history) + */ + historyKey?: symbol; /** * Callback fired when the flyout becomes active/visible, which may happen programmatically from history navigation. */ @@ -68,7 +73,7 @@ export const EuiFlyout = forwardRef< HTMLDivElement | HTMLElement, EuiFlyoutProps<'div' | 'nav'> >((props, ref) => { - const { as, onClose, onActive, session, ...rest } = + const { as, onClose, onActive, session, historyKey, ...rest } = usePropsWithComponentDefaults('EuiFlyout', props); const hasActiveSession = useHasActiveSession(); const isInsideParentFlyout = useIsInsideParentFlyout(); @@ -101,6 +106,7 @@ export const EuiFlyout = forwardRef< return ( { 'main', LEVEL_MAIN, 'm', + undefined, 'faceHappy' ); @@ -127,6 +128,7 @@ describe('flyout manager actions', () => { title: 'main', level: LEVEL_MAIN, size: 'm', + historyKey: undefined, iconType: 'faceHappy', }); }); @@ -137,6 +139,7 @@ describe('flyout manager actions', () => { 'main', LEVEL_MAIN, 'm', + undefined, 'faceHappy', 100 ); @@ -147,10 +150,33 @@ describe('flyout manager actions', () => { title: 'main', level: LEVEL_MAIN, size: 'm', + historyKey: undefined, iconType: 'faceHappy', minWidth: 100, }); }); + + it('should include historyKey in action when provided', () => { + const key = Symbol('test'); + const action = addFlyout( + 'flyout-1', + 'main', + LEVEL_MAIN, + 'm', + key, + 'faceHappy' + ); + + expect(action).toEqual({ + type: ACTION_ADD, + flyoutId: 'flyout-1', + title: 'main', + level: LEVEL_MAIN, + size: 'm', + historyKey: key, + iconType: 'faceHappy', + }); + }); }); describe('closeFlyout', () => { diff --git a/packages/eui/src/components/flyout/manager/actions.ts b/packages/eui/src/components/flyout/manager/actions.ts index 9f4768f6ad1..130d6014715 100644 --- a/packages/eui/src/components/flyout/manager/actions.ts +++ b/packages/eui/src/components/flyout/manager/actions.ts @@ -61,6 +61,7 @@ export interface AddFlyoutAction extends BaseAction { title: string; level: EuiFlyoutLevel; size?: string; + historyKey?: symbol; iconType?: IconType; minWidth?: number; } @@ -166,6 +167,7 @@ export type Action = * - `title` is used for the flyout menu. * - `level` determines whether the flyout is `main` or `child`. * - Optional `size` is the named EUI size (e.g. `s`, `m`, `l`). + * - Optional `historyKey` (Symbol) scopes history; only flyouts with the same reference share Back/history. Omit for a unique group per session. * - Optional `iconType` is shown next to the session title in the history menu. */ export const addFlyout = ( @@ -173,6 +175,7 @@ export const addFlyout = ( title: string, level: EuiFlyoutLevel = LEVEL_MAIN, size?: string, + historyKey?: symbol, iconType?: IconType, minWidth?: number ): AddFlyoutAction => ({ @@ -181,6 +184,7 @@ export const addFlyout = ( title, level, size, + historyKey, iconType, minWidth, }); diff --git a/packages/eui/src/components/flyout/manager/activity_stage.test.tsx b/packages/eui/src/components/flyout/manager/activity_stage.test.tsx index 9ccc022c364..cfcf66979cd 100644 --- a/packages/eui/src/components/flyout/manager/activity_stage.test.tsx +++ b/packages/eui/src/components/flyout/manager/activity_stage.test.tsx @@ -50,7 +50,7 @@ const buildMockState = ({ }>, } = {}) => ({ layoutMode, - sessions: [{ mainFlyoutId, childFlyoutId }], + sessions: [{ mainFlyoutId, childFlyoutId, historyKey: Symbol() }], flyouts, }); diff --git a/packages/eui/src/components/flyout/manager/flyout_managed.tsx b/packages/eui/src/components/flyout/manager/flyout_managed.tsx index 211874f49b4..14d57126c34 100644 --- a/packages/eui/src/components/flyout/manager/flyout_managed.tsx +++ b/packages/eui/src/components/flyout/manager/flyout_managed.tsx @@ -54,6 +54,7 @@ import { */ export interface EuiManagedFlyoutProps extends EuiFlyoutComponentProps { level: EuiFlyoutLevel; + historyKey?: symbol; flyoutMenuProps?: Omit; onActive?: () => void; } @@ -82,6 +83,7 @@ export const EuiManagedFlyout = forwardRef( level, size: sizeProp, minWidth, + historyKey, css: customCss, flyoutMenuProps: _flyoutMenuProps, ...props @@ -212,6 +214,7 @@ export const EuiManagedFlyout = forwardRef( title!, level, size as string, + level === LEVEL_MAIN ? historyKey : undefined, _flyoutMenuProps?.iconType, typeof minWidth === 'number' ? minWidth : undefined ); @@ -237,6 +240,7 @@ export const EuiManagedFlyout = forwardRef( level, size, minWidth, + historyKey, _flyoutMenuProps?.iconType, addFlyout, closeFlyout, 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 b27f2db19f4..b105d59db65 100644 --- a/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx +++ b/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx @@ -27,7 +27,6 @@ import { EuiFlexItem, EuiFlyoutBody, EuiFlyoutHeader, - EuiPanel, EuiProvider, EuiSpacer, EuiSwitch, @@ -56,6 +55,8 @@ interface FlyoutSessionProps { mainMaxWidth?: number; childSize: 's' | 'm' | 'fill'; childMaxWidth?: number; + /** Optional. When set, flyouts in this session share history with others using the same Symbol. */ + historyKey?: symbol; } const DisplayContext: React.FC<{ title: string }> = ({ title }) => { @@ -109,7 +110,14 @@ const SessionChildFlyout: React.FC<{ ); const FlyoutSession: React.FC = (props) => { - const { title, mainSize, childSize, mainMaxWidth, childMaxWidth } = props; + const { + title, + mainSize, + childSize, + mainMaxWidth, + childMaxWidth, + historyKey, + } = props; const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); const [isChild1FlyoutVisible, setIsChild1FlyoutVisible] = useState(false); @@ -205,6 +213,7 @@ const FlyoutSession: React.FC = (props) => { = ({ size }) => { }; const MultiSessionFlyoutDemo: React.FC = () => { + const parksHistoryKey = React.useRef(Symbol()).current; + const sanitationHistoryKey = React.useRef(Symbol()).current; + const listItems = [ { - title: 'Session A: main size = s, child size = s', + title: 'Parks (shared history)', description: ( - + + + + + + + + + + + ), }, { - title: 'Session B: main size = m, child size = s', + title: 'Sanitation (shared history)', description: ( - + + + + + + + + + + + ), }, { - title: 'Session C: main size = s, child size = fill', + title: 'Permits (no historyKey: unique group)', description: ( - ), - }, - { - title: 'Session D: main size = fill, child size = s', - description: ( - - ), - }, - { - title: 'Session E: main size = fill, child size = m', - description: ( - - ), - }, - { - title: 'Session F: main size = m, child size = fill (maxWidth 1000px)', - description: ( - ), }, @@ -507,6 +525,14 @@ const MultiSessionFlyoutDemo: React.FC = () => { return ( <> + +

+ "Parks", "Sanitation", and "Permits" are + separate groups of scoped history. Navigating with the Back button and + the history menu does not cross over into other groups. +

+
+ { export const MultiSessionExample: StoryObj = { name: 'Multi-session example', + parameters: { + docs: { + description: { + story: + 'Parks and Sanitation use a shared `historyKey` so Back/history are scoped to each domain. Permits omits `historyKey` so it is alone in its history group. Open flyouts from different sections to see that histories do not mix.', + }, + }, + }, render: () => , }; -const ExternalRootChildFlyout: React.FC<{ parentId: string }> = ({ - parentId, -}) => { +const ExternalRootChildFlyout: React.FC<{ + historyKey: symbol; + parentId: string; +}> = ({ historyKey, parentId }) => { const [isOpen, setIsOpen] = useState(false); const handleToggle = () => { @@ -586,13 +621,9 @@ const ExternalRootChildFlyout: React.FC<{ parentId: string }> = ({ }; return ( - - -

Root within {parentId}

-
- + <> - Open child flyout + {`Open ${parentId} child flyout in external root`} {isOpen && ( = ({ flyoutMenuProps={{ title: `Child flyout of ${parentId}` }} resizable={false} data-test-subj="child-flyout-in-new-root" + historyKey={historyKey} > @@ -617,11 +649,14 @@ const ExternalRootChildFlyout: React.FC<{ parentId: string }> = ({ )} -
+ ); }; -const ExternalRootFlyout: React.FC<{ id: string }> = ({ id }) => { +const ExternalRootFlyout: React.FC<{ id: string; historyKey: symbol }> = ({ + id, + historyKey, +}) => { const [isOpen, setIsOpen] = useState(false); const buttonContainerRef = useRef(null); const buttonRootRef = useRef(null); @@ -643,7 +678,7 @@ const ExternalRootFlyout: React.FC<{ id: string }> = ({ id }) => { const newRoot = createRoot(buttonContainerRef.current); newRoot.render( - + ); buttonRootRef.current = newRoot; @@ -667,13 +702,9 @@ const ExternalRootFlyout: React.FC<{ id: string }> = ({ id }) => { }, []); return ( - - -

{id}

-
- - setIsOpen((prev) => !prev)}> - {isOpen ? 'Close flyout' : 'Open flyout'} + <> + setIsOpen((prev) => !prev)} disabled={isOpen}> + {`Open ${id} flyout`} {isOpen && ( = ({ id }) => { ownFocus={false} flyoutMenuProps={{ title: `${id} flyout` }} resizable={true} + historyKey={historyKey} > @@ -711,10 +743,12 @@ const ExternalRootFlyout: React.FC<{ id: string }> = ({ id }) => { )} -
+ ); }; +const multiRootHistoryKey = Symbol('multiRootSharedHistory'); + const MultiRootFlyoutDemo: React.FC = () => { const secondaryRootRef = useRef(null); const tertiaryRootRef = useRef(null); @@ -735,7 +769,7 @@ const MultiRootFlyoutDemo: React.FC = () => { const root = createRoot(container); root.render( - + ); return root; 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 418ccdd2f3f..4b7c4e425b3 100644 --- a/packages/eui/src/components/flyout/manager/layout_mode.test.tsx +++ b/packages/eui/src/components/flyout/manager/layout_mode.test.tsx @@ -85,7 +85,11 @@ const buildMockManagerState = ({ session = { mainFlyoutId: 'main-1', childFlyoutId: 'child-1' as string | null, - } as { mainFlyoutId: string; childFlyoutId: string | null } | null, + } as { + mainFlyoutId: string; + childFlyoutId: string | null; + historyKey?: symbol; + } | null, mainFlyout = { flyoutId: 'main-1', level: 'main' as const, @@ -100,7 +104,9 @@ const buildMockManagerState = ({ } as { flyoutId: string; level: string; size: string; width?: number } | null, } = {}) => ({ layoutMode, - sessions: session ? [session] : [], + sessions: session + ? [{ ...session, historyKey: session.historyKey ?? Symbol() }] + : [], flyouts: [mainFlyout, childFlyout].filter(Boolean), }); diff --git a/packages/eui/src/components/flyout/manager/reducer.test.ts b/packages/eui/src/components/flyout/manager/reducer.test.ts index fcc3d51985a..2e7278a7b16 100644 --- a/packages/eui/src/components/flyout/manager/reducer.test.ts +++ b/packages/eui/src/components/flyout/manager/reducer.test.ts @@ -73,14 +73,17 @@ describe('flyoutManagerReducer', () => { }); expect(newState.sessions).toHaveLength(1); - expect(newState.sessions[0]).toEqual({ - mainFlyoutId: 'main-1', - childFlyoutId: null, - childHistory: [], - title: 'main', - iconType: undefined, - zIndex: 0, - }); + expect(newState.sessions[0]).toMatchInlineSnapshot(` + { + "childFlyoutId": null, + "childHistory": [], + "historyKey": Symbol(), + "iconType": undefined, + "mainFlyoutId": "main-1", + "title": "main", + "zIndex": 0, + } + `); }); it('should store iconType on session when addFlyout is called with iconType', () => { @@ -89,6 +92,7 @@ describe('flyoutManagerReducer', () => { 'Session A', LEVEL_MAIN, 'm', + undefined, 'faceHappy' ); const newState = flyoutManagerReducer(initialState, action); @@ -183,6 +187,7 @@ describe('flyoutManagerReducer', () => { 'Child 1 Updated', LEVEL_CHILD, undefined, + undefined, 'starFilled' ) ); @@ -214,22 +219,39 @@ describe('flyoutManagerReducer', () => { ); expect(state.sessions).toHaveLength(2); - expect(state.sessions[0]).toEqual({ - mainFlyoutId: 'main-1', - childFlyoutId: 'child-1', - childTitle: 'child', - childIconType: undefined, - childHistory: [], - title: 'main', - zIndex: 0, - }); - expect(state.sessions[1]).toEqual({ - mainFlyoutId: 'main-2', - childFlyoutId: null, - childHistory: [], - title: 'main', - zIndex: 3, - }); + expect(state.sessions[0]).toMatchInlineSnapshot(` + { + "childFlyoutId": "child-1", + "childHistory": [], + "childIconType": undefined, + "childTitle": "child", + "historyKey": Symbol(), + "iconType": undefined, + "mainFlyoutId": "main-1", + "title": "main", + "zIndex": 0, + } + `); + expect(state.sessions[1]).toMatchInlineSnapshot(` + { + "childFlyoutId": null, + "childHistory": [], + "historyKey": Symbol(), + "iconType": undefined, + "mainFlyoutId": "main-2", + "title": "main", + "zIndex": 3, + } + `); + }); + + it('should store historyKey on session when addFlyout main is called with historyKey', () => { + const key = Symbol('shared'); + const action = addFlyout('main-1', 'Session A', LEVEL_MAIN, 'm', key); + const newState = flyoutManagerReducer(initialState, action); + + expect(newState.sessions).toHaveLength(1); + expect(newState.sessions[0].historyKey).toBe(key); }); }); @@ -360,14 +382,15 @@ describe('flyoutManagerReducer', () => { }); it('should close all sessions and preserve unmanaged flyouts', () => { - // Setup: add managed and unmanaged flyouts + const historyKey = Symbol(); + // Setup: add managed and unmanaged flyouts (same historyKey so closeAll closes both sessions) let state = flyoutManagerReducer( initialState, - addFlyout('main-1', 'Main 1', LEVEL_MAIN) + addFlyout('main-1', 'Main 1', LEVEL_MAIN, undefined, historyKey) ); state = flyoutManagerReducer( state, - addFlyout('main-2', 'Main 2', LEVEL_MAIN) + addFlyout('main-2', 'Main 2', LEVEL_MAIN, undefined, historyKey) ); state = flyoutManagerReducer(state, addUnmanagedFlyout('unmanaged-1')); @@ -375,7 +398,7 @@ describe('flyoutManagerReducer', () => { expect(state.flyouts).toHaveLength(2); expect(state.unmanagedFlyouts).toHaveLength(1); - // Close all flyouts + // Close all flyouts (current history group = both sessions) const action = closeAllFlyouts(); state = flyoutManagerReducer(state, action); @@ -386,10 +409,11 @@ describe('flyoutManagerReducer', () => { }); it('should close all sessions including child flyouts', () => { - // Setup: add sessions with children + const historyKey = Symbol(); + // Setup: add sessions with children (same historyKey) let state = flyoutManagerReducer( initialState, - addFlyout('main-1', 'Main 1', LEVEL_MAIN) + addFlyout('main-1', 'Main 1', LEVEL_MAIN, undefined, historyKey) ); state = flyoutManagerReducer( state, @@ -397,13 +421,13 @@ describe('flyoutManagerReducer', () => { ); state = flyoutManagerReducer( state, - addFlyout('main-2', 'Main 2', LEVEL_MAIN) + addFlyout('main-2', 'Main 2', LEVEL_MAIN, undefined, historyKey) ); expect(state.sessions).toHaveLength(2); expect(state.flyouts).toHaveLength(3); - // Close all flyouts + // Close all flyouts (current group = both sessions) const action = closeAllFlyouts(); state = flyoutManagerReducer(state, action); @@ -420,15 +444,43 @@ describe('flyoutManagerReducer', () => { expect(newState).toEqual(initialState); }); + it('should close only current history group when multiple groups exist', () => { + const keyA = Symbol(); + const keyB = Symbol(); + let state = flyoutManagerReducer( + initialState, + addFlyout('main-1', 'Session A', LEVEL_MAIN, undefined, keyA) + ); + state = flyoutManagerReducer( + state, + addFlyout('main-2', 'Session B', LEVEL_MAIN, undefined, keyB) + ); + state = flyoutManagerReducer( + state, + addFlyout('main-3', 'Session A2', LEVEL_MAIN, undefined, keyA) + ); + + expect(state.sessions).toHaveLength(3); + + // closeAllFlyouts from top (main-3, keyA) removes only sessions with keyA: main-3 and main-1 + state = flyoutManagerReducer(state, closeAllFlyouts()); + + expect(state.sessions).toHaveLength(1); + expect(state.sessions[0].mainFlyoutId).toBe('main-2'); + expect(state.flyouts).toHaveLength(1); + expect(state.flyouts[0].flyoutId).toBe('main-2'); + }); + it('should reset currentZIndex value when all unmanaged and managed flyouts are closed', () => { - // Setup: add managed and unmanaged flyouts + const historyKey = Symbol(); + // Setup: add managed and unmanaged flyouts (same historyKey) let state = flyoutManagerReducer( initialState, - addFlyout('main-1', 'Main 1', LEVEL_MAIN) + addFlyout('main-1', 'Main 1', LEVEL_MAIN, undefined, historyKey) ); state = flyoutManagerReducer( state, - addFlyout('main-2', 'Main 2', LEVEL_MAIN) + addFlyout('main-2', 'Main 2', LEVEL_MAIN, undefined, historyKey) ); state = flyoutManagerReducer(state, addUnmanagedFlyout('unmanaged-1')); @@ -439,7 +491,7 @@ describe('flyoutManagerReducer', () => { state = flyoutManagerReducer(state, closeUnmanagedFlyout('unmanaged-1')); expect(state.currentZIndex).toEqual(8); - // Close all flyouts, currentZIndex should reset to 0 + // Close all flyouts (both sessions in group), currentZIndex should reset to 0 state = flyoutManagerReducer(state, closeAllFlyouts()); expect(state.currentZIndex).toBe(0); }); @@ -606,20 +658,21 @@ describe('flyoutManagerReducer', () => { describe('ACTION_GO_BACK', () => { it('should remove the current session and its flyouts', () => { - // Setup: create two sessions + const historyKey = Symbol(); + // Setup: create two sessions (same historyKey so goBack only removes one) let state = flyoutManagerReducer( initialState, - addFlyout('main-1', 'Session A', LEVEL_MAIN) + addFlyout('main-1', 'Session A', LEVEL_MAIN, undefined, historyKey) ); state = flyoutManagerReducer( state, - addFlyout('main-2', 'Session B', LEVEL_MAIN) + addFlyout('main-2', 'Session B', LEVEL_MAIN, undefined, historyKey) ); expect(state.sessions).toHaveLength(2); expect(state.flyouts).toHaveLength(2); - // Go back (should remove Session B) + // Go back (should remove Session B only, same group) const action = goBack(); state = flyoutManagerReducer(state, action); @@ -631,14 +684,15 @@ describe('flyoutManagerReducer', () => { }); it('should remove current session with child flyout', () => { - // Setup: create session with child + const historyKey = Symbol(); + // Setup: create session with child (same historyKey) let state = flyoutManagerReducer( initialState, - addFlyout('main-1', 'Session A', LEVEL_MAIN) + addFlyout('main-1', 'Session A', LEVEL_MAIN, undefined, historyKey) ); state = flyoutManagerReducer( state, - addFlyout('main-2', 'Session B', LEVEL_MAIN) + addFlyout('main-2', 'Session B', LEVEL_MAIN, undefined, historyKey) ); state = flyoutManagerReducer( state, @@ -649,7 +703,7 @@ describe('flyoutManagerReducer', () => { expect(state.sessions[1].childFlyoutId).toBe('child-2'); expect(state.flyouts).toHaveLength(3); - // Go back (should remove Session B and its child) + // Go back (should remove Session B and its child only) const action = goBack(); state = flyoutManagerReducer(state, action); @@ -659,6 +713,39 @@ describe('flyoutManagerReducer', () => { expect(state.flyouts[0].flyoutId).toBe('main-1'); }); + it('should keep other groups and restore previous session in current group when going back', () => { + const keyA = Symbol(); + const keyB = Symbol(); + let state = flyoutManagerReducer( + initialState, + addFlyout('main-1', 'Session A', LEVEL_MAIN, undefined, keyA) + ); + state = flyoutManagerReducer( + state, + addFlyout('main-2', 'Session B', LEVEL_MAIN, undefined, keyB) + ); + state = flyoutManagerReducer( + state, + addFlyout('main-3', 'Session A2', LEVEL_MAIN, undefined, keyA) + ); + + expect(state.sessions).toHaveLength(3); + + // goBack from main-3 (keyA): remove current session and bring prior keyA session to top. + // main-2 (keyB) remains in state and is restored when keyA group closes. + state = flyoutManagerReducer(state, goBack()); + + expect(state.sessions).toHaveLength(2); + expect(state.sessions.map((s) => s.mainFlyoutId)).toEqual([ + 'main-2', + 'main-1', + ]); + expect(state.flyouts.map((f) => f.flyoutId)).toEqual([ + 'main-1', + 'main-2', + ]); + }); + it('should do nothing when no sessions exist', () => { const action = goBack(); const newState = flyoutManagerReducer(initialState, action); @@ -807,6 +894,41 @@ describe('flyoutManagerReducer', () => { ]); }); + it('should preserve intervening groups when navigating to prior session in current history group', () => { + const keyA = Symbol(); + const keyB = Symbol(); + let state = flyoutManagerReducer( + initialState, + addFlyout('main-1', 'Session A1', LEVEL_MAIN, undefined, keyA) + ); + state = flyoutManagerReducer( + state, + addFlyout('main-2', 'Session B1', LEVEL_MAIN, undefined, keyB) + ); + state = flyoutManagerReducer( + state, + addFlyout('main-3', 'Session A2', LEVEL_MAIN, undefined, keyA) + ); + + expect(state.sessions.map((s) => s.mainFlyoutId)).toEqual([ + 'main-1', + 'main-2', + 'main-3', + ]); + + // Navigate from A2 to A1: remove newer same-group session(s) only and keep B1. + state = flyoutManagerReducer(state, goToFlyout('main-1')); + + expect(state.sessions.map((s) => s.mainFlyoutId)).toEqual([ + 'main-2', + 'main-1', + ]); + 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( @@ -1062,26 +1184,27 @@ describe('flyoutManagerReducer', () => { }); it('should handle multiple sessions with children', () => { + const historyKey = Symbol(); let state = initialState; - // Session 1: main + child + // Session 1: main + child (shared historyKey) state = flyoutManagerReducer( state, - addFlyout('main-1', 'main', LEVEL_MAIN) + addFlyout('main-1', 'main', LEVEL_MAIN, undefined, historyKey) ); state = flyoutManagerReducer( state, addFlyout('child-1', 'child', LEVEL_CHILD) ); - // Session 2: main only + // Session 2: main only (same historyKey) state = flyoutManagerReducer( state, - addFlyout('main-2', 'main', LEVEL_MAIN) + addFlyout('main-2', 'main', LEVEL_MAIN, undefined, historyKey) ); expect(state.sessions).toHaveLength(2); - expect(state.sessions[0]).toEqual({ + expect(state.sessions[0]).toMatchObject({ mainFlyoutId: 'main-1', childFlyoutId: 'child-1', childTitle: 'child', @@ -1090,7 +1213,7 @@ describe('flyoutManagerReducer', () => { title: 'main', zIndex: 0, }); - expect(state.sessions[1]).toEqual({ + expect(state.sessions[1]).toMatchObject({ mainFlyoutId: 'main-2', childFlyoutId: null, childHistory: [], @@ -1098,7 +1221,7 @@ describe('flyoutManagerReducer', () => { zIndex: 3, }); - // Close first session's main flyout + // Close current history group (both sessions share key, so both close) state = flyoutManagerReducer(state, closeAllFlyouts()); expect(state.sessions).toHaveLength(0); diff --git a/packages/eui/src/components/flyout/manager/reducer.ts b/packages/eui/src/components/flyout/manager/reducer.ts index 210628247b3..8243960b6a0 100644 --- a/packages/eui/src/components/flyout/manager/reducer.ts +++ b/packages/eui/src/components/flyout/manager/reducer.ts @@ -47,6 +47,35 @@ export const initialState: EuiFlyoutManagerState = { unmanagedFlyouts: [], }; +const addSessionFlyoutsToRemove = ( + session: FlyoutSession, + flyoutsToRemove: Set +) => { + flyoutsToRemove.add(session.mainFlyoutId); + if (session.childFlyoutId) { + flyoutsToRemove.add(session.childFlyoutId); + } + (session.childHistory ?? []).forEach((e) => flyoutsToRemove.add(e.flyoutId)); +}; + +const moveHistoryGroupToTop = ( + sessions: FlyoutSession[], + historyKey: symbol +): FlyoutSession[] => { + const groupSessions: FlyoutSession[] = []; + const otherSessions: FlyoutSession[] = []; + + sessions.forEach((session) => { + if (session.historyKey === historyKey) { + groupSessions.push(session); + } else { + otherSessions.push(session); + } + }); + + return [...otherSessions, ...groupSessions]; +}; + /** * Reducer handling all flyout manager actions and state transitions. */ @@ -93,7 +122,8 @@ export function flyoutManagerReducer( // - For a `child` flyout, attach it to the most recent session; if no // session exists, do nothing (invalid child without a parent). case ACTION_ADD: { - const { flyoutId, title, level, size, iconType, minWidth } = action; + const { flyoutId, title, level, size, historyKey, iconType, minWidth } = + action; const isDuplicate = state.flyouts.some((f) => f.flyoutId === flyoutId); const isIdempotentChild = @@ -125,6 +155,7 @@ export function flyoutManagerReducer( childFlyoutId: null, childHistory: [], zIndex: state.currentZIndex, + historyKey: historyKey ?? Symbol(), }; return { @@ -289,22 +320,41 @@ export function flyoutManagerReducer( return { ...state, sessions: updatedSessions, flyouts: newFlyouts }; } - // Unregister all flyouts. + // Unregister all flyouts (within the current history group only). case ACTION_CLOSE_ALL: { if (state.sessions.length === 0) { return state; } - // Reset current z-index to 0 only if no unmanaged flyouts remain. + const currentSessionIndex = state.sessions.length - 1; + const currentSession = state.sessions[currentSessionIndex]; + const currentKey = currentSession.historyKey; + + // Remove all sessions that have the current historyKey (entire group) + const newSessions = state.sessions.filter( + (s) => s.historyKey !== currentKey + ); + const flyoutsToRemove = new Set(); + state.sessions.forEach((session) => { + if (session.historyKey === currentKey) { + addSessionFlyoutsToRemove(session, flyoutsToRemove); + } + }); + + const newFlyouts = state.flyouts.filter( + (f) => !flyoutsToRemove.has(f.flyoutId) + ); + let newCurrentZIndex = state.currentZIndex; - if (state.unmanagedFlyouts.length === 0) { + if (newSessions.length === 0 && state.unmanagedFlyouts.length === 0) { newCurrentZIndex = 0; } return { - ...initialState, + ...state, + sessions: newSessions, + flyouts: newFlyouts, currentZIndex: newCurrentZIndex, - unmanagedFlyouts: state.unmanagedFlyouts, }; } @@ -352,7 +402,7 @@ export function flyoutManagerReducer( return { ...state, flyouts: updatedFlyouts }; } - // Go back: pop child history when any, else pop current session + // Go back: pop child history when any, else pop current session (only within same historyKey). case ACTION_GO_BACK: { if (state.sessions.length === 0) { return state; @@ -381,18 +431,26 @@ export function flyoutManagerReducer( } // No child history: pop current session (main + all its children) - const flyoutsToRemove = new Set([currentSession.mainFlyoutId]); - if (currentSession.childFlyoutId) { - flyoutsToRemove.add(currentSession.childFlyoutId); - } - (currentSession.childHistory ?? []).forEach((e) => - flyoutsToRemove.add(e.flyoutId) + const flyoutsToRemove = new Set(); + addSessionFlyoutsToRemove(currentSession, flyoutsToRemove); + + const sessionsWithoutCurrent = state.sessions.slice( + 0, + currentSessionIndex + ); + const hasRemainingInCurrentGroup = sessionsWithoutCurrent.some( + (s) => s.historyKey === currentSession.historyKey ); + const newSessions = hasRemainingInCurrentGroup + ? moveHistoryGroupToTop( + sessionsWithoutCurrent, + currentSession.historyKey + ) + : sessionsWithoutCurrent; const newFlyouts = state.flyouts.filter( (f) => !flyoutsToRemove.has(f.flyoutId) ); - const newSessions = state.sessions.slice(0, currentSessionIndex); return { ...state, sessions: newSessions, flyouts: newFlyouts }; } @@ -449,23 +507,49 @@ export function flyoutManagerReducer( return state; // Target flyout not found } + const currentSession = state.sessions[currentSessionIndex]; + const targetSession = state.sessions[targetSessionIndex]; + + // Group-local navigation: keep other history groups, remove only newer sessions in target's group, + // then bring that group to the top. + if (targetSession.historyKey === currentSession.historyKey) { + const flyoutsToRemove = new Set(); + const sessionsAfterTargetInGroup = state.sessions.filter( + (session, index) => + index > targetSessionIndex && + session.historyKey === targetSession.historyKey + ); + + sessionsAfterTargetInGroup.forEach((session) => { + addSessionFlyoutsToRemove(session, flyoutsToRemove); + }); + + const sessionsWithoutRemoved = state.sessions.filter( + (session) => + !sessionsAfterTargetInGroup.some( + (removed) => removed.mainFlyoutId === session.mainFlyoutId + ) + ); + const reorderedSessions = moveHistoryGroupToTop( + sessionsWithoutRemoved, + targetSession.historyKey + ); + const newFlyouts = state.flyouts.filter( + (f) => !flyoutsToRemove.has(f.flyoutId) + ); + + return { ...state, sessions: reorderedSessions, flyouts: newFlyouts }; + } + 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); - } - (session.childHistory ?? []).forEach((e) => - flyoutsToRemove.add(e.flyoutId) - ); + addSessionFlyoutsToRemove(session, flyoutsToRemove); }); const newFlyouts = state.flyouts.filter( (f) => !flyoutsToRemove.has(f.flyoutId) ); - const newSessions = state.sessions.slice(0, targetSessionIndex + 1); return { ...state, sessions: newSessions, flyouts: newFlyouts }; diff --git a/packages/eui/src/components/flyout/manager/store.test.ts b/packages/eui/src/components/flyout/manager/store.test.ts index 28ea1c88bce..90e56e00322 100644 --- a/packages/eui/src/components/flyout/manager/store.test.ts +++ b/packages/eui/src/components/flyout/manager/store.test.ts @@ -55,29 +55,55 @@ describe('Flyout Manager Store', () => { it('should update references when sessions change', () => { const store = getFlyoutManagerStore(); + const historyKey = Symbol(); - // Add first flyout - store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN); + // Add first flyout (with shared historyKey) + store.addFlyout( + 'flyout-1', + 'First Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); const firstHistoryItems = store.historyItems; - // Add second flyout (creates a new session) - store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN); + // Add second flyout (same historyKey so they share history) + store.addFlyout( + 'flyout-2', + 'Second Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); const secondHistoryItems = store.historyItems; // References should be different since sessions changed expect(secondHistoryItems).not.toBe(firstHistoryItems); - // Should have one history item (the first session) + // Should have one history item (the first session, same group) 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(); + const historyKey = Symbol(); - // Add two flyouts to create history - store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN); - store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN); + // Add two flyouts (same historyKey) to create history + store.addFlyout( + 'flyout-1', + 'First Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); + store.addFlyout( + 'flyout-2', + 'Second Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); const firstHistoryItems = store.historyItems; const firstOnClick = firstHistoryItems[0].onClick; @@ -95,15 +121,34 @@ describe('Flyout Manager Store', () => { it('should properly compute history items with correct titles', () => { const store = getFlyoutManagerStore(); + const historyKey = Symbol(); - // 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); + // Create multiple sessions (same historyKey so they share history) + store.addFlyout( + 'flyout-1', + 'First Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); + store.addFlyout( + 'flyout-2', + 'Second Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); + store.addFlyout( + 'flyout-3', + 'Third Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); const historyItems = store.historyItems; - // Should have 2 history items (all previous sessions, in reverse order) + // Should have 2 history items (previous sessions in same group, reverse order) expect(historyItems).toHaveLength(2); expect(historyItems[0].title).toBe('Second Flyout'); expect(historyItems[1].title).toBe('First Flyout'); @@ -111,15 +156,23 @@ describe('Flyout Manager Store', () => { it('should include iconType in history items when sessions were added with iconType', () => { const store = getFlyoutManagerStore(); + const historyKey = Symbol(); store.addFlyout( 'flyout-1', 'First Flyout', LEVEL_MAIN, undefined, + historyKey, 'faceHappy' ); - store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN); + store.addFlyout( + 'flyout-2', + 'Second Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); const historyItems = store.historyItems; @@ -131,10 +184,23 @@ describe('Flyout Manager Store', () => { it('should have functional onClick handlers', () => { const store = getFlyoutManagerStore(); + const historyKey = Symbol(); - // Create two sessions - store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN); - store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN); + // Create two sessions (same historyKey) + store.addFlyout( + 'flyout-1', + 'First Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); + store.addFlyout( + 'flyout-2', + 'Second Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); const historyItems = store.historyItems; @@ -147,10 +213,37 @@ describe('Flyout Manager Store', () => { expect(store.getState().sessions[0].mainFlyoutId).toBe('flyout-1'); }); + it('should keep intervening groups when history onClick navigates within a group', () => { + const store = getFlyoutManagerStore(); + const keyA = Symbol(); + const keyB = Symbol(); + + store.addFlyout('a-1', 'A1', LEVEL_MAIN, undefined, keyA); + store.addFlyout('b-1', 'B1', LEVEL_MAIN, undefined, keyB); + store.addFlyout('a-2', 'A2', LEVEL_MAIN, undefined, keyA); + + expect(store.historyItems).toHaveLength(1); + expect(store.historyItems[0].title).toBe('A1'); + + // Navigate to A1 from A2 history item. + store.historyItems[0].onClick(); + + // B1 should still exist and be restored behind the active A group. + expect(store.getState().sessions.map((s) => s.mainFlyoutId)).toEqual([ + 'b-1', + 'a-1', + ]); + expect(store.getState().flyouts.map((f) => f.flyoutId)).toEqual([ + 'a-1', + 'b-1', + ]); + }); + it('should include current session child history first, then previous main sessions (child items most recent first)', () => { const store = getFlyoutManagerStore(); + const historyKey = Symbol(); - store.addFlyout('main-1', 'Main', LEVEL_MAIN); + store.addFlyout('main-1', 'Main', LEVEL_MAIN, undefined, historyKey); store.addFlyout('child-1', 'Child 1', LEVEL_CHILD); store.addFlyout('child-2', 'Child 2', LEVEL_CHILD); @@ -160,10 +253,10 @@ describe('Flyout Manager Store', () => { expect(historyItems[0].title).toBe('Child 1'); expect(historyItems[0].onClick).toBeDefined(); - // Add a second main: current session becomes main-2 (no child). History = previous session's child breadcrumb (current child + child history), most recent first - store.addFlyout('main-2', 'Main 2', LEVEL_MAIN); + // Add a second main (same historyKey): current session becomes main-2 (no child), so history = previous session in group (main-1 had children) + store.addFlyout('main-2', 'Main 2', LEVEL_MAIN, undefined, historyKey); const historyItems2 = store.historyItems; - expect(historyItems2).toHaveLength(2); // Child 2 (main-1's current child), then Child 1 (main-1's child history) + expect(historyItems2).toHaveLength(2); // main-1 had Child 2 and Child 1 in history expect(historyItems2[0].title).toBe('Child 2'); expect(historyItems2[1].title).toBe('Child 1'); }); @@ -180,6 +273,16 @@ describe('Flyout Manager Store', () => { store.addFlyout('child-1', 'Child', LEVEL_CHILD); expect(store.historyItems).toHaveLength(0); }); + + it('should not share history when no historyKey is passed (each session gets unique Symbol)', () => { + const store = getFlyoutManagerStore(); + + store.addFlyout('main-1', 'First', LEVEL_MAIN); + store.addFlyout('main-2', 'Second', LEVEL_MAIN); + + // Each session has its own Symbol, so no shared history - current session has no previous in its group + expect(store.historyItems).toHaveLength(0); + }); }); describe('store subscription', () => { @@ -227,10 +330,23 @@ describe('Flyout Manager Store', () => { it('should emit CLOSE_SESSION event when a session is removed by going back', () => { const store = getFlyoutManagerStore(); const eventListener = jest.fn(); + const historyKey = Symbol(); - // Create two sessions - store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN); - store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN); + // Create two sessions (same historyKey so goBack only removes one) + store.addFlyout( + 'flyout-1', + 'First Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); + store.addFlyout( + 'flyout-2', + 'Second Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); const sessions = store.getState().sessions; expect(sessions).toHaveLength(2); @@ -238,10 +354,10 @@ describe('Flyout Manager Store', () => { // Subscribe to events const unsubscribe = store.subscribeToEvents(eventListener); - // Go back one session + // Go back one session (within same history group) store.goBack(); - // Should have emitted CLOSE_SESSION for the second session + // Should have emitted CLOSE_SESSION for the second session only expect(eventListener).toHaveBeenCalledTimes(1); expect(eventListener).toHaveBeenCalledWith({ type: 'CLOSE_SESSION', @@ -254,11 +370,30 @@ describe('Flyout Manager Store', () => { it('should emit CLOSE_SESSION event when navigating to a previous flyout', () => { const store = getFlyoutManagerStore(); const eventListener = jest.fn(); + const historyKey = Symbol(); - // Create three 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); + // Create three sessions (same historyKey) + store.addFlyout( + 'flyout-1', + 'First Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); + store.addFlyout( + 'flyout-2', + 'Second Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); + store.addFlyout( + 'flyout-3', + 'Third Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); const sessions = store.getState().sessions; expect(sessions).toHaveLength(3); @@ -287,19 +422,32 @@ describe('Flyout Manager Store', () => { const store = getFlyoutManagerStore(); const eventListener1 = jest.fn(); const eventListener2 = jest.fn(); + const historyKey = Symbol(); - store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN); - store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN); + store.addFlyout( + 'flyout-1', + 'First Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); + store.addFlyout( + 'flyout-2', + 'Second Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); const sessions = store.getState().sessions; store.subscribeToEvents(eventListener1); store.subscribeToEvents(eventListener2); - // Go back one session + // Go back one session (same group) store.goBack(); - // Both listeners should have been called + // Both listeners should have been called once (one session removed) expect(eventListener1).toHaveBeenCalledTimes(1); expect(eventListener1).toHaveBeenCalledWith({ type: 'CLOSE_SESSION', @@ -340,20 +488,33 @@ describe('Flyout Manager Store', () => { it('should emit CLOSE_SESSION events when all sessions are removed by closeAllFlyouts', () => { const store = getFlyoutManagerStore(); const eventListener = jest.fn(); + const historyKey = Symbol(); - // Create sessions - store.addFlyout('flyout-1', 'Test Flyout', LEVEL_MAIN); - store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN); + // Create sessions (same historyKey so closeAllFlyouts closes both) + store.addFlyout( + 'flyout-1', + 'Test Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); + store.addFlyout( + 'flyout-2', + 'Second Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); const sessions = store.getState().sessions; expect(sessions).toHaveLength(2); const unsubscribe = store.subscribeToEvents(eventListener); - // Closing flyout will close all sessions + // closeAllFlyouts closes only current history group (both sessions share key) store.closeAllFlyouts(); - // Should have emitted CLOSE_SESSION + // Should have emitted CLOSE_SESSION for both sessions expect(eventListener).toHaveBeenCalledTimes(2); expect(eventListener).toHaveBeenNthCalledWith(1, { type: 'CLOSE_SESSION', diff --git a/packages/eui/src/components/flyout/manager/store.ts b/packages/eui/src/components/flyout/manager/store.ts index 011cf23cd58..ee3b7026aa1 100644 --- a/packages/eui/src/components/flyout/manager/store.ts +++ b/packages/eui/src/components/flyout/manager/store.ts @@ -51,6 +51,7 @@ export interface FlyoutManagerStore { title: string, level?: EuiFlyoutLevel, size?: string, + historyKey?: symbol, iconType?: IconType, minWidth?: number ) => void; @@ -116,12 +117,21 @@ function createStore( currentSessionIndex >= 0 ? currentState.sessions[currentSessionIndex] : null; + + if (!currentSession) { + return []; + } + const previousSessions = currentState.sessions.slice( 0, currentSessionIndex ); + // Only include sessions in the same history group (same historyKey reference) + const previousSessionsInGroup = previousSessions.filter( + (session) => session.historyKey === currentSession.historyKey + ); - const childHistory = currentSession?.childHistory ?? []; + const childHistory = currentSession.childHistory ?? []; const childItems = [...childHistory].reverse().map((entry) => ({ title: entry.title, iconType: entry.iconType, @@ -130,14 +140,14 @@ function createStore( }, })); - // Previous sessions: list each session's current child then its child history (so all travelled entries appear) + // Previous sessions (same group): list each session's current child then its child history const previousSessionItems: Array<{ title: string; iconType?: IconType; onClick: () => void; }> = []; - for (let i = previousSessions.length - 1; i >= 0; i--) { - const session = previousSessions[i]; + for (let i = previousSessionsInGroup.length - 1; i >= 0; i--) { + const session = previousSessionsInGroup[i]; const mainTitle = session.title; const mainFlyoutId = session.mainFlyoutId; const history = session.childHistory ?? []; @@ -215,9 +225,17 @@ function createStore( subscribe, subscribeToEvents, dispatch, - addFlyout: (flyoutId, title, level, size, iconType, minWidth) => + addFlyout: (flyoutId, title, level, size, historyKey, iconType, minWidth) => dispatch( - addFlyoutAction(flyoutId, title, level, size, iconType, minWidth) + addFlyoutAction( + flyoutId, + title, + level, + size, + historyKey, + iconType, + minWidth + ) ), closeFlyout: (flyoutId) => dispatch(closeFlyoutAction(flyoutId)), closeAllFlyouts: () => dispatch(closeAllFlyoutsAction()), diff --git a/packages/eui/src/components/flyout/manager/types.ts b/packages/eui/src/components/flyout/manager/types.ts index 37ce31e077c..58cfa96fe6b 100644 --- a/packages/eui/src/components/flyout/manager/types.ts +++ b/packages/eui/src/components/flyout/manager/types.ts @@ -73,6 +73,8 @@ export interface FlyoutSession { childIconType?: IconType; /** Stack of child flyouts we navigated away from. */ childHistory: ChildHistoryEntry[]; + /** Key that scopes this session's history; same Symbol reference = same history group. Always set (from action or Symbol()). */ + historyKey: symbol; } export interface PushPaddingOffsets { @@ -111,6 +113,7 @@ export interface FlyoutManagerApi { title: string, level?: EuiFlyoutLevel, size?: string, + historyKey?: symbol, iconType?: IconType, minWidth?: number ) => void; diff --git a/packages/website/docs/components/containers/flyout/_session_management.mdx b/packages/website/docs/components/containers/flyout/_session_management.mdx index dff822fdab6..33baa441948 100644 --- a/packages/website/docs/components/containers/flyout/_session_management.mdx +++ b/packages/website/docs/components/containers/flyout/_session_management.mdx @@ -201,6 +201,191 @@ the history popover — it is not shown in the flyout's menu bar.
``` +##### Scoping history (`historyKey`) + +By default, every `session="start"` flyout gets its own isolated history group - +the Back button and history popover only ever show entries from flyouts that are +part of the same group. + +In some applications, multiple areas of the product can open `session="start"` +flyouts independently (for example, an Alerts panel and a Logs panel that are +both active at the same time). Without any extra configuration these areas each +have their own history. + +In the example below, the two **Alerts** buttons share a `historyKey` so their +flyouts form a single history group — use the Back button or history popover to +navigate between them. The **Logs** button uses a separate key, so its flyout +has its own isolated history. + +```tsx interactive +import React, { useState } from "react"; +import { + EuiButton, + EuiCode, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiTitle, +} from "@elastic/eui"; + +const alertsKey = Symbol("alerts"); +const logsKey = Symbol("logs"); + +export default () => { + const [showAlertOverview, setShowAlertOverview] = useState(false); + const [showAlertDetail, setShowAlertDetail] = useState(false); + const [showLogs, setShowLogs] = useState(false); + + return ( + <> + + + setShowAlertOverview(true)} + disabled={showAlertOverview} + > + Alert overview + + + + setShowAlertDetail(true)} + disabled={showAlertDetail} + > + Alert detail + + + + setShowLogs(true)} + disabled={showLogs} + > + Logs + + + + + {showAlertOverview && ( + setShowAlertOverview(false)} + > + + +

Alert overview

+
+
+ + +

+ This flyout shares a historyKey with{" "} + Alert detail. Open both to see them appear in + the same history group. +

+
+
+
+ )} + + {showAlertDetail && ( + setShowAlertDetail(false)} + > + + +

Alert detail

+
+
+ + +

+ This flyout shares a historyKey with{" "} + Alert overview. They form a single history + group, connected by the Back button. +

+
+
+
+ )} + + {showLogs && ( + setShowLogs(false)} + > + + +

Logs

+
+
+ + +

+ This flyout uses a different historyKey than + the Alerts flyouts. Its history is completely independent. +

+
+
+
+ )} + + ); +}; +``` + +However, if you want **two or more independently-rendered flyouts to share a single +history** — so that navigating Back can move between them — you can pass the same +`Symbol` reference as the `historyKey` prop to each flyout: + +```tsx +// Create the key once, outside the component (or in a shared module). +const alertsHistoryKey = Symbol('alerts'); + +// Both flyouts below will share the same Back button and history entries. + + {/* ... */} + + + + {/* ... */} + +``` + +Because JavaScript `Symbol` identity is used for the comparison, you must pass the +**same `Symbol` reference**. + +:::info Note +`historyKey` is only meaningful on `session="start"` (main) flyouts. Child flyouts +don't carry their own key — they attach to the most recent session, which already +has the key set by its main flyout. You should not pass `historyKey` on child flyouts. +::: + +Closing a managed flyout (via the × button or `ACTION_CLOSE_ALL`) will close all +flyouts in the **same history group** and leave flyouts belonging to other groups +untouched. + ##### Controlling session participation To prevent a flyout from being a part of the session management system: