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<string, unknown>` as type * Extension Sidebar: Use `Record<string, unknown>` as type * Extension Sidebar: Add tests for event based opening * Extension Sidebar: Lint * Extension Sidebar: Fix toolbar button tests
This commit is contained in:
@@ -167,6 +167,13 @@ export type PluginExtensionEventHelpers<Context extends object = object> = {
|
||||
context?: Readonly<Context>;
|
||||
// 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<string, unknown>) => void;
|
||||
};
|
||||
|
||||
// Extension Points & Contexts
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
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<ExtensionSidebarComponentProps>({
|
||||
extensionPointId: EXTENSION_SIDEBAR_EXTENSION_POINT_ID,
|
||||
});
|
||||
|
||||
if (isLoading || !dockedComponentId || !isEnabled) {
|
||||
return null;
|
||||
@@ -39,7 +45,7 @@ export function ExtensionSidebar() {
|
||||
return (
|
||||
<div className={styles.sidebarWrapper}>
|
||||
<div className={styles.content}>
|
||||
<ExtensionComponent />
|
||||
<ExtensionComponent {...props} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+132
-3
@@ -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(
|
||||
<ExtensionSidebarContextProvider>
|
||||
<TestComponent />
|
||||
</ExtensionSidebarContextProvider>
|
||||
);
|
||||
|
||||
expect(subscribeSpy).toHaveBeenCalledWith(OpenExtensionSidebarEvent, expect.any(Function));
|
||||
});
|
||||
|
||||
it('should not subscribe to OpenExtensionSidebarEvent when feature is disabled', () => {
|
||||
jest.replaceProperty(config.featureToggles, 'extensionSidebar', false);
|
||||
|
||||
render(
|
||||
<ExtensionSidebarContextProvider>
|
||||
<TestComponent />
|
||||
</ExtensionSidebarContextProvider>
|
||||
);
|
||||
|
||||
expect(subscribeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set dockedComponentId and props when receiving a valid OpenExtensionSidebarEvent', () => {
|
||||
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');
|
||||
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(
|
||||
<ExtensionSidebarContextProvider>
|
||||
<TestComponent />
|
||||
</ExtensionSidebarContextProvider>
|
||||
);
|
||||
|
||||
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(
|
||||
<ExtensionSidebarContextProvider>
|
||||
<TestComponent />
|
||||
</ExtensionSidebarContextProvider>
|
||||
);
|
||||
|
||||
unmount();
|
||||
expect(unsubscribeMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Utility Functions', () => {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
export const ExtensionSidebarContext = createContext<ExtensionSidebarContextType>({
|
||||
@@ -66,6 +69,7 @@ interface ExtensionSidebarContextProps {
|
||||
}
|
||||
|
||||
export const ExtensionSidebarContextProvider = ({ children }: ExtensionSidebarContextProps) => {
|
||||
const [props, setProps] = useState<Record<string, unknown> | 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<string | undefined>(defaultDockedComponentId);
|
||||
|
||||
const setDockedComponentWithProps = useCallback(
|
||||
(componentId: string | undefined, props?: Record<string, unknown>) => {
|
||||
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}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}'`);
|
||||
|
||||
@@ -28,6 +28,12 @@ export interface ShowModalReactPayload {
|
||||
props?: any;
|
||||
}
|
||||
|
||||
export interface OpenExtensionSidebarPayload {
|
||||
props?: Record<string, unknown>;
|
||||
pluginId: string;
|
||||
componentTitle: string;
|
||||
}
|
||||
|
||||
export interface ShowConfirmModalPayload {
|
||||
title?: string;
|
||||
text?: string;
|
||||
@@ -184,6 +190,10 @@ export class ShowModalReactEvent extends BusEventWithPayload<ShowModalReactPaylo
|
||||
static type = 'show-react-modal';
|
||||
}
|
||||
|
||||
export class OpenExtensionSidebarEvent extends BusEventWithPayload<OpenExtensionSidebarPayload> {
|
||||
static type = 'open-extension-sidebar';
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use ShowModalReactEvent instead that has this capability built in
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user