Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/eui/changelogs/upcoming/9347.md
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for adding the changelog!

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Exported the flyout system store singleton and added an event observer for emitting close session events
9 changes: 9 additions & 0 deletions packages/eui/src/components/flyout/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
8 changes: 8 additions & 0 deletions packages/eui/src/components/flyout/manager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
115 changes: 115 additions & 0 deletions packages/eui/src/components/flyout/manager/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
45 changes: 44 additions & 1 deletion packages/eui/src/components/flyout/manager/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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: (
Expand Down Expand Up @@ -53,6 +68,7 @@ function createStore(
): FlyoutManagerStore {
let currentState: EuiFlyoutManagerState = initial;
const listeners = new Set<Listener>();
const eventListeners = new Set<EventListener>();

const getState = () => currentState;

Expand All @@ -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;
Expand Down Expand Up @@ -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) => {
Expand All @@ -105,6 +147,7 @@ function createStore(
store = {
getState,
subscribe,
subscribeToEvents,
dispatch,
addFlyout: (flyoutId, title, level, size) =>
dispatch(addFlyoutAction(flyoutId, title, level, size)),
Expand Down