Introduce ToggleExtensionSidebar

This commit is contained in:
ivanahuckova
2025-10-13 15:06:28 +02:00
parent c85440dbff
commit 862118b30b
6 changed files with 238 additions and 8 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
@@ -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(
<ExtensionSidebarContextProvider>
<TestComponent />
</ExtensionSidebarContextProvider>
);
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 (
<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>
);
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 (
<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>
);
// 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 (
<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>
);
// 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', () => {
@@ -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(() => {
+29 -1
View File
@@ -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();
@@ -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}'`);
+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
*/