Extension Sidebar: Enable conditional rendering of component (#104177)

* Extension Sidebar: Add `links` extension point to conditional render component

* Extension Sidebar: Add tests

* Extension Sidebar: Fix tests
This commit is contained in:
Sven Grossmann
2025-04-23 16:25:50 +02:00
committed by GitHub
parent ab7e18feda
commit c20cd9874c
3 changed files with 167 additions and 18 deletions

View File

@ -1,7 +1,7 @@
import { render, screen, act } from '@testing-library/react';
import { store, EventBusSrv, EventBus } from '@grafana/data';
import { config, getAppEvents, setAppEvents } from '@grafana/runtime';
import { config, getAppEvents, setAppEvents, locationService } from '@grafana/runtime';
import { getExtensionPointPluginMeta } from 'app/features/plugins/extensions/utils';
import { OpenExtensionSidebarEvent } from 'app/types/events';
@ -13,6 +13,18 @@ import {
EXTENSION_SIDEBAR_DOCKED_LOCAL_STORAGE_KEY,
} from './ExtensionSidebarProvider';
const mockComponent = {
title: 'Test Component',
description: 'Test Description',
targets: [],
};
const mockPluginMeta = {
pluginId: 'grafana-investigations-app',
addedComponents: [mockComponent],
addedLinks: [],
};
// Mock the store
jest.mock('@grafana/data', () => ({
...jest.requireActual('@grafana/data'),
@ -38,23 +50,26 @@ jest.mock('@grafana/runtime', () => ({
extensionSidebar: true,
},
},
locationService: {
getLocation: jest.fn().mockReturnValue({ pathname: '/test-path' }),
getLocationObservable: jest.fn(),
},
usePluginLinks: jest.fn().mockImplementation(() => ({
links: [
{
pluginId: mockPluginMeta.pluginId,
title: mockComponent.title,
},
],
})),
}));
const mockComponent = {
title: 'Test Component',
description: 'Test Description',
targets: [],
};
const mockPluginMeta = {
pluginId: 'grafana-investigations-app',
addedComponents: [mockComponent],
};
describe('ExtensionSidebarProvider', () => {
let subscribeSpy: jest.SpyInstance;
let originalAppEvents: EventBus;
let mockEventBus: EventBusSrv;
let locationObservableMock: { callback: jest.Mock | null; subscribe: jest.Mock };
const getExtensionPointPluginMetaMock = jest.mocked(getExtensionPointPluginMeta);
beforeEach(() => {
jest.clearAllMocks();
@ -66,10 +81,21 @@ describe('ExtensionSidebarProvider', () => {
setAppEvents(mockEventBus);
(getExtensionPointPluginMeta as jest.Mock).mockReturnValue(new Map([[mockPluginMeta.pluginId, mockPluginMeta]]));
getExtensionPointPluginMetaMock.mockReturnValue(new Map([[mockPluginMeta.pluginId, mockPluginMeta]]));
jest.replaceProperty(config.featureToggles, 'extensionSidebar', true);
locationObservableMock = {
subscribe: jest.fn((callback) => {
locationObservableMock.callback = callback;
return {
unsubscribe: jest.fn(),
};
}),
callback: null,
};
(locationService.getLocationObservable as jest.Mock).mockReturnValue(locationObservableMock);
(store.get as jest.Mock).mockReturnValue(undefined);
(store.set as jest.Mock).mockImplementation(() => {});
(store.delete as jest.Mock).mockImplementation(() => {});
@ -196,14 +222,16 @@ describe('ExtensionSidebarProvider', () => {
const permittedPluginMeta = {
pluginId: 'grafana-investigations-app',
addedComponents: [mockComponent],
addedLinks: [],
};
const prohibitedPluginMeta = {
pluginId: 'disabled-plugin',
addedComponents: [mockComponent],
addedLinks: [],
};
(getExtensionPointPluginMeta as jest.Mock).mockReturnValue(
getExtensionPointPluginMetaMock.mockReturnValue(
new Map([
[permittedPluginMeta.pluginId, permittedPluginMeta],
[prohibitedPluginMeta.pluginId, prohibitedPluginMeta],
@ -328,6 +356,80 @@ describe('ExtensionSidebarProvider', () => {
unmount();
expect(unsubscribeMock).toHaveBeenCalled();
});
it('should subscribe to location service observable', () => {
render(
<ExtensionSidebarContextProvider>
<TestComponent />
</ExtensionSidebarContextProvider>
);
expect(locationService.getLocationObservable).toHaveBeenCalled();
expect(locationObservableMock.subscribe).toHaveBeenCalled();
});
it('should update current path when location changes', () => {
const usePluginLinksMock = jest.fn().mockReturnValue({ links: [] });
jest.requireMock('@grafana/runtime').usePluginLinks = usePluginLinksMock;
render(
<ExtensionSidebarContextProvider>
<TestComponent />
</ExtensionSidebarContextProvider>
);
expect(usePluginLinksMock).toHaveBeenCalledWith(
expect.objectContaining({
context: expect.objectContaining({
path: '/test-path',
}),
})
);
act(() => {
locationObservableMock.callback?.({ pathname: '/new-path' });
});
expect(usePluginLinksMock).toHaveBeenCalledWith(
expect.objectContaining({
context: expect.objectContaining({
path: '/new-path',
}),
})
);
});
it('should unsubscribe from location service on unmount', () => {
const unsubscribeMock = jest.fn();
locationObservableMock.subscribe.mockReturnValue({
unsubscribe: unsubscribeMock,
});
const { unmount } = render(
<ExtensionSidebarContextProvider>
<TestComponent />
</ExtensionSidebarContextProvider>
);
unmount();
expect(unsubscribeMock).toHaveBeenCalled();
});
it('should not include plugins in available components when no links are returned', () => {
jest.requireMock('@grafana/runtime').usePluginLinks.mockImplementation(() => ({
links: [],
}));
getExtensionPointPluginMetaMock.mockReturnValue(new Map([[mockPluginMeta.pluginId, mockPluginMeta]]));
render(
<ExtensionSidebarContextProvider>
<TestComponent />
</ExtensionSidebarContextProvider>
);
expect(screen.getByTestId('available-components-size')).toHaveTextContent('0');
});
});
describe('Utility Functions', () => {

View File

@ -2,7 +2,7 @@ import { createContext, ReactNode, useCallback, useContext, useEffect, useState
import { useLocalStorage } from 'react-use';
import { store, type ExtensionInfo } from '@grafana/data';
import { config, getAppEvents } from '@grafana/runtime';
import { config, getAppEvents, usePluginLinks, locationService } from '@grafana/runtime';
import { ExtensionPointPluginMeta, getExtensionPointPluginMeta } from 'app/features/plugins/extensions/utils';
import { OpenExtensionSidebarEvent } from 'app/types/events';
@ -75,13 +75,43 @@ export const ExtensionSidebarContextProvider = ({ children }: ExtensionSidebarCo
EXTENSION_SIDEBAR_WIDTH_LOCAL_STORAGE_KEY,
DEFAULT_EXTENSION_SIDEBAR_WIDTH
);
const [currentPath, setCurrentPath] = useState(locationService.getLocation().pathname);
useEffect(() => {
const subscription = locationService.getLocationObservable().subscribe((location) => {
setCurrentPath(location.pathname);
});
return () => {
subscription.unsubscribe();
};
}, []);
// these links are needed to conditionally render the extension component
// that means, a plugin would need to register both, a link and a component to
// `grafana/extension-sidebar/v0-alpha` and the link's `configure` method would control
// whether the component is rendered or not
const { links } = usePluginLinks({
extensionPointId: EXTENSION_SIDEBAR_EXTENSION_POINT_ID,
context: {
path: currentPath,
},
});
const isEnabled = !!config.featureToggles.extensionSidebar;
// get all components for this extension point, but only for the permitted plugins
// if the extension sidebar is not enabled, we will return an empty map
const availableComponents = isEnabled
? new Map(
Array.from(getExtensionPointPluginMeta(EXTENSION_SIDEBAR_EXTENSION_POINT_ID).entries()).filter(([pluginId]) =>
PERMITTED_EXTENSION_SIDEBAR_PLUGINS.includes(pluginId)
Array.from(getExtensionPointPluginMeta(EXTENSION_SIDEBAR_EXTENSION_POINT_ID).entries()).filter(
([pluginId, pluginMeta]) =>
PERMITTED_EXTENSION_SIDEBAR_PLUGINS.includes(pluginId) &&
links.some(
(link) =>
link.pluginId === pluginId &&
pluginMeta.addedComponents.some((component) => component.title === link.title)
)
)
)
: new Map<

View File

@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { EventBusSrv, store } from '@grafana/data';
import { config, setAppEvents } from '@grafana/runtime';
import { config, setAppEvents, usePluginLinks } from '@grafana/runtime';
import { getExtensionPointPluginMeta } from 'app/features/plugins/extensions/utils';
import { ExtensionSidebarContextProvider, useExtensionSidebarContext } from './ExtensionSidebarProvider';
@ -33,6 +33,15 @@ jest.mock('@grafana/runtime', () => ({
extensionSidebar: true,
},
},
usePluginLinks: jest.fn().mockImplementation(() => ({
links: [
{
pluginId: mockPluginMeta.pluginId,
title: mockComponent.title,
},
],
isLoading: false,
})),
}));
const mockComponent = {
@ -121,6 +130,14 @@ describe('ExtensionToolbarItem', () => {
],
};
(usePluginLinks as jest.Mock).mockReturnValue({
links: [
{ pluginId: multipleComponentsMeta.pluginId, title: multipleComponentsMeta.addedComponents[0].title },
{ pluginId: multipleComponentsMeta.pluginId, title: multipleComponentsMeta.addedComponents[1].title },
],
isLoading: false,
});
(getExtensionPointPluginMeta as jest.Mock).mockReturnValue(
new Map([[multipleComponentsMeta.pluginId, multipleComponentsMeta]])
);