diff --git a/packages/grafana-runtime/src/services/index.ts b/packages/grafana-runtime/src/services/index.ts index e9d2eeac852..42ef1c3f655 100644 --- a/packages/grafana-runtime/src/services/index.ts +++ b/packages/grafana-runtime/src/services/index.ts @@ -30,6 +30,7 @@ export { type UsePluginFunctionsOptions, type UsePluginFunctionsResult, } from './pluginExtensions/usePluginFunctions'; +export { setHelpNavItemHook, useHelpNavItem, type UseHelpNavItem } from './navigation/useHelpNavItem'; export { getObservablePluginLinks } from './pluginExtensions/getObservablePluginLinks'; export { getObservablePluginComponents } from './pluginExtensions/getObservablePluginComponents'; export { diff --git a/packages/grafana-runtime/src/services/navigation/useHelpNavItem.ts b/packages/grafana-runtime/src/services/navigation/useHelpNavItem.ts new file mode 100644 index 00000000000..cb39aee2c0e --- /dev/null +++ b/packages/grafana-runtime/src/services/navigation/useHelpNavItem.ts @@ -0,0 +1,20 @@ +import { NavModelItem } from '@grafana/data'; + +export type UseHelpNavItem = () => NavModelItem | undefined; + +let singleton: UseHelpNavItem | undefined; + +export function setHelpNavItemHook(hook: UseHelpNavItem): void { + // We allow overriding the registry in tests + if (singleton && process.env.NODE_ENV !== 'test') { + throw new Error('setHelpNavItemHook() function should only be called once, when Grafana is starting.'); + } + singleton = hook; +} + +export function useHelpNavItem(): NavModelItem | undefined { + if (!singleton) { + throw new Error('useHelpNavItem() can only be used after the Grafana instance has started.'); + } + return singleton(); +} diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 000ead39266..08853afa2bf 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -59,7 +59,8 @@ type NavigationAppConfig struct { func ProvideService(cfg *setting.Cfg, accessControl ac.AccessControl, pluginStore pluginstore.Store, pluginSettings pluginsettings.Service, starService star.Service, features featuremgmt.FeatureToggles, dashboardService dashboards.DashboardService, accesscontrolService ac.Service, kvStore kvstore.KVStore, apiKeyService apikey.Service, - license licensing.Licensing, authnService authn.Service) navtree.Service { + license licensing.Licensing, authnService authn.Service, +) navtree.Service { service := &ServiceImpl{ cfg: cfg, log: log.New("navtree service"), @@ -243,9 +244,10 @@ func isSupportBundlesEnabled(s *ServiceImpl) bool { return s.cfg.SectionWithEnvOverrides("support_bundles").Key("enabled").MustBool(true) } +// addHelpLinks adds a help menu item to the navigation bar. +// If the Grafana Pathfinder plugin is installed, it will handle enriching the help menu. func (s *ServiceImpl) addHelpLinks(treeRoot *navtree.NavTreeRoot, c *contextmodel.ReqContext) { if s.cfg.HelpEnabled { - // The version subtitle is set later by NavTree.ApplyHelpVersion helpNode := &navtree.NavLink{ Text: "Help", Id: "help", @@ -257,6 +259,16 @@ func (s *ServiceImpl) addHelpLinks(treeRoot *navtree.NavTreeRoot, c *contextmode treeRoot.AddSection(helpNode) + ctx := c.Req.Context() + // The docs plugin ID is going to transition from grafana-grafanadocsplugin-app to grafana-pathfinder-app. + // Support both until that migration is complete. + _, oldPathfinderInstalled := s.pluginStore.Plugin(ctx, "grafana-grafanadocsplugin-app") + _, newPathfinderInstalled := s.pluginStore.Plugin(ctx, "grafana-pathfinder-app") + if oldPathfinderInstalled || newPathfinderInstalled { + // Add a custom property to indicate this should open Grafana Pathfinder. + helpNode.HideFromTabs = true + } + hasAccess := ac.HasAccess(s.accessControl, c) supportBundleAccess := ac.EvalAny( ac.EvalPermission(supportbundlesimpl.ActionRead), diff --git a/public/app/app.ts b/public/app/app.ts index 7896117c1e7..48b97f40024 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -37,6 +37,7 @@ import { setCurrentUser, setChromeHeaderHeightHook, setPluginLinksHook, + setHelpNavItemHook, setFolderPicker, setCorrelationsService, setPluginFunctionsHook, @@ -60,6 +61,7 @@ import { AppWrapper } from './AppWrapper'; import appEvents from './core/app_events'; import { AppChromeService } from './core/components/AppChrome/AppChromeService'; import { useChromeHeaderHeight } from './core/components/AppChrome/TopBar/useChromeHeaderHeight'; +import { useHelpNode } from './core/components/AppChrome/TopBar/useHelpNode'; import { LazyFolderPicker } from './core/components/NestedFolderPicker/LazyFolderPicker'; import { getAllOptionEditors, getAllStandardFieldConfigs } from './core/components/OptionsUI/registry'; import { PluginPage } from './core/components/Page/PluginPage'; @@ -260,6 +262,7 @@ export class GrafanaApp { await preloadPlugins(appPluginsToAwait); } + setHelpNavItemHook(useHelpNode); setPluginLinksHook(usePluginLinks); setPluginComponentHook(usePluginComponent); setPluginComponentsHook(usePluginComponents); diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebar.test.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebar.test.tsx index 3891dac8e50..e08a37fda26 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebar.test.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebar.test.tsx @@ -43,7 +43,7 @@ const addedComponentConfigMock: ExtensionInfo = { }; const extensionSidebarContextMock: ExtensionSidebarContextType = { - dockedComponentId: getComponentIdFromComponentMeta(pluginId, addedComponentConfigMock), + dockedComponentId: getComponentIdFromComponentMeta(pluginId, addedComponentConfigMock.title), props: {}, isOpen: true, setDockedComponentId: jest.fn(), diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx index 7839207632b..955c85923b6 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx @@ -121,7 +121,7 @@ describe('ExtensionSidebarProvider', () => { }); it('should load docked component from storage if available', () => { - const componentId = getComponentIdFromComponentMeta(mockPluginMeta.pluginId, mockComponent); + const componentId = getComponentIdFromComponentMeta(mockPluginMeta.pluginId, mockComponent.title); (store.get as jest.Mock).mockReturnValue(componentId); render( @@ -135,7 +135,7 @@ describe('ExtensionSidebarProvider', () => { }); it('should update storage when docked component changes', () => { - const componentId = getComponentIdFromComponentMeta(mockPluginMeta.pluginId, mockComponent); + const componentId = getComponentIdFromComponentMeta(mockPluginMeta.pluginId, mockComponent.title); const TestComponentWithActions = () => { const context = useExtensionSidebarContext(); @@ -291,7 +291,7 @@ describe('ExtensionSidebarProvider', () => { }); it('should close sidebar when receiving a CloseExtensionSidebarEvent', () => { - const componentId = getComponentIdFromComponentMeta(mockPluginMeta.pluginId, mockComponent); + const componentId = getComponentIdFromComponentMeta(mockPluginMeta.pluginId, mockComponent.title); const TestComponentWithProps = () => { const context = useExtensionSidebarContext(); @@ -433,7 +433,7 @@ describe('ExtensionSidebarProvider', () => { describe('Utility Functions', () => { describe('getComponentIdFromComponentMeta', () => { it('should create a valid component ID', () => { - const componentId = getComponentIdFromComponentMeta(mockPluginMeta.pluginId, mockComponent); + const componentId = getComponentIdFromComponentMeta(mockPluginMeta.pluginId, mockComponent.title); expect(componentId).toBe( JSON.stringify({ pluginId: mockPluginMeta.pluginId, componentTitle: mockComponent.title }) @@ -443,7 +443,7 @@ describe('Utility Functions', () => { describe('getComponentMetaFromComponentId', () => { it('should parse a valid component ID', () => { - const componentId = getComponentIdFromComponentMeta(mockPluginMeta.pluginId, mockComponent); + const componentId = getComponentIdFromComponentMeta(mockPluginMeta.pluginId, mockComponent.title); const meta = getComponentMetaFromComponentId(componentId); expect(meta).toEqual({ diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx index ca5ff926169..9dcb1cf1edb 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx @@ -1,7 +1,7 @@ import { createContext, ReactNode, useCallback, useContext, useEffect, useState, useMemo } from 'react'; import { useLocalStorage } from 'react-use'; -import { PluginExtensionPoints, store, type ExtensionInfo } from '@grafana/data'; +import { PluginExtensionPoints, store } from '@grafana/data'; import { getAppEvents, reportInteraction, usePluginLinks, locationService } from '@grafana/runtime'; import { ExtensionPointPluginMeta, getExtensionPointPluginMeta } from 'app/features/plugins/extensions/utils'; import { CloseExtensionSidebarEvent, OpenExtensionSidebarEvent } from 'app/types/events'; @@ -228,8 +228,8 @@ export const ExtensionSidebarContextProvider = ({ children }: ExtensionSidebarCo ); }; -export function getComponentIdFromComponentMeta(pluginId: string, component: ExtensionInfo) { - return JSON.stringify({ pluginId, componentTitle: component.title }); +export function getComponentIdFromComponentMeta(pluginId: string, componentTitle: string) { + return JSON.stringify({ pluginId, componentTitle }); } export function getComponentMetaFromComponentId( @@ -252,3 +252,18 @@ export function getComponentMetaFromComponentId( return undefined; } } + +// The docs plugin ID is going to transition from grafana-grafanadocsplugin-app to grafana-pathfinder-app. +// Support both until that migration is complete. +// Prioritize the new plugin ID (grafana-pathfinder-app). +export function getPathfinderPluginId(availableComponents: ExtensionPointPluginMeta): string | undefined { + if (availableComponents.has('grafana-pathfinder-app')) { + return 'grafana-pathfinder-app'; + } + + if (availableComponents.has('grafana-grafanadocsplugin-app')) { + return 'grafana-grafanadocsplugin-app'; + } + + return undefined; +} diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.tsx index 3839ebcfcaa..cee522a2e85 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.tsx @@ -17,20 +17,32 @@ type Props = { }; const compactAllowedComponents = ['grafana-assistant-app']; +const pathfinderPluginIds = ['grafana-pathfinder-app', 'grafana-grafanadocsplugin-app']; export function ExtensionToolbarItem({ compact }: Props) { const { availableComponents, dockedComponentId, setDockedComponentId } = useExtensionSidebarContext(); - if (availableComponents.size === 0) { + // Don't render the toolbar if the only available plugins are Grafana Pathfinder. + // It's opened by the help menu. + const nonPathfinderPlugins = Array.from(availableComponents.keys()).filter( + (pluginId) => !pathfinderPluginIds.includes(pluginId) + ); + if (nonPathfinderPlugins.length === 0) { return null; } const dockedMeta = dockedComponentId ? getComponentMetaFromComponentId(dockedComponentId) : null; const renderPluginButton = (pluginId: string, components: ComponentWithPluginId[]) => { + // Don't render the Grafana Pathfinder button. + // It's opened by the help menu button. + if (pathfinderPluginIds.includes(pluginId)) { + return null; + } + if (components.length === 1) { const component = components[0]; - const componentId = getComponentIdFromComponentMeta(pluginId, component); + const componentId = getComponentIdFromComponentMeta(pluginId, component.title); const isActive = dockedComponentId === componentId; // we now allow more components in the extension sidebar @@ -54,7 +66,7 @@ export function ExtensionToolbarItem({ compact }: Props) { const MenuItems = (