diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx index 660aa81e014..b45be60bb2f 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx @@ -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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + expect(screen.getByTestId('available-components-size')).toHaveTextContent('0'); + }); }); describe('Utility Functions', () => { diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx index 8f64c6a6071..df23d0f2898 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx @@ -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< diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx index b1a6e35ed2c..2b468d9f4c2 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx @@ -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]]) );