Open Grafana Pathfinder instead of the help menu if its installed (#110592)
* Link to Grafana Pathfinder if available Signed-off-by: Jack Baldry <jack.baldry@grafana.com> * 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 <jack.baldry@grafana.com> * 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 <jack.baldry@grafana.com> * Rename hook for consistency Signed-off-by: Jack Baldry <jack.baldry@grafana.com> --------- Signed-off-by: Jack Baldry <jack.baldry@grafana.com> Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com> Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>
This commit is contained in:
co-authored by
Levente Balogh
Marcus Andersson
parent
e05cfb676a
commit
09a3498552
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+5
-5
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = (
|
||||
<Menu>
|
||||
{components.map((c) => {
|
||||
const id = getComponentIdFromComponentMeta(pluginId, c);
|
||||
const id = getComponentIdFromComponentMeta(pluginId, c.title);
|
||||
return (
|
||||
<Menu.Item
|
||||
key={id}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { getAppEvents } from '@grafana/runtime';
|
||||
import { Dropdown, ToolbarButton, useStyles2 } from '@grafana/ui';
|
||||
import { OpenExtensionSidebarEvent } from 'app/types/events';
|
||||
|
||||
import {
|
||||
useExtensionSidebarContext,
|
||||
getComponentIdFromComponentMeta,
|
||||
getPathfinderPluginId,
|
||||
} from '../ExtensionSidebar/ExtensionSidebarProvider';
|
||||
|
||||
import { TopNavBarMenu } from './TopNavBarMenu';
|
||||
import { useHelpNode } from './useHelpNode';
|
||||
|
||||
interface Props {
|
||||
isSmallScreen: boolean;
|
||||
}
|
||||
|
||||
export const HelpTopBarButton = memo(function HelpTopBarButton({ isSmallScreen }: Props) {
|
||||
const enrichedHelpNode = useHelpNode();
|
||||
const { setDockedComponentId, dockedComponentId, availableComponents } = useExtensionSidebarContext();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
if (!enrichedHelpNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathfinderPluginId = getPathfinderPluginId(availableComponents);
|
||||
|
||||
if (isSmallScreen || !enrichedHelpNode.hideFromTabs || pathfinderPluginId === undefined) {
|
||||
return (
|
||||
<Dropdown overlay={() => <TopNavBarMenu node={enrichedHelpNode} />} placement="bottom-end">
|
||||
<ToolbarButton iconOnly icon="question-circle" aria-label={t('navigation.help.aria-label', 'Help')} />
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
const componentId = getComponentIdFromComponentMeta(pathfinderPluginId, 'Grafana Pathfinder');
|
||||
const isOpen = dockedComponentId === componentId;
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
iconOnly
|
||||
icon="question-circle"
|
||||
aria-label={t('navigation.help.aria-label', 'Help')}
|
||||
className={isOpen ? styles.helpButtonActive : undefined}
|
||||
onClick={() => {
|
||||
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,
|
||||
}),
|
||||
});
|
||||
@@ -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({
|
||||
<TopSearchBarCommandPaletteTrigger />
|
||||
{unifiedHistoryEnabled && !isSmallScreen && <HistoryContainer />}
|
||||
{!isSmallScreen && <QuickAdd />}
|
||||
{enrichedHelpNode && (
|
||||
<Dropdown overlay={() => <TopNavBarMenu node={enrichedHelpNode} />} placement="bottom-end">
|
||||
<ToolbarButton iconOnly icon="question-circle" aria-label={t('navigation.help.aria-label', 'Help')} />
|
||||
</Dropdown>
|
||||
)}
|
||||
<HelpTopBarButton isSmallScreen={isSmallScreen} />
|
||||
<NavToolbarSeparator />
|
||||
{!isSmallScreen && <ExtensionToolbarItem compact={isSmallScreen} />}
|
||||
{!showToolbarLevel && actions}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user