From 722cd25da1ee7cf285c86d7f03360f0f42e9b1bd Mon Sep 17 00:00:00 2001 From: Sven Grossmann Date: Mon, 14 Apr 2025 14:59:35 +0200 Subject: [PATCH] Extension Sidebar: Add `openExtensionSidebar` helper to plugin extensions (#103962) * Extension Sidebar: add `openExtensionSidebar` helper * Extension Sidebar: Change var to `props` * Extension Sidebar: Fix comment * Extension Sidebar: Destructure `props` * Extension Sidebar: Remove `@alpha` and rename `context` to `props` as `any` * Extension Sidebar: Improve docs * Extension Sidebar: Rename `openExtensionSidebar` to `openSidebar` * Extension Sidebar: Use `Record` as type * Extension Sidebar: Use `Record` as type * Extension Sidebar: Add tests for event based opening * Extension Sidebar: Lint * Extension Sidebar: Fix toolbar button tests --- .../src/types/pluginExtensions.ts | 7 + .../ExtensionSidebar/ExtensionSidebar.tsx | 12 +- .../ExtensionSidebarProvider.test.tsx | 135 +++++++++++++++++- .../ExtensionSidebarProvider.tsx | 44 +++++- .../ExtensionToolbarItem.test.tsx | 9 +- .../app/features/plugins/extensions/utils.tsx | 11 +- public/app/types/events.ts | 10 ++ 7 files changed, 216 insertions(+), 12 deletions(-) diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index f33a67d8bf9..c7a1df01b23 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -167,6 +167,13 @@ export type PluginExtensionEventHelpers = { context?: Readonly; // Opens a modal dialog and renders the provided React component inside it openModal: (options: PluginExtensionOpenModalOptions) => void; + /** + * @internal + * Opens the extension sidebar with the registered component. + * @param componentTitle The title of the component to be opened in the sidebar. + * @param props The props to be passed to the component. + */ + openSidebar: (componentTitle: string, props?: Record) => void; }; // Extension Points & Contexts diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebar.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebar.tsx index 89e95a4815d..9c1c08eb578 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebar.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebar.tsx @@ -14,10 +14,16 @@ export const DEFAULT_EXTENSION_SIDEBAR_WIDTH = 300; export const MIN_EXTENSION_SIDEBAR_WIDTH = 100; export const MAX_EXTENSION_SIDEBAR_WIDTH = 700; +type ExtensionSidebarComponentProps = { + props?: Record; +}; + export function ExtensionSidebar() { const styles = getStyles(useTheme2()); - const { dockedComponentId, isEnabled } = useExtensionSidebarContext(); - const { components, isLoading } = usePluginComponents({ extensionPointId: EXTENSION_SIDEBAR_EXTENSION_POINT_ID }); + const { dockedComponentId, isEnabled, props = {} } = useExtensionSidebarContext(); + const { components, isLoading } = usePluginComponents({ + extensionPointId: EXTENSION_SIDEBAR_EXTENSION_POINT_ID, + }); if (isLoading || !dockedComponentId || !isEnabled) { return null; @@ -39,7 +45,7 @@ export function ExtensionSidebar() { return (
- +
); diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx index 2055802bf22..660aa81e014 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 { render } from 'test/test-utils'; -import { store } from '@grafana/data'; -import { config } from '@grafana/runtime'; +import { store, EventBusSrv, EventBus } from '@grafana/data'; +import { config, getAppEvents, setAppEvents } from '@grafana/runtime'; import { getExtensionPointPluginMeta } from 'app/features/plugins/extensions/utils'; +import { OpenExtensionSidebarEvent } from 'app/types/events'; import { ExtensionSidebarContextProvider, @@ -52,10 +52,31 @@ const mockPluginMeta = { }; describe('ExtensionSidebarProvider', () => { + let subscribeSpy: jest.SpyInstance; + let originalAppEvents: EventBus; + let mockEventBus: EventBusSrv; + beforeEach(() => { jest.clearAllMocks(); + + originalAppEvents = getAppEvents(); + + mockEventBus = new EventBusSrv(); + subscribeSpy = jest.spyOn(mockEventBus, 'subscribe'); + + setAppEvents(mockEventBus); + (getExtensionPointPluginMeta as jest.Mock).mockReturnValue(new Map([[mockPluginMeta.pluginId, mockPluginMeta]])); + jest.replaceProperty(config.featureToggles, 'extensionSidebar', true); + + (store.get as jest.Mock).mockReturnValue(undefined); + (store.set as jest.Mock).mockImplementation(() => {}); + (store.delete as jest.Mock).mockImplementation(() => {}); + }); + + afterEach(() => { + setAppEvents(originalAppEvents); }); const TestComponent = () => { @@ -199,6 +220,114 @@ describe('ExtensionSidebarProvider', () => { expect(screen.getByTestId('available-components-size')).toHaveTextContent('1'); expect(screen.getByTestId('plugin-ids')).toHaveTextContent(permittedPluginMeta.pluginId); }); + + it('should subscribe to OpenExtensionSidebarEvent when feature is enabled', async () => { + render( + + + + ); + + expect(subscribeSpy).toHaveBeenCalledWith(OpenExtensionSidebarEvent, expect.any(Function)); + }); + + it('should not subscribe to OpenExtensionSidebarEvent when feature is disabled', () => { + jest.replaceProperty(config.featureToggles, 'extensionSidebar', false); + + render( + + + + ); + + expect(subscribeSpy).not.toHaveBeenCalled(); + }); + + it('should set dockedComponentId and props when receiving a valid OpenExtensionSidebarEvent', () => { + const TestComponentWithProps = () => { + const context = useExtensionSidebarContext(); + return ( +
+
{context.isOpen.toString()}
+
{context.dockedComponentId || 'undefined'}
+
{context.props ? JSON.stringify(context.props) : 'undefined'}
+
+ ); + }; + + render( + + + + ); + + expect(screen.getByTestId('is-open')).toHaveTextContent('false'); + expect(screen.getByTestId('props')).toHaveTextContent('undefined'); + + expect(subscribeSpy).toHaveBeenCalledWith(OpenExtensionSidebarEvent, expect.any(Function)); + act(() => { + // Get the event subscriber function + const [[, subscriberFn]] = subscribeSpy.mock.calls; + + // Call it directly with the test event + subscriberFn( + new OpenExtensionSidebarEvent({ + pluginId: 'grafana-investigations-app', + componentTitle: 'Test Component', + props: { testProp: 'test value' }, + }) + ); + }); + + 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); + }); + + it('should not open sidebar when receiving an OpenExtensionSidebarEvent with non-permitted plugin', () => { + render( + + + + ); + + expect(screen.getByTestId('is-open')).toHaveTextContent('false'); + + act(() => { + // Get the event subscriber function + const [[, subscriberFn]] = subscribeSpy.mock.calls; + + // Call it directly with the test event for a non-permitted plugin + subscriberFn( + new OpenExtensionSidebarEvent({ + pluginId: 'non-permitted-plugin', + componentTitle: 'Test Component', + }) + ); + }); + + expect(screen.getByTestId('is-open')).toHaveTextContent('false'); + }); + + it('should unsubscribe from OpenExtensionSidebarEvent on unmount', () => { + const unsubscribeMock = jest.fn(); + subscribeSpy.mockReturnValue({ + unsubscribe: unsubscribeMock, + }); + + const { unmount } = render( + + + + ); + + unmount(); + expect(unsubscribeMock).toHaveBeenCalled(); + }); }); describe('Utility Functions', () => { diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx index 6346a561555..8f64c6a6071 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx @@ -1,9 +1,10 @@ -import { createContext, ReactNode, useContext, useEffect, useState } from 'react'; +import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react'; import { useLocalStorage } from 'react-use'; import { store, type ExtensionInfo } from '@grafana/data'; -import { config } from '@grafana/runtime'; +import { config, getAppEvents } from '@grafana/runtime'; import { ExtensionPointPluginMeta, getExtensionPointPluginMeta } from 'app/features/plugins/extensions/utils'; +import { OpenExtensionSidebarEvent } from 'app/types/events'; import { DEFAULT_EXTENSION_SIDEBAR_WIDTH } from './ExtensionSidebar'; @@ -45,6 +46,8 @@ type ExtensionSidebarContextType = { * Set the width of the extension sidebar. */ setExtensionSidebarWidth: (width: number) => void; + + props?: Record; }; export const ExtensionSidebarContext = createContext({ @@ -66,6 +69,7 @@ interface ExtensionSidebarContextProps { } export const ExtensionSidebarContextProvider = ({ children }: ExtensionSidebarContextProps) => { + const [props, setProps] = useState | undefined>(undefined); const storedDockedPluginId = store.get(EXTENSION_SIDEBAR_DOCKED_LOCAL_STORAGE_KEY); const [extensionSidebarWidth, setExtensionSidebarWidth] = useLocalStorage( EXTENSION_SIDEBAR_WIDTH_LOCAL_STORAGE_KEY, @@ -104,6 +108,39 @@ export const ExtensionSidebarContextProvider = ({ children }: ExtensionSidebarCo } const [dockedComponentId, setDockedComponentId] = useState(defaultDockedComponentId); + const setDockedComponentWithProps = useCallback( + (componentId: string | undefined, props?: Record) => { + setProps(props); + setDockedComponentId(componentId); + }, + [setDockedComponentId] + ); + + useEffect(() => { + if (!isEnabled) { + return; + } + + // handler to open the extension sidebar from plugins. this is done with the `helpers.openSidebar` function + const openSidebarHandler = (event: OpenExtensionSidebarEvent) => { + if ( + event.payload.pluginId && + event.payload.componentTitle && + PERMITTED_EXTENSION_SIDEBAR_PLUGINS.includes(event.payload.pluginId) + ) { + setDockedComponentWithProps( + JSON.stringify({ pluginId: event.payload.pluginId, componentTitle: event.payload.componentTitle }), + event.payload.props + ); + } + }; + + const subscription = getAppEvents().subscribe(OpenExtensionSidebarEvent, openSidebarHandler); + return () => { + subscription.unsubscribe(); + }; + }, [isEnabled, setDockedComponentWithProps]); + // update the stored docked component id when it changes useEffect(() => { if (dockedComponentId) { @@ -119,10 +156,11 @@ export const ExtensionSidebarContextProvider = ({ children }: ExtensionSidebarCo isEnabled, isOpen: isEnabled && dockedComponentId !== undefined, dockedComponentId, - setDockedComponentId, + setDockedComponentId: (componentId) => setDockedComponentWithProps(componentId, undefined), availableComponents, extensionSidebarWidth: extensionSidebarWidth ?? DEFAULT_EXTENSION_SIDEBAR_WIDTH, setExtensionSidebarWidth, + props, }} > {children} diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx index ccea31d0059..7b6823c56d3 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx @@ -1,8 +1,8 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { store } from '@grafana/data'; -import { config } from '@grafana/runtime'; +import { EventBusSrv, store } from '@grafana/data'; +import { config, setAppEvents } from '@grafana/runtime'; import { getExtensionPointPluginMeta } from 'app/features/plugins/extensions/utils'; import { ExtensionSidebarContextProvider, useExtensionSidebarContext } from './ExtensionSidebarProvider'; @@ -73,6 +73,11 @@ describe('ExtensionToolbarItem', () => { (store.set as jest.Mock).mockClear(); (store.delete as jest.Mock).mockClear(); jest.replaceProperty(config.featureToggles, 'extensionSidebar', true); + setAppEvents(new EventBusSrv()); + }); + + afterEach(() => { + jest.clearAllMocks(); }); it('should not render when feature toggle is disabled', () => { diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index 4ba153fe94d..dbf88a26156 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -20,7 +20,7 @@ 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 { ShowModalReactEvent } from 'app/types/events'; +import { OpenExtensionSidebarEvent, ShowModalReactEvent } from 'app/types/events'; import { ExtensionsLog, log } from './logs/log'; import { AddedLinkRegistryItem } from './registry/AddedLinksRegistry'; @@ -375,6 +375,15 @@ export function getLinkExtensionOnClick( const helpers: PluginExtensionEventHelpers = { context, openModal: createOpenModalFunction(pluginId), + openSidebar: (componentTitle, context) => { + appEvents.publish( + new OpenExtensionSidebarEvent({ + 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 eb45e260f43..4ec85c07e64 100644 --- a/public/app/types/events.ts +++ b/public/app/types/events.ts @@ -28,6 +28,12 @@ export interface ShowModalReactPayload { props?: any; } +export interface OpenExtensionSidebarPayload { + props?: Record; + pluginId: string; + componentTitle: string; +} + export interface ShowConfirmModalPayload { title?: string; text?: string; @@ -184,6 +190,10 @@ export class ShowModalReactEvent extends BusEventWithPayload { + static type = 'open-extension-sidebar'; +} + /** * @deprecated use ShowModalReactEvent instead that has this capability built in */