diff --git a/packages/grafana-data/src/types/panel.ts b/packages/grafana-data/src/types/panel.ts index df7ab01a9c3..70b237b0c44 100644 --- a/packages/grafana-data/src/types/panel.ts +++ b/packages/grafana-data/src/types/panel.ts @@ -188,7 +188,11 @@ export interface PanelOptionsEditorConfig void; shortcut?: string; diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 385da2dfb09..2b3564ac797 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -28,9 +28,12 @@ export type PluginExtensionLink = PluginExtensionBase & { type: PluginExtensionTypes.link; path?: string; onClick?: (event?: React.MouseEvent) => void; + /** + * @deprecated Use `prefix` instead. This property will be removed in a future release. + */ icon?: IconName; - suffix?: React.ReactElement; - prefix?: React.ReactElement; + /** A React element or IconName that will be displayed before the title */ + prefix?: React.ReactElement | IconName; category?: string; openInNewTab?: boolean; }; @@ -108,9 +111,12 @@ export type PluginAddedLinksConfigureFunc = (context: Re description: string; path: string; onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers) => void; + /** + * @deprecated Use `prefix` instead. This property will be removed in a future release. + */ icon: IconName; - suffix: React.ReactElement; - prefix: React.ReactElement; + /** A React element or IconName that will be displayed before the title */ + prefix: React.ReactElement | IconName; category: string; openInNewTab: boolean; }> @@ -139,7 +145,9 @@ export type PluginExtensionAddedLinkConfig = Pl // (Optional) A function that can be used to configure the extension dynamically based on the extension point's context configure?: PluginAddedLinksConfigureFunc; - // (Optional) A icon that can be displayed in the ui for the extension option. + /** + * @deprecated Use `prefix` instead. This property will be removed in a future release. + */ icon?: IconName; // (Optional) A category to be used when grouping the options in the ui @@ -149,11 +157,8 @@ export type PluginExtensionAddedLinkConfig = Pl // (Important: this is not guaranteed, depends on the extension point if it implements it.) openInNewTab?: boolean; - // (Optional) A React element that will be displayed after the title - suffix?: React.ReactElement; - - // (Optional) A React element that will be displayed before the title - prefix?: React.ReactElement; + // (Optional) A React element or IconName that will be displayed before the title + prefix?: React.ReactElement | IconName; }; export type PluginExtensionExposedComponentConfig = PluginExtensionConfigBase & { diff --git a/packages/grafana-ui/src/components/Menu/MenuItem.tsx b/packages/grafana-ui/src/components/Menu/MenuItem.tsx index 68db053ac44..0e22d45c15c 100644 --- a/packages/grafana-ui/src/components/Menu/MenuItem.tsx +++ b/packages/grafana-ui/src/components/Menu/MenuItem.tsx @@ -2,12 +2,11 @@ import { css, cx } from '@emotion/css'; import { ReactElement, useCallback, useState, useRef, useImperativeHandle, CSSProperties, AriaRole } from 'react'; import * as React from 'react'; -import { GrafanaTheme2, LinkTarget } from '@grafana/data'; +import { GrafanaTheme2, IconName, isIconName, LinkTarget } from '@grafana/data'; import { t } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; import { getFocusStyles, getInternalRadius } from '../../themes/mixins'; -import { IconName } from '../../types/icon'; import { Icon } from '../Icon/Icon'; import { Stack } from '../Layout/Stack/Stack'; @@ -29,10 +28,12 @@ export interface MenuItemProps { ariaChecked?: boolean; /** Target of the menu item (i.e. new window) */ target?: LinkTarget; - /** Icon of the menu item */ + /** + * @deprecated Use `prefix` instead. This property will be removed in a future release. + */ icon?: IconName; - /** Prefix of the menu item if icon is specified prefix will be ignored */ - prefix?: React.ReactElement; + /** A React element or IconName that will be displayed before the title */ + prefix?: React.ReactElement | IconName; /** Role of the menu item */ role?: AriaRole; /** Url of the menu item */ @@ -180,7 +181,7 @@ export const MenuItem = React.memo( {...disabledProps} > - + {label}
{hasShortcut && ( diff --git a/packages/grafana-ui/src/components/Menu/MenuItemPrefix.test.tsx b/packages/grafana-ui/src/components/Menu/MenuItemPrefix.test.tsx new file mode 100644 index 00000000000..5c4aa2b03d0 --- /dev/null +++ b/packages/grafana-ui/src/components/Menu/MenuItemPrefix.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from '@testing-library/react'; + +import { MenuItemPrefix } from './MenuItemPrefix'; + +describe('MenuItemPrefix', () => { + it('renders nothing when prefix is not provided', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders icon when prefix is an IconName string', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + expect(svg).toHaveAttribute('aria-hidden', 'true'); + }); + + it('renders custom element when prefix is a React element', () => { + render(Custom} />); + expect(screen.getByTestId('custom-icon')).toBeInTheDocument(); + }); + + it('wraps React element prefix in a div with aria-hidden', () => { + render(Custom} />); + const wrapper = screen.getByTestId('custom-icon').parentElement; + expect(wrapper).toHaveAttribute('aria-hidden', 'true'); + }); + + it('renders nothing when prefix is an invalid value', () => { + // @ts-expect-error - testing invalid input + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing when prefix is null', () => { + // @ts-expect-error - testing null input + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/packages/grafana-ui/src/components/Menu/MenuItemPrefix.tsx b/packages/grafana-ui/src/components/Menu/MenuItemPrefix.tsx index f6f76541b02..204397f5aaf 100644 --- a/packages/grafana-ui/src/components/Menu/MenuItemPrefix.tsx +++ b/packages/grafana-ui/src/components/Menu/MenuItemPrefix.tsx @@ -1,26 +1,30 @@ import { css } from '@emotion/css'; +import { isValidElement } from 'react'; -import { IconName } from '@grafana/data'; +import { IconName, isIconName } from '@grafana/data'; import { useStyles2 } from '../../themes/ThemeContext'; import { Icon } from '../Icon/Icon'; import { getSvgSize } from '../Icon/utils'; type MenuItemPrefixProps = { - prefix?: React.ReactElement; - icon?: IconName; + prefix?: React.ReactElement | IconName; }; /** @internal */ -export function MenuItemPrefix({ prefix, icon }: MenuItemPrefixProps): React.ReactNode { +export function MenuItemPrefix({ prefix }: MenuItemPrefixProps): React.ReactNode { const styles = useStyles2(getStyles); - if (!icon && !prefix) { + if (!prefix) { return null; } - if (icon) { - return ; + if (isIconName(prefix)) { + return ; + } + + if (!isValidElement(prefix)) { + return null; } return ( diff --git a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts index c3892074d55..d8990295465 100644 --- a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts +++ b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts @@ -19,9 +19,12 @@ export type AddedLinkRegistryItem = { path?: string; onClick?: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers) => void; configure?: PluginAddedLinksConfigureFunc; + /** + * @deprecated Use `prefix` instead. This property will be removed in a future release. + */ icon?: IconName; - suffix?: React.ReactElement; - prefix?: React.ReactElement; + /** A React element or IconName that will be displayed before the title */ + prefix?: React.ReactElement | IconName; category?: string; openInNewTab?: boolean; }; diff --git a/public/app/features/plugins/extensions/usePluginLinks.tsx b/public/app/features/plugins/extensions/usePluginLinks.tsx index 2e32c85f081..ab7f80f3ac3 100644 --- a/public/app/features/plugins/extensions/usePluginLinks.tsx +++ b/public/app/features/plugins/extensions/usePluginLinks.tsx @@ -73,6 +73,8 @@ export function usePluginLinks({ } const path = overrides?.path || addedLink.path; + // For backwards compatibility: if prefix is not set, fall back to icon + const prefix = overrides?.prefix || addedLink.prefix || overrides?.icon || addedLink.icon; const extension: PluginExtensionLink = { id: generateExtensionId(pluginId, extensionPointId, addedLink.title), type: PluginExtensionTypes.link, @@ -86,8 +88,7 @@ export function usePluginLinks({ path: isString(path) ? getLinkExtensionPathWithTracking(pluginId, path, extensionPointId) : undefined, category: overrides?.category || addedLink.category, openInNewTab: overrides?.openInNewTab ?? addedLink.openInNewTab, - suffix: overrides?.suffix || addedLink.suffix, - prefix: overrides?.prefix || addedLink.prefix, + prefix, }; extensions.push(extension); diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index e21fb4a5e67..6b9e2314a74 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -486,7 +486,6 @@ export function getLinkExtensionOverrides( icon = config.icon, category = config.category, openInNewTab = config.openInNewTab, - suffix = config.suffix, prefix = config.prefix, ...rest } = overrides; @@ -513,7 +512,6 @@ export function getLinkExtensionOverrides( icon, category, openInNewTab, - suffix, prefix, }; } catch (error) {