Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
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
Loading