diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 9a02e5fac27..47e130afcce 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -181,6 +181,15 @@ export type PluginExtensionEventHelpers = { * 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) => void; }; // Extension Points & Contexts diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx index 955c85923b6..c6c8ee37239 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx @@ -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( + + + + ); + + expect(subscribeSpy).toHaveBeenCalledWith(ToggleExtensionSidebarEvent, expect.any(Function)); + }); + + it('should toggle sidebar when receiving ToggleExtensionSidebarEvent', () => { + const TestComponentWithProps = () => { + const context = useExtensionSidebarContext(); + return ( +
+
{context.isOpen.toString()}
+
{context.dockedComponentId || 'undefined'}
+
{context.props ? JSON.stringify(context.props) : 'undefined'}
+
+ ); + }; + + render( + + + + ); + + // 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 ( +
+
{context.isOpen.toString()}
+
{context.dockedComponentId || 'undefined'}
+ +
+ ); + }; + + render( + + + + ); + + 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', () => { diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx index 9dcb1cf1edb..89073d6779a 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx @@ -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(() => { diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index ee064c0a387..a10460d786c 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -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}'`); diff --git a/public/app/types/events.ts b/public/app/types/events.ts index 978dfbf811f..5c7e4ba151b 100644 --- a/public/app/types/events.ts +++ b/public/app/types/events.ts @@ -34,6 +34,12 @@ export interface OpenExtensionSidebarPayload { componentTitle: string; } +export interface ToggleExtensionSidebarPayload { + props?: Record; + 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 { + static type = 'toggle-extension-sidebar'; +} + /** * @deprecated use ShowModalReactEvent instead that has this capability built in */