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>;
|
context?: Readonly<Context>;
|
||||||
// Opens a modal dialog and renders the provided React component inside it
|
// Opens a modal dialog and renders the provided React component inside it
|
||||||
openModal: (options: PluginExtensionOpenModalOptions) => void;
|
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
|
// Extension Points & Contexts
|
||||||
|
|||||||
@@ -14,10 +14,16 @@ export const DEFAULT_EXTENSION_SIDEBAR_WIDTH = 300;
|
|||||||
export const MIN_EXTENSION_SIDEBAR_WIDTH = 100;
|
export const MIN_EXTENSION_SIDEBAR_WIDTH = 100;
|
||||||
export const MAX_EXTENSION_SIDEBAR_WIDTH = 700;
|
export const MAX_EXTENSION_SIDEBAR_WIDTH = 700;
|
||||||
|
|
||||||
|
type ExtensionSidebarComponentProps = {
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
export function ExtensionSidebar() {
|
export function ExtensionSidebar() {
|
||||||
const styles = getStyles(useTheme2());
|
const styles = getStyles(useTheme2());
|
||||||
const { dockedComponentId, isEnabled } = useExtensionSidebarContext();
|
const { dockedComponentId, isEnabled, props = {} } = useExtensionSidebarContext();
|
||||||
const { components, isLoading } = usePluginComponents({ extensionPointId: EXTENSION_SIDEBAR_EXTENSION_POINT_ID });
|
const { components, isLoading } = usePluginComponents<ExtensionSidebarComponentProps>({
|
||||||
|
extensionPointId: EXTENSION_SIDEBAR_EXTENSION_POINT_ID,
|
||||||
|
});
|
||||||
|
|
||||||
if (isLoading || !dockedComponentId || !isEnabled) {
|
if (isLoading || !dockedComponentId || !isEnabled) {
|
||||||
return null;
|
return null;
|
||||||
@@ -39,7 +45,7 @@ export function ExtensionSidebar() {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.sidebarWrapper}>
|
<div className={styles.sidebarWrapper}>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<ExtensionComponent />
|
<ExtensionComponent {...props} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+132
-3
@@ -1,9 +1,9 @@
|
|||||||
import { render, screen, act } from '@testing-library/react';
|
import { render, screen, act } from '@testing-library/react';
|
||||||
// import { render } from 'test/test-utils';
|
|
||||||
|
|
||||||
import { store } from '@grafana/data';
|
import { store, EventBusSrv, EventBus } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
import { config, getAppEvents, setAppEvents } from '@grafana/runtime';
|
||||||
import { getExtensionPointPluginMeta } from 'app/features/plugins/extensions/utils';
|
import { getExtensionPointPluginMeta } from 'app/features/plugins/extensions/utils';
|
||||||
|
import { OpenExtensionSidebarEvent } from 'app/types/events';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ExtensionSidebarContextProvider,
|
ExtensionSidebarContextProvider,
|
||||||
@@ -52,10 +52,31 @@ const mockPluginMeta = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('ExtensionSidebarProvider', () => {
|
describe('ExtensionSidebarProvider', () => {
|
||||||
|
let subscribeSpy: jest.SpyInstance;
|
||||||
|
let originalAppEvents: EventBus;
|
||||||
|
let mockEventBus: EventBusSrv;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
originalAppEvents = getAppEvents();
|
||||||
|
|
||||||
|
mockEventBus = new EventBusSrv();
|
||||||
|
subscribeSpy = jest.spyOn(mockEventBus, 'subscribe');
|
||||||
|
|
||||||
|
setAppEvents(mockEventBus);
|
||||||
|
|
||||||
(getExtensionPointPluginMeta as jest.Mock).mockReturnValue(new Map([[mockPluginMeta.pluginId, mockPluginMeta]]));
|
(getExtensionPointPluginMeta as jest.Mock).mockReturnValue(new Map([[mockPluginMeta.pluginId, mockPluginMeta]]));
|
||||||
|
|
||||||
jest.replaceProperty(config.featureToggles, 'extensionSidebar', true);
|
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 = () => {
|
const TestComponent = () => {
|
||||||
@@ -199,6 +220,114 @@ describe('ExtensionSidebarProvider', () => {
|
|||||||
expect(screen.getByTestId('available-components-size')).toHaveTextContent('1');
|
expect(screen.getByTestId('available-components-size')).toHaveTextContent('1');
|
||||||
expect(screen.getByTestId('plugin-ids')).toHaveTextContent(permittedPluginMeta.pluginId);
|
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', () => {
|
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 { useLocalStorage } from 'react-use';
|
||||||
|
|
||||||
import { store, type ExtensionInfo } from '@grafana/data';
|
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 { ExtensionPointPluginMeta, getExtensionPointPluginMeta } from 'app/features/plugins/extensions/utils';
|
||||||
|
import { OpenExtensionSidebarEvent } from 'app/types/events';
|
||||||
|
|
||||||
import { DEFAULT_EXTENSION_SIDEBAR_WIDTH } from './ExtensionSidebar';
|
import { DEFAULT_EXTENSION_SIDEBAR_WIDTH } from './ExtensionSidebar';
|
||||||
|
|
||||||
@@ -45,6 +46,8 @@ type ExtensionSidebarContextType = {
|
|||||||
* Set the width of the extension sidebar.
|
* Set the width of the extension sidebar.
|
||||||
*/
|
*/
|
||||||
setExtensionSidebarWidth: (width: number) => void;
|
setExtensionSidebarWidth: (width: number) => void;
|
||||||
|
|
||||||
|
props?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ExtensionSidebarContext = createContext<ExtensionSidebarContextType>({
|
export const ExtensionSidebarContext = createContext<ExtensionSidebarContextType>({
|
||||||
@@ -66,6 +69,7 @@ interface ExtensionSidebarContextProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ExtensionSidebarContextProvider = ({ children }: 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 storedDockedPluginId = store.get(EXTENSION_SIDEBAR_DOCKED_LOCAL_STORAGE_KEY);
|
||||||
const [extensionSidebarWidth, setExtensionSidebarWidth] = useLocalStorage(
|
const [extensionSidebarWidth, setExtensionSidebarWidth] = useLocalStorage(
|
||||||
EXTENSION_SIDEBAR_WIDTH_LOCAL_STORAGE_KEY,
|
EXTENSION_SIDEBAR_WIDTH_LOCAL_STORAGE_KEY,
|
||||||
@@ -104,6 +108,39 @@ export const ExtensionSidebarContextProvider = ({ children }: ExtensionSidebarCo
|
|||||||
}
|
}
|
||||||
const [dockedComponentId, setDockedComponentId] = useState<string | undefined>(defaultDockedComponentId);
|
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
|
// update the stored docked component id when it changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dockedComponentId) {
|
if (dockedComponentId) {
|
||||||
@@ -119,10 +156,11 @@ export const ExtensionSidebarContextProvider = ({ children }: ExtensionSidebarCo
|
|||||||
isEnabled,
|
isEnabled,
|
||||||
isOpen: isEnabled && dockedComponentId !== undefined,
|
isOpen: isEnabled && dockedComponentId !== undefined,
|
||||||
dockedComponentId,
|
dockedComponentId,
|
||||||
setDockedComponentId,
|
setDockedComponentId: (componentId) => setDockedComponentWithProps(componentId, undefined),
|
||||||
availableComponents,
|
availableComponents,
|
||||||
extensionSidebarWidth: extensionSidebarWidth ?? DEFAULT_EXTENSION_SIDEBAR_WIDTH,
|
extensionSidebarWidth: extensionSidebarWidth ?? DEFAULT_EXTENSION_SIDEBAR_WIDTH,
|
||||||
setExtensionSidebarWidth,
|
setExtensionSidebarWidth,
|
||||||
|
props,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
import { store } from '@grafana/data';
|
import { EventBusSrv, store } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
import { config, setAppEvents } from '@grafana/runtime';
|
||||||
import { getExtensionPointPluginMeta } from 'app/features/plugins/extensions/utils';
|
import { getExtensionPointPluginMeta } from 'app/features/plugins/extensions/utils';
|
||||||
|
|
||||||
import { ExtensionSidebarContextProvider, useExtensionSidebarContext } from './ExtensionSidebarProvider';
|
import { ExtensionSidebarContextProvider, useExtensionSidebarContext } from './ExtensionSidebarProvider';
|
||||||
@@ -73,6 +73,11 @@ describe('ExtensionToolbarItem', () => {
|
|||||||
(store.set as jest.Mock).mockClear();
|
(store.set as jest.Mock).mockClear();
|
||||||
(store.delete as jest.Mock).mockClear();
|
(store.delete as jest.Mock).mockClear();
|
||||||
jest.replaceProperty(config.featureToggles, 'extensionSidebar', true);
|
jest.replaceProperty(config.featureToggles, 'extensionSidebar', true);
|
||||||
|
setAppEvents(new EventBusSrv());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not render when feature toggle is disabled', () => {
|
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 { Modal } from '@grafana/ui';
|
||||||
import appEvents from 'app/core/app_events';
|
import appEvents from 'app/core/app_events';
|
||||||
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
|
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 { ExtensionsLog, log } from './logs/log';
|
||||||
import { AddedLinkRegistryItem } from './registry/AddedLinksRegistry';
|
import { AddedLinkRegistryItem } from './registry/AddedLinksRegistry';
|
||||||
@@ -375,6 +375,15 @@ export function getLinkExtensionOnClick(
|
|||||||
const helpers: PluginExtensionEventHelpers = {
|
const helpers: PluginExtensionEventHelpers = {
|
||||||
context,
|
context,
|
||||||
openModal: createOpenModalFunction(pluginId),
|
openModal: createOpenModalFunction(pluginId),
|
||||||
|
openSidebar: (componentTitle, context) => {
|
||||||
|
appEvents.publish(
|
||||||
|
new OpenExtensionSidebarEvent({
|
||||||
|
props: context,
|
||||||
|
pluginId,
|
||||||
|
componentTitle,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
log.debug(`onClick '${config.title}' at '${extensionPointId}'`);
|
log.debug(`onClick '${config.title}' at '${extensionPointId}'`);
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ export interface ShowModalReactPayload {
|
|||||||
props?: any;
|
props?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OpenExtensionSidebarPayload {
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
pluginId: string;
|
||||||
|
componentTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ShowConfirmModalPayload {
|
export interface ShowConfirmModalPayload {
|
||||||
title?: string;
|
title?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
@@ -184,6 +190,10 @@ export class ShowModalReactEvent extends BusEventWithPayload<ShowModalReactPaylo
|
|||||||
static type = 'show-react-modal';
|
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
|
* @deprecated use ShowModalReactEvent instead that has this capability built in
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user