Skip to content

Commit 7d60403

Browse files
authored
[Flyout System] Add close all flyouts action (#9378)
1 parent c67fe5c commit 7d60403

16 files changed

Lines changed: 296 additions & 67 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Updated `EuiFlyout` manager to close all flyouts when a parent flyout is closed.

packages/eui/src/components/flyout/manager/__mocks__/index.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,30 @@
77
*/
88

99
import { LEVEL_MAIN } from '../const';
10+
import { FlyoutManagerApi } from '../types';
1011

1112
/**
1213
* Centralized test utilities for flyout manager tests.
1314
*/
1415

1516
export const mockCloseFlyout = jest.fn();
17+
export const mockCloseAllFlyouts = jest.fn();
1618

17-
export const createMockFunctions = () => ({
19+
export const createMockFunctions = (): Omit<
20+
FlyoutManagerApi,
21+
'state' | 'historyItems'
22+
> => ({
1823
dispatch: jest.fn(),
1924
addFlyout: jest.fn(),
2025
closeFlyout: mockCloseFlyout,
26+
closeAllFlyouts: mockCloseAllFlyouts,
2127
setActiveFlyout: jest.fn(),
2228
setFlyoutWidth: jest.fn(),
2329
goBack: jest.fn(),
2430
goToFlyout: jest.fn(),
25-
getHistoryItems: jest.fn(() => []),
31+
addUnmanagedFlyout: jest.fn(),
32+
closeUnmanagedFlyout: jest.fn(),
33+
setPushPadding: jest.fn(),
2634
});
2735

2836
export const createMockState = () => ({
@@ -36,6 +44,7 @@ export const createMockState = () => ({
3644
*/
3745
export const createFlyoutManagerMock = () => ({
3846
state: createMockState(),
47+
historyItems: [],
3948
...createMockFunctions(),
4049
});
4150

packages/eui/src/components/flyout/manager/actions.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@
1616
import {
1717
addFlyout,
1818
closeFlyout,
19+
closeAllFlyouts,
1920
setActiveFlyout,
2021
setFlyoutWidth,
2122
setLayoutMode,
2223
setActivityStage,
2324
ACTION_ADD,
2425
ACTION_CLOSE,
26+
ACTION_CLOSE_ALL,
2527
ACTION_SET_ACTIVE,
2628
ACTION_SET_WIDTH,
2729
ACTION_SET_LAYOUT_MODE,
@@ -130,6 +132,16 @@ describe('flyout manager actions', () => {
130132
});
131133
});
132134

135+
describe('closeAllFlyouts', () => {
136+
it('should create close all flyouts action', () => {
137+
const action = closeAllFlyouts();
138+
139+
expect(action).toEqual({
140+
type: ACTION_CLOSE_ALL,
141+
});
142+
});
143+
});
144+
133145
describe('setActiveFlyout', () => {
134146
it('should create set active flyout action with flyout ID', () => {
135147
const action = setActiveFlyout('child-1');

packages/eui/src/components/flyout/manager/actions.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ interface BaseAction {
2323
export const ACTION_ADD = `${PREFIX}/add` as const;
2424
/** Dispatched to remove a flyout from the manager (usually on close/unmount). */
2525
export const ACTION_CLOSE = `${PREFIX}/close` as const;
26+
/** Dispatched to remove all flyouts from the manager. */
27+
export const ACTION_CLOSE_ALL = `${PREFIX}/closeAll` as const;
2628
/** Dispatched to set which flyout is currently active within the session. */
2729
export const ACTION_SET_ACTIVE = `${PREFIX}/setActive` as const;
2830
/** Dispatched when an active flyout's pixel width changes (for responsive layout). */
@@ -60,6 +62,11 @@ export interface CloseFlyoutAction extends BaseAction {
6062
flyoutId: string;
6163
}
6264

65+
/** Remove all flyouts from manager state. */
66+
export interface CloseAllFlyoutsAction extends BaseAction {
67+
type: typeof ACTION_CLOSE_ALL;
68+
}
69+
6370
/** Set the active flyout within the current session (or clear with `null`). */
6471
export interface SetActiveFlyoutAction extends BaseAction {
6572
type: typeof ACTION_SET_ACTIVE;
@@ -118,6 +125,7 @@ export interface CloseUnmanagedFlyoutAction extends BaseAction {
118125
export type Action =
119126
| AddFlyoutAction
120127
| CloseFlyoutAction
128+
| CloseAllFlyoutsAction
121129
| SetActiveFlyoutAction
122130
| SetWidthAction
123131
| SetLayoutModeAction
@@ -153,6 +161,11 @@ export const closeFlyout = (flyoutId: string): CloseFlyoutAction => ({
153161
flyoutId,
154162
});
155163

164+
/** Unregister all flyouts. */
165+
export const closeAllFlyouts = (): CloseAllFlyoutsAction => ({
166+
type: ACTION_CLOSE_ALL,
167+
});
168+
156169
/** Set or clear the active flyout for the current session. */
157170
export const setActiveFlyout = (
158171
flyoutId: string | null

packages/eui/src/components/flyout/manager/flyout_main.test.tsx

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { render } from '../../../test/rtl';
1212
import { EuiFlyoutMain } from './flyout_main';
1313
import { EuiFlyoutManager } from './provider';
1414
import { LEVEL_MAIN, PROPERTY_LEVEL } from './const';
15+
import { createFlyoutManagerMock } from './__mocks__';
1516

1617
// Mock managed flyout so we can observe props passed through
1718
jest.mock('./flyout_managed', () => ({
@@ -27,19 +28,11 @@ jest.mock('./flyout_managed', () => ({
2728
),
2829
}));
2930

31+
const mockUseFlyoutManager = createFlyoutManagerMock();
32+
3033
// Keep layout/ID hooks deterministic
3134
jest.mock('./hooks', () => ({
32-
useFlyoutManager: () => ({
33-
state: { sessions: [], flyouts: [], layoutMode: 'side-by-side' },
34-
dispatch: jest.fn(),
35-
addFlyout: jest.fn(),
36-
closeFlyout: jest.fn(),
37-
setActiveFlyout: jest.fn(),
38-
setFlyoutWidth: jest.fn(),
39-
goBack: jest.fn(),
40-
goToFlyout: jest.fn(),
41-
historyItems: [],
42-
}),
35+
useFlyoutManager: () => mockUseFlyoutManager,
4336
useHasChildFlyout: () => false,
4437
useFlyoutId: (id?: string) => id ?? 'generated-id',
4538
}));

packages/eui/src/components/flyout/manager/flyout_managed.test.tsx

Lines changed: 89 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ jest.mock('../flyout.component', () => {
5858

5959
// Shared mock functions - must be defined in module scope for Jest
6060
const mockCloseFlyout = jest.fn();
61+
const mockCloseAllFlyouts = jest.fn();
6162

6263
// Create mock state and functions once at module scope to avoid redundant object creation
6364
const mockState = {
@@ -70,6 +71,7 @@ const mockFunctions = {
7071
dispatch: jest.fn(),
7172
addFlyout: jest.fn(),
7273
closeFlyout: mockCloseFlyout,
74+
closeAllFlyouts: mockCloseAllFlyouts,
7375
setActiveFlyout: jest.fn(),
7476
setFlyoutWidth: jest.fn(),
7577
goBack: jest.fn(),
@@ -152,7 +154,7 @@ describe('EuiManagedFlyout', () => {
152154
expect(el).toHaveAttribute(PROPERTY_LEVEL, LEVEL_MAIN);
153155
});
154156

155-
it('calls the unregister callback prop when onClose', () => {
157+
it('calls closeAllFlyouts during cleanup when main flyout unmounts', () => {
156158
const onClose = jest.fn();
157159

158160
const { getByTestSubject, unmount } = renderInProvider(
@@ -166,12 +168,36 @@ describe('EuiManagedFlyout', () => {
166168
// The onClose should be called when the flyout is clicked
167169
expect(onClose).toHaveBeenCalled();
168170

171+
// The closeAllFlyouts should be called when the component unmounts (cleanup)
172+
act(() => {
173+
unmount();
174+
});
175+
176+
expect(mockCloseAllFlyouts).toHaveBeenCalled();
177+
expect(mockCloseFlyout).not.toHaveBeenCalled();
178+
});
179+
180+
it('calls closeFlyout during cleanup when child flyout unmounts', () => {
181+
const onClose = jest.fn();
182+
183+
const { getByTestSubject, unmount } = renderInProvider(
184+
<EuiManagedFlyout id="close-me" level={LEVEL_CHILD} onClose={onClose} />
185+
);
186+
187+
act(() => {
188+
userEvent.click(getByTestSubject('managed-flyout'));
189+
});
190+
191+
// The onClose should be called when the flyout is clicked
192+
expect(onClose).toHaveBeenCalled();
193+
169194
// The closeFlyout should be called when the component unmounts (cleanup)
170195
act(() => {
171196
unmount();
172197
});
173198

174199
expect(mockCloseFlyout).toHaveBeenCalledWith('close-me');
200+
expect(mockCloseAllFlyouts).not.toHaveBeenCalled();
175201
});
176202

177203
it('registers child flyout and sets data-level child', () => {
@@ -463,7 +489,7 @@ describe('EuiManagedFlyout', () => {
463489
expect(onClose).not.toHaveBeenCalled();
464490

465491
// Clear any calls from mount
466-
mockCloseFlyout.mockClear();
492+
mockCloseAllFlyouts.mockClear();
467493

468494
// Unmount the component to trigger cleanup
469495
act(() => {
@@ -504,13 +530,13 @@ describe('EuiManagedFlyout', () => {
504530
});
505531

506532
describe('manager state update ordering', () => {
507-
it('calls closeFlyout before parent onClose callback', () => {
533+
it('calls closeAllFlyouts before parent onClose callback', () => {
508534
const onClose = jest.fn();
509535
const callOrder: string[] = [];
510536

511537
// Track call order
512-
mockCloseFlyout.mockImplementation(() => {
513-
callOrder.push('closeFlyout');
538+
mockCloseAllFlyouts.mockImplementation(() => {
539+
callOrder.push('closeAllFlyouts');
514540
});
515541
onClose.mockImplementation(() => {
516542
callOrder.push('onClose');
@@ -530,13 +556,13 @@ describe('EuiManagedFlyout', () => {
530556
userEvent.click(getByTestSubject('managed-flyout'));
531557
});
532558

533-
// Verify closeFlyout was called BEFORE onClose
534-
expect(callOrder).toEqual(['closeFlyout', 'onClose']);
535-
expect(mockCloseFlyout).toHaveBeenCalledWith('ordering-test');
559+
// Verify closeAllFlyouts was called BEFORE onClose
560+
expect(callOrder).toEqual(['closeAllFlyouts', 'onClose']);
561+
expect(mockCloseAllFlyouts).toHaveBeenCalled();
536562
expect(onClose).toHaveBeenCalled();
537563
});
538564

539-
it('prevents duplicate closeFlyout calls when closing via user interaction', () => {
565+
it('prevents duplicate closeAllFlyouts calls when closing via user interaction', () => {
540566
const onClose = jest.fn();
541567

542568
const { getByTestSubject, unmount } = renderInProvider(
@@ -549,23 +575,23 @@ describe('EuiManagedFlyout', () => {
549575
);
550576

551577
// Clear any setup calls
552-
mockCloseFlyout.mockClear();
578+
mockCloseAllFlyouts.mockClear();
553579

554580
// User closes the flyout
555581
act(() => {
556582
userEvent.click(getByTestSubject('managed-flyout'));
557583
});
558584

559-
// closeFlyout should be called once from the onClose handler
560-
expect(mockCloseFlyout).toHaveBeenCalledTimes(1);
585+
// closeAllFlyouts should be called once from the onClose handler
586+
expect(mockCloseAllFlyouts).toHaveBeenCalledTimes(1);
561587

562588
// Manual, duplicate cleanup call
563589
act(() => {
564590
unmount();
565591
});
566592

567593
// Should still be called only once total
568-
expect(mockCloseFlyout).toHaveBeenCalledTimes(1);
594+
expect(mockCloseAllFlyouts).toHaveBeenCalledTimes(1);
569595
});
570596

571597
it('handles cascade close correctly when main flyout closes', () => {
@@ -603,10 +629,51 @@ describe('EuiManagedFlyout', () => {
603629
});
604630

605631
// Manager should be notified to handle cascade close
606-
expect(mockCloseFlyout).toHaveBeenCalledWith('main-flyout');
632+
expect(mockCloseAllFlyouts).toHaveBeenCalled();
607633
expect(onCloseMain).toHaveBeenCalled();
608634
});
609635

636+
it('calls closeFlyout when closing a child flyout', () => {
637+
const onCloseMain = jest.fn();
638+
const onCloseChild = jest.fn();
639+
640+
// Simulate a main flyout with child
641+
const { container } = renderInProvider(
642+
<>
643+
<EuiManagedFlyout
644+
id="main-flyout"
645+
level={LEVEL_MAIN}
646+
onClose={onCloseMain}
647+
flyoutMenuProps={{ title: 'Main Flyout' }}
648+
data-test-subj="main-flyout-element"
649+
/>
650+
<EuiManagedFlyout
651+
id="child-flyout"
652+
level={LEVEL_CHILD}
653+
onClose={onCloseChild}
654+
data-test-subj="child-flyout-element"
655+
/>
656+
</>
657+
);
658+
659+
// Find the child flyout specifically
660+
const childFlyout = container.querySelector('[id="child-flyout"]');
661+
expect(childFlyout).toBeInTheDocument();
662+
663+
// Close the child flyout
664+
act(() => {
665+
if (childFlyout) {
666+
userEvent.click(childFlyout);
667+
}
668+
});
669+
670+
// Child flyouts should call closeFlyout, not closeAllFlyouts
671+
expect(mockCloseFlyout).toHaveBeenCalledWith('child-flyout');
672+
expect(mockCloseFlyout).toHaveBeenCalledTimes(1);
673+
expect(mockCloseAllFlyouts).not.toHaveBeenCalled();
674+
expect(onCloseChild).toHaveBeenCalled();
675+
});
676+
610677
it('uses flushSync to ensure synchronous state update before DOM cleanup', () => {
611678
const onClose = jest.fn();
612679

@@ -621,7 +688,7 @@ describe('EuiManagedFlyout', () => {
621688

622689
// Clear any setup calls
623690
mockFlushSync.mockClear();
624-
mockCloseFlyout.mockClear();
691+
mockCloseAllFlyouts.mockClear();
625692

626693
// Trigger close via user interaction
627694
act(() => {
@@ -632,14 +699,14 @@ describe('EuiManagedFlyout', () => {
632699
expect(mockFlushSync).toHaveBeenCalledTimes(1);
633700
expect(mockFlushSync).toHaveBeenCalledWith(expect.any(Function));
634701

635-
// Verify closeFlyout was called (inside flushSync)
636-
expect(mockCloseFlyout).toHaveBeenCalledWith('flush-sync-test');
702+
// Verify closeAllFlyouts was called (inside flushSync)
703+
expect(mockCloseAllFlyouts).toHaveBeenCalled();
637704

638705
// Verify onClose was called after the synchronous state update
639706
expect(onClose).toHaveBeenCalled();
640707
});
641708

642-
it('calls closeFlyout inside flushSync callback', () => {
709+
it('calls closeAllFlyouts inside flushSync callback', () => {
643710
const onClose = jest.fn();
644711
const callOrder: string[] = [];
645712

@@ -650,8 +717,8 @@ describe('EuiManagedFlyout', () => {
650717
callOrder.push('flushSync-end');
651718
});
652719

653-
mockCloseFlyout.mockImplementation(() => {
654-
callOrder.push('closeFlyout');
720+
mockCloseAllFlyouts.mockImplementation(() => {
721+
callOrder.push('closeAllFlyouts');
655722
});
656723

657724
onClose.mockImplementation(() => {
@@ -675,10 +742,10 @@ describe('EuiManagedFlyout', () => {
675742
userEvent.click(getByTestSubject('managed-flyout'));
676743
});
677744

678-
// Verify closeFlyout is called INSIDE flushSync, and onClose is called AFTER
745+
// Verify closeAllFlyouts is called INSIDE flushSync, and onClose is called AFTER
679746
expect(callOrder).toEqual([
680747
'flushSync-start',
681-
'closeFlyout',
748+
'closeAllFlyouts',
682749
'flushSync-end',
683750
'onClose',
684751
]);

0 commit comments

Comments
 (0)