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
pull/102976/head
Sven Grossmann 3 months ago committed by GitHub
parent 1d4d7cec41
commit 722cd25da1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 7
      packages/grafana-data/src/types/pluginExtensions.ts
  2. 12
      public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebar.tsx
  3. 135
      public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx
  4. 44
      public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx
  5. 9
      public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx
  6. 11
      public/app/features/plugins/extensions/utils.tsx
  7. 10
      public/app/types/events.ts

@ -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>
);

@ -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
*/

Loading…
Cancel
Save