From 862118b30b351ff90aa574ddccd147da01244d3c Mon Sep 17 00:00:00 2001 From: ivanahuckova Date: Mon, 13 Oct 2025 15:06:28 +0200 Subject: [PATCH] Introduce ToggleExtensionSidebar --- .../src/types/pluginExtensions.ts | 9 + .../ExtensionSidebarProvider.test.tsx | 162 +++++++++++++++++- .../ExtensionSidebarProvider.tsx | 19 +- public/app/core/services/keybindingSrv.ts | 30 +++- .../app/features/plugins/extensions/utils.tsx | 16 +- public/app/types/events.ts | 10 ++ 6 files changed, 238 insertions(+), 8 deletions(-) diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 4707c9123dd..544855d0fc9 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 7839207632b..66482a0edd2 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx @@ -3,7 +3,7 @@ import { render, screen, act } from '@testing-library/react'; import { store, EventBusSrv, EventBus } 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, @@ -334,8 +334,161 @@ 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 open when receiving ToggleExtensionSidebarEvent and sidebar is closed', () => { + 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'); + + act(() => { + // Find the ToggleExtensionSidebarEvent subscriber + const toggleEventSubscriberCall = subscribeSpy.mock.calls.find((call) => call[0] === ToggleExtensionSidebarEvent); + expect(toggleEventSubscriberCall).toBeDefined(); + const [, subscriberFn] = toggleEventSubscriberCall!; + + // Call the toggle event handler + subscriberFn( + new ToggleExtensionSidebarEvent({ + 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 toggle sidebar closed when receiving ToggleExtensionSidebarEvent for currently open component', () => { + const componentId = getComponentIdFromComponentMeta(mockPluginMeta.pluginId, mockComponent); + + const TestComponentWithProps = () => { + const context = useExtensionSidebarContext(); + return ( +
+
{context.isOpen.toString()}
+
{context.dockedComponentId || 'undefined'}
+ +
+ ); + }; + + render( + + + + ); + + // First open the sidebar manually + act(() => { + screen.getByText('Open Sidebar').click(); + }); + + expect(screen.getByTestId('is-open')).toHaveTextContent('true'); + expect(screen.getByTestId('docked-component-id')).toHaveTextContent(componentId); + + act(() => { + // Find the ToggleExtensionSidebarEvent subscriber + const toggleEventSubscriberCall = subscribeSpy.mock.calls.find((call) => call[0] === ToggleExtensionSidebarEvent); + expect(toggleEventSubscriberCall).toBeDefined(); + const [, subscriberFn] = toggleEventSubscriberCall!; + + // Call the toggle event handler with the same component + 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); + + const TestComponentWithProps = () => { + const context = useExtensionSidebarContext(); + return ( +
+
{context.isOpen.toString()}
+
{context.dockedComponentId || 'undefined'}
+ +
+ ); + }; + + render( + + + + ); + + // First open the sidebar manually + act(() => { + screen.getByText('Open Sidebar').click(); + }); + + expect(screen.getByTestId('is-open')).toHaveTextContent('true'); + expect(screen.getByTestId('docked-component-id')).toHaveTextContent(componentId); + + act(() => { + // Find the ToggleExtensionSidebarEvent subscriber + const toggleEventSubscriberCall = subscribeSpy.mock.calls.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 +503,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 8b90bd24914..07d4bbe4cd5 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, type ExtensionInfo } 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'; @@ -171,13 +171,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(new OpenExtensionSidebarEvent(event.payload)); + } + }; + 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/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index 37f70eb6b9a..ba3a7140491 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -1,3 +1,4 @@ +import { toggleAssistant, isAssistantAvailable } from '@grafana/assistant'; import { LegacyGraphHoverClearEvent, SetPanelAttentionEvent, locationUtil } from '@grafana/data'; import { LocationService } from '@grafana/runtime'; import appEvents from 'app/core/app_events'; @@ -38,7 +39,8 @@ export class KeybindingSrv { } /** string for VizPanel key and number for panelId */ private panelId: string | number | null = null; - + private assistantSubscription: { unsubscribe: () => void } | null = null; + clearAndInitGlobalBindings(route: RouteDescriptor) { mousetrap.reset(); @@ -51,6 +53,8 @@ export class KeybindingSrv { this.bind('g e', this.goToExplore); this.bind('g a', this.openAlerting); this.bind('g p', this.goToProfile); + // Conditionally bind open Assistant shortcut ('cmd .') if Assistant is available + this.bindAssistantShortcutIfAvailable(); this.bind('esc', this.exit); this.bindGlobalEsc(); } @@ -120,6 +124,30 @@ export class KeybindingSrv { appEvents.publish(new ShowModalReactEvent({ component: HelpModal })); } + private bindAssistantShortcutIfAvailable() { + // Clean up any existing subscription + if (this.assistantSubscription) { + this.assistantSubscription.unsubscribe(); + } + // Subscribe to assistant availability and bind/unbind shortcut accordingly + this.assistantSubscription = isAssistantAvailable().subscribe((available) => { + if (available) { + this.bind('mod+.', this.toggleAssistant); + } else { + // Unbind the shortcut if assistant becomes unavailable + mousetrap.unbind('mod+.'); + } + }); + } + + private toggleAssistant() { + toggleAssistant({ + origin: 'grafana/keyboard-shortcut', + prompt: '', + context: [], + }); + } + private exit() { const search = this.locationService.getSearchObject(); diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index 5077da479d3..d317c973e14 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'; @@ -551,6 +556,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 */