From 09a3498552bbf8b2997c737f19fbc1082cd16228 Mon Sep 17 00:00:00 2001 From: Jack Baldry Date: Fri, 10 Oct 2025 13:23:23 +0100 Subject: [PATCH] Open Grafana Pathfinder instead of the help menu if its installed (#110592) * Link to Grafana Pathfinder if available Signed-off-by: Jack Baldry * refactor: `getComponentIdFromComponentMeta()` only receives the title * Making sure we pass helpNode without Parents to the pathfinder app. * minor refactoring to isolate the code. * Fix tests Signed-off-by: Jack Baldry * cleaned up the structure and exposing the helpNavItem via a hook * added missing files. * Add support for old and new pathfinder IDs Signed-off-by: Jack Baldry * Rename hook for consistency Signed-off-by: Jack Baldry --------- Signed-off-by: Jack Baldry Co-authored-by: Levente Balogh Co-authored-by: Marcus Andersson --- .../grafana-runtime/src/services/index.ts | 1 + .../src/services/navigation/useHelpNavItem.ts | 20 +++++ pkg/services/navtree/navtreeimpl/navtree.go | 16 +++- public/app/app.ts | 3 + .../ExtensionSidebar.test.tsx | 2 +- .../ExtensionSidebarProvider.test.tsx | 10 +-- .../ExtensionSidebarProvider.tsx | 21 +++++- .../ExtensionSidebar/ExtensionToolbarItem.tsx | 18 ++++- .../AppChrome/TopBar/HelpTopBarButton.tsx | 75 +++++++++++++++++++ .../AppChrome/TopBar/SingleTopBar.tsx | 19 ++--- .../AppChrome/TopBar/useHelpNode.tsx | 11 +++ 11 files changed, 168 insertions(+), 28 deletions(-) create mode 100644 packages/grafana-runtime/src/services/navigation/useHelpNavItem.ts create mode 100644 public/app/core/components/AppChrome/TopBar/HelpTopBarButton.tsx create mode 100644 public/app/core/components/AppChrome/TopBar/useHelpNode.tsx 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 = ( {components.map((c) => { - const id = getComponentIdFromComponentMeta(pluginId, c); + const id = getComponentIdFromComponentMeta(pluginId, c.title); return ( } placement="bottom-end"> + + + ); + } + + const componentId = getComponentIdFromComponentMeta(pathfinderPluginId, 'Grafana Pathfinder'); + const isOpen = dockedComponentId === componentId; + + return ( + { + if (isOpen) { + setDockedComponentId(undefined); + } else { + const appEvents = getAppEvents(); + appEvents.publish( + new OpenExtensionSidebarEvent({ + pluginId: pathfinderPluginId, + componentTitle: 'Grafana Pathfinder', + }) + ); + } + }} + /> + ); +}); + +const getStyles = (theme: GrafanaTheme2) => ({ + helpButtonActive: css({ + borderRadius: theme.shape.radius.circle, + backgroundColor: theme.colors.primary.transparent, + border: `1px solid ${theme.colors.primary.borderTransparent}`, + color: theme.colors.text.primary, + }), +}); diff --git a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx index 71211e4b535..f04222533f9 100644 --- a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx +++ b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx @@ -1,12 +1,11 @@ import { css } from '@emotion/css'; -import { cloneDeep } from 'lodash'; -import { memo } from 'react'; +import React, { memo } from 'react'; import { GrafanaTheme2, NavModelItem } from '@grafana/data'; import { Components } from '@grafana/e2e-selectors'; import { t } from '@grafana/i18n'; import { ScopesContextValue } from '@grafana/runtime'; -import { Dropdown, Icon, Stack, ToolbarButton, useStyles2 } from '@grafana/ui'; +import { Icon, Stack, ToolbarButton, useStyles2 } from '@grafana/ui'; import { config } from 'app/core/config'; import { MEGA_MENU_TOGGLE_ID } from 'app/core/constants'; import { useGrafana } from 'app/core/context/GrafanaContext'; @@ -20,15 +19,14 @@ import { Breadcrumbs } from '../../Breadcrumbs/Breadcrumbs'; import { buildBreadcrumbs } from '../../Breadcrumbs/utils'; import { ExtensionToolbarItem } from '../ExtensionSidebar/ExtensionToolbarItem'; import { HistoryContainer } from '../History/HistoryContainer'; -import { enrichHelpItem } from '../MegaMenu/utils'; import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator'; import { QuickAdd } from '../QuickAdd/QuickAdd'; +import { HelpTopBarButton } from './HelpTopBarButton'; import { InviteUserButton } from './InviteUserButton'; import { ProfileButton } from './ProfileButton'; import { SignInLink } from './SignInLink'; import { SingleTopBarActions } from './SingleTopBarActions'; -import { TopNavBarMenu } from './TopNavBarMenu'; import { TopSearchBarCommandPaletteTrigger } from './TopSearchBarCommandPaletteTrigger'; import { getChromeHeaderLevelHeight } from './useChromeHeaderHeight'; @@ -57,10 +55,7 @@ export const SingleTopBar = memo(function SingleTopBar({ const state = chrome.useState(); const menuDockedAndOpen = !state.chromeless && state.megaMenuDocked && state.megaMenuOpen; const styles = useStyles2(getStyles, menuDockedAndOpen); - const navIndex = useSelector((state) => state.navIndex); - const helpNode = cloneDeep(navIndex['help']); - const enrichedHelpNode = helpNode ? enrichHelpItem(helpNode) : undefined; - const profileNode = navIndex['profile']; + const profileNode = useSelector((state) => state.navIndex['profile']); const homeNav = useSelector((state) => state.navIndex)[HOME_NAV_ID]; const breadcrumbs = buildBreadcrumbs(sectionNav, pageNav, homeNav); const unifiedHistoryEnabled = config.featureToggles.unifiedHistory; @@ -98,11 +93,7 @@ export const SingleTopBar = memo(function SingleTopBar({ {unifiedHistoryEnabled && !isSmallScreen && } {!isSmallScreen && } - {enrichedHelpNode && ( - } placement="bottom-end"> - - - )} + {!isSmallScreen && } {!showToolbarLevel && actions} diff --git a/public/app/core/components/AppChrome/TopBar/useHelpNode.tsx b/public/app/core/components/AppChrome/TopBar/useHelpNode.tsx new file mode 100644 index 00000000000..52beedffb56 --- /dev/null +++ b/public/app/core/components/AppChrome/TopBar/useHelpNode.tsx @@ -0,0 +1,11 @@ +import { cloneDeep } from 'lodash'; + +import { useSelector } from 'app/types/store'; + +import { enrichHelpItem } from '../MegaMenu/utils'; + +export function useHelpNode() { + const navIndex = useSelector((state) => state.navIndex); + const helpNode = cloneDeep(navIndex['help']); + return helpNode ? enrichHelpItem(helpNode) : undefined; +}