ExtensionSidebar: Introduce ToggleExtensionSidebarEvent (#112325)

* ExtensionSidebar: Use appEvents for opening/closing sidebar

* Refactor

* Fix lint

* Introduce ToggleExtensionSidebar

* Revert keyboard shortcuts

* Revert "Refactor"

This reverts commit ffd901ed0c.

* Revert "ExtensionSidebar: Use appEvents for opening/closing sidebar"

This reverts commit 8083bec978.

* Fix test

* Refactor to prevent flakiness

* Try again

* Improve mocks

* simple

* trying anything at this point

* Flip test order

* Revert "Flip test order"

This reverts commit 2dd33f744e.

* Update togglespyes and act

* Try async await

* Update test

* Update solution

* Update

* Test

* Update

* Refactor

* Uodate comments

* Types

* Fix type
This commit is contained in:
Ivana Huckova
2025-10-15 13:20:10 +02:00
committed by GitHub
parent a3c4f5cb9f
commit 89b3f64479
5 changed files with 191 additions and 10 deletions
@@ -181,6 +181,15 @@ export type PluginExtensionEventHelpers<Context extends object = object> = {
* Closes the extension sidebar.
*/
closeSidebar: () => void;
/**
* @internal
* Toggles the extension sidebar with the registered component.
* If the sidebar is open with the same component, it will be closed.
* If the sidebar is closed or open with a different component, it will be opened with the specified component.
* @param componentTitle The title of the component to be toggled in the sidebar.
* @param props The props to be passed to the component.
*/
toggleSidebar: (componentTitle: string, props?: Record<string, unknown>) => void;
};
// Extension Points & Contexts
@@ -1,9 +1,9 @@
import { render, screen, act } from '@testing-library/react';
import { store, EventBusSrv, EventBus } from '@grafana/data';
import { store, EventBusSrv, EventBus, ExtensionInfo } from '@grafana/data';
import { getAppEvents, setAppEvents, locationService } from '@grafana/runtime';
import { getExtensionPointPluginMeta } from 'app/features/plugins/extensions/utils';
import { OpenExtensionSidebarEvent, CloseExtensionSidebarEvent } from 'app/types/events';
import { OpenExtensionSidebarEvent, CloseExtensionSidebarEvent, ToggleExtensionSidebarEvent } from 'app/types/events';
import {
ExtensionSidebarContextProvider,
@@ -17,11 +17,17 @@ const mockComponent = {
title: 'Test Component',
description: 'Test Description',
targets: [],
};
} as ExtensionInfo;
const mockDifferentComponent = {
title: 'Different Component',
description: 'Different Description',
targets: [],
} as ExtensionInfo;
const mockPluginMeta = {
pluginId: 'grafana-investigations-app',
addedComponents: [mockComponent],
addedComponents: [mockComponent, mockDifferentComponent],
addedLinks: [],
};
@@ -54,6 +60,7 @@ jest.mock('@grafana/runtime', () => ({
title: mockComponent.title,
},
],
isLoading: false,
})),
}));
@@ -334,8 +341,133 @@ describe('ExtensionSidebarProvider', () => {
expect(screen.getByTestId('docked-component-id')).toHaveTextContent('undefined');
});
it('should unsubscribe from both OpenExtensionSidebarEvent and CloseExtensionSidebarEvent on unmount', () => {
const unsubscribeMocks = [jest.fn(), jest.fn()];
it('should subscribe to ToggleExtensionSidebarEvent', () => {
render(
<ExtensionSidebarContextProvider>
<TestComponent />
</ExtensionSidebarContextProvider>
);
expect(subscribeSpy).toHaveBeenCalledWith(ToggleExtensionSidebarEvent, expect.any(Function));
});
it('should toggle sidebar when receiving ToggleExtensionSidebarEvent', () => {
const TestComponentWithProps = () => {
const context = useExtensionSidebarContext();
return (
<div>
<div data-testid="is-open">{context.isOpen.toString()}</div>
<div data-testid="docked-component-id">{context.dockedComponentId || 'undefined'}</div>
<div data-testid="props">{context.props ? JSON.stringify(context.props) : 'undefined'}</div>
</div>
);
};
render(
<ExtensionSidebarContextProvider>
<TestComponentWithProps />
</ExtensionSidebarContextProvider>
);
// Sidebar is closed
expect(screen.getByTestId('is-open')).toHaveTextContent('false');
// Toggle the sidebar to open it
act(() => {
// Call the toggle event handler
const toggleEventSubscriberCall = subscribeSpy.mock.calls.find((call) => call[0] === ToggleExtensionSidebarEvent);
expect(toggleEventSubscriberCall).toBeDefined();
const [, subscriberFn] = toggleEventSubscriberCall!;
subscriberFn(
new ToggleExtensionSidebarEvent({
pluginId: 'grafana-investigations-app',
componentTitle: 'Test Component',
props: { testProp: 'test value' },
})
);
});
// Sidebar is open
expect(screen.getByTestId('is-open')).toHaveTextContent('true');
expect(screen.getByTestId('props')).toHaveTextContent('{"testProp":"test value"}');
const expectedComponentId = JSON.stringify({
pluginId: 'grafana-investigations-app',
componentTitle: 'Test Component',
});
expect(screen.getByTestId('docked-component-id')).toHaveTextContent(expectedComponentId);
// Toggle the sidebar to close it
act(() => {
// Call the toggle event handler
const toggleEventSubscriberCall = subscribeSpy.mock.calls
.slice()
.reverse()
.find((call) => call[0] === ToggleExtensionSidebarEvent);
expect(toggleEventSubscriberCall).toBeDefined();
const [, subscriberFn] = toggleEventSubscriberCall!;
subscriberFn(
new ToggleExtensionSidebarEvent({
pluginId: mockPluginMeta.pluginId,
componentTitle: mockComponent.title,
})
);
});
expect(screen.getByTestId('is-open')).toHaveTextContent('false');
expect(screen.getByTestId('docked-component-id')).toHaveTextContent('undefined');
});
it('should toggle to different component when receiving ToggleExtensionSidebarEvent for different component', () => {
const componentId = getComponentIdFromComponentMeta(mockPluginMeta.pluginId, mockComponent.title);
(store.get as jest.Mock).mockReturnValue(componentId);
const TestComponentWithProps = () => {
const context = useExtensionSidebarContext();
return (
<div>
<div data-testid="is-open">{context.isOpen.toString()}</div>
<div data-testid="docked-component-id">{context.dockedComponentId || 'undefined'}</div>
<button onClick={() => context.setDockedComponentId(componentId)}>Open Sidebar</button>
</div>
);
};
render(
<ExtensionSidebarContextProvider>
<TestComponentWithProps />
</ExtensionSidebarContextProvider>
);
act(() => {
// Find the ToggleExtensionSidebarEvent subscriber
const toggleEventSubscriberCall = subscribeSpy.mock.calls
.slice()
.reverse()
.find((call) => call[0] === ToggleExtensionSidebarEvent);
expect(toggleEventSubscriberCall).toBeDefined();
const [, subscriberFn] = toggleEventSubscriberCall!;
// Call the toggle event handler with a different component
subscriberFn(
new ToggleExtensionSidebarEvent({
pluginId: mockPluginMeta.pluginId,
componentTitle: 'Different Component',
})
);
});
expect(screen.getByTestId('is-open')).toHaveTextContent('true');
const expectedComponentId = JSON.stringify({
pluginId: mockPluginMeta.pluginId,
componentTitle: 'Different Component',
});
expect(screen.getByTestId('docked-component-id')).toHaveTextContent(expectedComponentId);
});
it('should unsubscribe from all event subscriptions on unmount', () => {
const unsubscribeMocks = [jest.fn(), jest.fn(), jest.fn()];
let callIndex = 0;
subscribeSpy.mockImplementation(() => ({
@@ -350,9 +482,10 @@ describe('ExtensionSidebarProvider', () => {
unmount();
// Both event subscriptions should be unsubscribed
// All event subscriptions should be unsubscribed
expect(unsubscribeMocks[0]).toHaveBeenCalled();
expect(unsubscribeMocks[1]).toHaveBeenCalled();
expect(unsubscribeMocks[2]).toHaveBeenCalled();
});
it('should subscribe to location service observable', () => {
@@ -4,7 +4,7 @@ import { useLocalStorage } from 'react-use';
import { PluginExtensionPoints, store } from '@grafana/data';
import { getAppEvents, reportInteraction, usePluginLinks, locationService } from '@grafana/runtime';
import { ExtensionPointPluginMeta, getExtensionPointPluginMeta } from 'app/features/plugins/extensions/utils';
import { CloseExtensionSidebarEvent, OpenExtensionSidebarEvent } from 'app/types/events';
import { CloseExtensionSidebarEvent, OpenExtensionSidebarEvent, ToggleExtensionSidebarEvent } from 'app/types/events';
import { DEFAULT_EXTENSION_SIDEBAR_WIDTH, MAX_EXTENSION_SIDEBAR_WIDTH } from './ExtensionSidebar';
@@ -174,13 +174,28 @@ export const ExtensionSidebarContextProvider = ({ children }: ExtensionSidebarCo
setDockedComponentId(undefined);
};
const toggleSidebarHandler = (event: ToggleExtensionSidebarEvent) => {
const currentComponentMeta = getComponentMetaFromComponentId(dockedComponentId ?? '');
const isCurrentlyOpen =
currentComponentMeta?.pluginId === event.payload.pluginId &&
currentComponentMeta?.componentTitle === event.payload.componentTitle;
if (isCurrentlyOpen) {
closeSidebarHandler();
} else {
openSidebarHandler(event);
}
};
const openSubscription = getAppEvents().subscribe(OpenExtensionSidebarEvent, openSidebarHandler);
const closeSubscription = getAppEvents().subscribe(CloseExtensionSidebarEvent, closeSidebarHandler);
const toggleSubscription = getAppEvents().subscribe(ToggleExtensionSidebarEvent, toggleSidebarHandler);
return () => {
openSubscription.unsubscribe();
closeSubscription.unsubscribe();
toggleSubscription.unsubscribe();
};
}, [setDockedComponentWithProps, availableComponents]);
}, [setDockedComponentWithProps, availableComponents, dockedComponentId]);
// update the stored docked component id when it changes
useEffect(() => {
@@ -20,7 +20,12 @@ import { reportInteraction, config, AppPluginConfig } from '@grafana/runtime';
import { Modal } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
import { CloseExtensionSidebarEvent, OpenExtensionSidebarEvent, ShowModalReactEvent } from 'app/types/events';
import {
CloseExtensionSidebarEvent,
OpenExtensionSidebarEvent,
ShowModalReactEvent,
ToggleExtensionSidebarEvent,
} from 'app/types/events';
import { RestrictedGrafanaApisProvider } from '../components/restrictedGrafanaApis/RestrictedGrafanaApisProvider';
@@ -550,6 +555,15 @@ export function getLinkExtensionOnClick(
closeSidebar: () => {
appEvents.publish(new CloseExtensionSidebarEvent());
},
toggleSidebar: (componentTitle, context) => {
appEvents.publish(
new ToggleExtensionSidebarEvent({
props: context,
pluginId,
componentTitle,
})
);
},
};
log.debug(`onClick '${config.title}' at '${extensionPointId}'`);
+10
View File
@@ -34,6 +34,12 @@ export interface OpenExtensionSidebarPayload {
componentTitle: string;
}
export interface ToggleExtensionSidebarPayload {
props?: Record<string, unknown>;
pluginId: string;
componentTitle: string;
}
export interface ShowConfirmModalPayload {
title?: string;
text?: string;
@@ -181,6 +187,10 @@ export class CloseExtensionSidebarEvent extends BusEventBase {
static type = 'close-extension-sidebar';
}
export class ToggleExtensionSidebarEvent extends BusEventWithPayload<ToggleExtensionSidebarPayload> {
static type = 'toggle-extension-sidebar';
}
/**
* @deprecated use ShowModalReactEvent instead that has this capability built in
*/