diff --git a/packages/eui/changelogs/upcoming/9347.md b/packages/eui/changelogs/upcoming/9347.md new file mode 100644 index 000000000000..e6c02eec9213 --- /dev/null +++ b/packages/eui/changelogs/upcoming/9347.md @@ -0,0 +1 @@ +- Exported the flyout system store singleton and added an event observer for emitting close session events diff --git a/packages/eui/src/components/flyout/index.ts b/packages/eui/src/components/flyout/index.ts index b5c300815f05..d6f57839f289 100644 --- a/packages/eui/src/components/flyout/index.ts +++ b/packages/eui/src/components/flyout/index.ts @@ -33,3 +33,12 @@ export { EuiFlyoutMenu } from './flyout_menu'; // Hooks for using Manager-based flyouts export { useIsInManagedFlyout, useHasActiveSession } from './manager'; export { useIsInsideParentFlyout } from './flyout_parent_context'; + +// Flyout manager store (for cross-root state synchronization) +export { + getFlyoutManagerStore, + type FlyoutManagerStore, + type FlyoutManagerEvent, + type EuiFlyoutManagerState, + type FlyoutSession, +} from './manager'; diff --git a/packages/eui/src/components/flyout/manager/index.ts b/packages/eui/src/components/flyout/manager/index.ts index 73176e75d83a..06c99d956363 100644 --- a/packages/eui/src/components/flyout/manager/index.ts +++ b/packages/eui/src/components/flyout/manager/index.ts @@ -21,6 +21,14 @@ export { /** Reducer and default state for the flyout manager. */ export { flyoutManagerReducer, initialState } from './reducer'; +/** Flyout manager store singleton and types. */ +export { + getFlyoutManagerStore, + type FlyoutManagerStore, + type FlyoutManagerEvent, +} from './store'; +export type { EuiFlyoutManagerState, FlyoutSession } from './types'; + /** Provider component exposing the Flyout Manager API via context. */ export { EuiFlyoutManager } from './provider'; diff --git a/packages/eui/src/components/flyout/manager/store.test.ts b/packages/eui/src/components/flyout/manager/store.test.ts index 00ebf4a4a776..9bd574cc5f61 100644 --- a/packages/eui/src/components/flyout/manager/store.test.ts +++ b/packages/eui/src/components/flyout/manager/store.test.ts @@ -168,4 +168,119 @@ describe('Flyout Manager Store', () => { expect(listener2).toHaveBeenCalledTimes(1); }); }); + + describe('event subscription', () => { + it('should emit CLOSE_SESSION event when a session is removed by going back', () => { + const store = getFlyoutManagerStore(); + const eventListener = jest.fn(); + + // Create two sessions + store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN); + store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN); + + const sessions = store.getState().sessions; + expect(sessions).toHaveLength(2); + + // Subscribe to events + const unsubscribe = store.subscribeToEvents(eventListener); + + // Go back one session + store.goBack(); + + // Should have emitted CLOSE_SESSION for the second session + expect(eventListener).toHaveBeenCalledTimes(1); + expect(eventListener).toHaveBeenCalledWith({ + type: 'CLOSE_SESSION', + session: sessions[1], + }); + + unsubscribe(); + }); + + it('should emit CLOSE_SESSION event when navigating to a previous flyout', () => { + const store = getFlyoutManagerStore(); + const eventListener = jest.fn(); + + // 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); + + const sessions = store.getState().sessions; + expect(sessions).toHaveLength(3); + + // Subscribe to events + const unsubscribe = store.subscribeToEvents(eventListener); + + // Navigate to first flyout (should remove sessions 2 and 3) + store.goToFlyout('flyout-1'); + + // Should have emitted CLOSE_SESSION for sessions 2 and 3 + expect(eventListener).toHaveBeenCalledTimes(2); + expect(eventListener).toHaveBeenNthCalledWith(1, { + type: 'CLOSE_SESSION', + session: sessions[1], + }); + expect(eventListener).toHaveBeenNthCalledWith(2, { + type: 'CLOSE_SESSION', + session: sessions[2], + }); + + unsubscribe(); + }); + + it('should notify all event subscribers', () => { + const store = getFlyoutManagerStore(); + const eventListener1 = jest.fn(); + const eventListener2 = jest.fn(); + + store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN); + store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN); + + const sessions = store.getState().sessions; + + store.subscribeToEvents(eventListener1); + store.subscribeToEvents(eventListener2); + + // Go back one session + store.goBack(); + + // Both listeners should have been called + expect(eventListener1).toHaveBeenCalledTimes(1); + expect(eventListener1).toHaveBeenCalledWith({ + type: 'CLOSE_SESSION', + session: sessions[1], + }); + expect(eventListener2).toHaveBeenCalledTimes(1); + expect(eventListener2).toHaveBeenCalledWith({ + type: 'CLOSE_SESSION', + session: sessions[1], + }); + }); + + it('should emit CLOSE_SESSION when closing a main flyout removes its session', () => { + const store = getFlyoutManagerStore(); + const eventListener = jest.fn(); + + // Create a session + store.addFlyout('flyout-1', 'Test Flyout', LEVEL_MAIN); + + const sessions = store.getState().sessions; + expect(sessions).toHaveLength(1); + + const unsubscribe = store.subscribeToEvents(eventListener); + + // Close the main flyout + store.closeFlyout('flyout-1'); + + // Should have emitted CLOSE_SESSION + expect(eventListener).toHaveBeenCalledTimes(1); + expect(eventListener).toHaveBeenCalledWith({ + type: 'CLOSE_SESSION', + session: sessions[0], + }); + + unsubscribe(); + }); + }); }); diff --git a/packages/eui/src/components/flyout/manager/store.ts b/packages/eui/src/components/flyout/manager/store.ts index 6a4f038a4db6..2b0cfe3adef7 100644 --- a/packages/eui/src/components/flyout/manager/store.ts +++ b/packages/eui/src/components/flyout/manager/store.ts @@ -6,7 +6,11 @@ * Side Public License, v 1. */ -import type { EuiFlyoutLevel, EuiFlyoutManagerState } from './types'; +import type { + EuiFlyoutLevel, + EuiFlyoutManagerState, + FlyoutSession, +} from './types'; import type { Action } from './actions'; import { addFlyout as addFlyoutAction, @@ -23,9 +27,20 @@ import { flyoutManagerReducer, initialState } from './reducer'; type Listener = () => void; +/** + * Events emitted by the flyout manager store for external consumers. + */ +export type FlyoutManagerEvent = { + type: 'CLOSE_SESSION'; + session: FlyoutSession; +}; + +type EventListener = (event: FlyoutManagerEvent) => void; + export interface FlyoutManagerStore { getState: () => EuiFlyoutManagerState; subscribe: (listener: Listener) => () => void; + subscribeToEvents: (listener: EventListener) => () => void; dispatch: (action: Action) => void; // Convenience bound action creators addFlyout: ( @@ -53,6 +68,7 @@ function createStore( ): FlyoutManagerStore { let currentState: EuiFlyoutManagerState = initial; const listeners = new Set(); + const eventListeners = new Set(); const getState = () => currentState; @@ -63,6 +79,19 @@ function createStore( }; }; + const subscribeToEvents = (listener: EventListener) => { + eventListeners.add(listener); + return () => { + eventListeners.delete(listener); + }; + }; + + const emitEvent = (event: FlyoutManagerEvent) => { + eventListeners.forEach((listener) => { + listener(event); + }); + }; + // 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; @@ -94,6 +123,19 @@ function createStore( // This ensures stable references and avoids stale closures if (nextState.sessions !== previousSessions) { store.historyItems = computeHistoryItems(); + + // Detect removed sessions and emit CLOSE_SESSION events + const nextSessionIds = new Set( + nextState.sessions.map((s) => s.mainFlyoutId) + ); + previousSessions.forEach((session) => { + if (!nextSessionIds.has(session.mainFlyoutId)) { + emitEvent({ + type: 'CLOSE_SESSION', + session, + }); + } + }); } listeners.forEach((l) => { @@ -105,6 +147,7 @@ function createStore( store = { getState, subscribe, + subscribeToEvents, dispatch, addFlyout: (flyoutId, title, level, size) => dispatch(addFlyoutAction(flyoutId, title, level, size)),