Compare commits

..

3 Commits

Author SHA1 Message Date
Marcus Andersson 43eef28e1b Updated according to feedback: 2026-01-14 13:40:03 +01:00
Marcus Andersson cc8505f4e0 Added support for custom icon/prefix in panel menu extensions. 2026-01-14 13:40:00 +01:00
Marcus Andersson 999b24d2a0 Made it possible to pass a custom react node as the icon for extension links. 2026-01-14 13:38:47 +01:00
13 changed files with 149 additions and 201 deletions
+5
View File
@@ -188,6 +188,11 @@ export interface PanelOptionsEditorConfig<TOptions, TSettings = any, TValue = an
export interface PanelMenuItem {
type?: 'submenu' | 'divider' | 'group';
text: string;
/** A React element or IconName that will be displayed before the title */
prefix?: React.ReactElement | IconName;
/**
* @deprecated Use `prefix` instead. This property will be removed in a future release.
*/
iconClassName?: IconName;
onClick?: (event: React.MouseEvent) => void;
shortcut?: string;
@@ -28,7 +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;
/** A React element or IconName that will be displayed before the title */
prefix?: React.ReactElement | IconName;
category?: string;
openInNewTab?: boolean;
};
@@ -106,7 +111,12 @@ export type PluginAddedLinksConfigureFunc<Context extends object> = (context: Re
description: string;
path: string;
onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers<Context>) => void;
/**
* @deprecated Use `prefix` instead. This property will be removed in a future release.
*/
icon: IconName;
/** A React element or IconName that will be displayed before the title */
prefix: React.ReactElement | IconName;
category: string;
openInNewTab: boolean;
}>
@@ -135,7 +145,9 @@ export type PluginExtensionAddedLinkConfig<Context extends object = object> = Pl
// (Optional) A function that can be used to configure the extension dynamically based on the extension point's context
configure?: PluginAddedLinksConfigureFunc<Context>;
// (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
@@ -144,6 +156,9 @@ export type PluginExtensionAddedLinkConfig<Context extends object = object> = Pl
// (Optional) If true, opens the link in a new tab (renders with target="_blank")
// (Important: this is not guaranteed, depends on the extension point if it implements it.)
openInNewTab?: boolean;
// (Optional) A React element or IconName that will be displayed before the title
prefix?: React.ReactElement | IconName;
};
export type PluginExtensionExposedComponentConfig<Props = {}> = PluginExtensionConfigBase & {
@@ -2,15 +2,15 @@ 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';
import { MenuItemPrefix } from './MenuItemPrefix';
import { SubMenu } from './SubMenu';
/** @internal */
@@ -28,8 +28,12 @@ export interface MenuItemProps<T = unknown> {
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;
/** 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 */
@@ -79,6 +83,7 @@ export const MenuItem = React.memo(
customSubMenuContainerStyles,
shortcut,
testId,
prefix,
} = props;
const styles = useStyles2(getStyles);
const [isActive, setIsActive] = useState(active);
@@ -176,7 +181,7 @@ export const MenuItem = React.memo(
{...disabledProps}
>
<Stack direction="row" justifyContent="flex-start" alignItems="center">
{icon && <Icon name={icon} className={styles.icon} aria-hidden />}
<MenuItemPrefix prefix={isIconName(icon) ? icon : prefix} />
<span className={cx(styles.ellipsis, styles.label)}>{label}</span>
<div className={cx(styles.rightWrapper, { [styles.withShortcut]: hasShortcut })}>
{hasShortcut && (
@@ -272,9 +277,6 @@ const getStyles = (theme: GrafanaTheme2) => {
color: theme.colors.action.disabledText,
},
}),
icon: css({
opacity: 0.7,
}),
rightWrapper: css({
display: 'flex',
alignItems: 'center',
@@ -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(<MenuItemPrefix />);
expect(container).toBeEmptyDOMElement();
});
it('renders icon when prefix is an IconName string', () => {
const { container } = render(<MenuItemPrefix prefix="history" />);
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(<MenuItemPrefix prefix={<span data-testid="custom-icon">Custom</span>} />);
expect(screen.getByTestId('custom-icon')).toBeInTheDocument();
});
it('wraps React element prefix in a div with aria-hidden', () => {
render(<MenuItemPrefix prefix={<span data-testid="custom-icon">Custom</span>} />);
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(<MenuItemPrefix prefix="not-a-valid-icon-name" />);
expect(container).toBeEmptyDOMElement();
});
it('renders nothing when prefix is null', () => {
// @ts-expect-error - testing null input
const { container } = render(<MenuItemPrefix prefix={null} />);
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,52 @@
import { css } from '@emotion/css';
import { isValidElement } from 'react';
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 | IconName;
};
/** @internal */
export function MenuItemPrefix({ prefix }: MenuItemPrefixProps): React.ReactNode {
const styles = useStyles2(getStyles);
if (!prefix) {
return null;
}
if (isIconName(prefix)) {
return <Icon name={prefix} className={styles.icon} aria-hidden />;
}
if (!isValidElement(prefix)) {
return null;
}
return (
<div className={styles.prefix} aria-hidden>
{prefix}
</div>
);
}
function getStyles() {
const prefixSize = getSvgSize('md');
return {
icon: css({
opacity: 0.7,
}),
prefix: css({
display: 'inline-block',
verticalAlign: 'middle',
width: prefixSize,
height: prefixSize,
overflow: 'hidden',
}),
};
}
@@ -1,5 +1,4 @@
import { DataQuery } from '@grafana/data';
import { createMonitoringLogger, MonitoringLogger } from '@grafana/runtime';
import store from 'app/core/store';
import { RichHistoryQuery } from 'app/types/explore';
@@ -27,15 +26,8 @@ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => backendSrv,
getDataSourceSrv: () => dsMock,
createMonitoringLogger: jest.fn().mockReturnValue({ logWarning: jest.fn() }),
}));
// logger is created at import so we cannot initialize inside the test
const loggerIndex = (createMonitoringLogger as jest.Mock).mock.calls.findIndex(
(args) => args[0] === 'features.query-history.local-storage'
);
const loggerMock: MonitoringLogger = (createMonitoringLogger as jest.Mock).mock.results[loggerIndex]?.value;
interface MockQuery extends DataQuery {
query: string;
}
@@ -83,8 +75,6 @@ describe('RichHistoryLocalStorage', () => {
jest.setSystemTime(now);
storage = new RichHistoryLocalStorage();
await storage.deleteAll();
(loggerMock.logWarning as jest.Mock).mockReset();
});
afterEach(() => {
@@ -233,90 +223,6 @@ describe('RichHistoryLocalStorage', () => {
});
});
describe('quota errors and retries', () => {
it('should rotate and retry saving when QuotaExceededError occurs once', async () => {
const initial = [
{ ts: Date.now(), starred: true, comment: 'starred1', queries: [], datasourceName: 'name-of-dev-test' },
{ ts: Date.now(), starred: false, comment: 'notStarred1', queries: [], datasourceName: 'name-of-dev-test' },
{ ts: Date.now(), starred: true, comment: 'starred2', queries: [], datasourceName: 'name-of-dev-test' },
];
store.setObject(key, initial);
// Spy on setObject to throw once with QuotaExceededError, then call through
const originalSetObject = store.setObject.bind(store);
jest
.spyOn(store, 'setObject')
// first attempt throws and errors
.mockImplementationOnce(() => {
const err = new Error('quota hit');
err.name = 'QuotaExceededError';
throw err;
})
// second attempt calls through
.mockImplementation((k: string, value: unknown) => {
return originalSetObject(k, value);
});
const result = await storage.addToRichHistory({
starred: false,
datasourceUid: 'dev-test',
datasourceName: 'name-of-dev-test',
comment: 'new',
queries: [{ refId: 'A' }],
});
expect(result.richHistoryQuery).toBeDefined();
// After one failure, rotation removes one unstarred entry
const saved = store.getObject<RichHistoryQuery[]>(key)!;
expect(saved).toHaveLength(3);
expect(saved).toMatchObject([
expect.objectContaining({ comment: 'new' }),
expect.objectContaining({ comment: 'starred1' }),
expect.objectContaining({ comment: 'starred2' }),
]);
// Ensure logger was called for the failure, with expected flags
expect(loggerMock.logWarning).toHaveBeenCalled();
const [message, payload] = (loggerMock.logWarning as jest.Mock).mock.calls[0];
expect(message).toContain('Failed to save rich history to local storage');
expect(payload.saveRetriesLeft).toBe('3');
expect(payload.quotaExceededError).toBe('true');
});
it('should throw StorageFull when QuotaExceededError persists for all retries and track attempts', async () => {
store.setObject(key, [
{ ts: Date.now(), starred: false, comment: 'notStarred1', queries: [], datasourceName: 'name-of-dev-test' },
]);
const setSpy = jest.spyOn(store, 'setObject').mockImplementation(() => {
const err = new Error('quota still hit');
err.name = 'QuotaExceededError';
throw err;
});
await expect(
storage.addToRichHistory({
starred: false,
datasourceUid: 'dev-test',
datasourceName: 'name-of-dev-test',
comment: 'new',
queries: [{ refId: 'B' }],
})
).rejects.toMatchObject({ name: 'StorageFull' });
// 4 failed tracking attempts (1 save + 3 retries) should be logged (for each failed try)
expect(loggerMock.logWarning).toHaveBeenCalledTimes(4);
const calls = (loggerMock.logWarning as jest.Mock).mock.calls;
expect(calls[0][0]).toContain('Failed to save rich history to local storage');
expect(calls[0][1].saveRetriesLeft).toBe('3');
expect(calls[1][1].saveRetriesLeft).toBe('2');
expect(calls[2][1].saveRetriesLeft).toBe('1');
expect(calls[3][1].saveRetriesLeft).toBe('0');
setSpy.mockRestore();
});
});
describe('migration', () => {
afterEach(() => {
storage.deleteAll();
@@ -1,8 +1,7 @@
import { find, isEqual, omit } from 'lodash';
import { DataQuery, SelectableValue } from '@grafana/data';
import { createMonitoringLogger } from '@grafana/runtime';
import { RichHistorySearchFilters, RichHistorySettings, SortOrder } from 'app/core/utils/richHistoryTypes';
import { RichHistorySearchFilters, RichHistorySettings } from 'app/core/utils/richHistoryTypes';
import { RichHistoryQuery } from 'app/types/explore';
import store from '../store';
@@ -27,18 +26,10 @@ export type RichHistoryLocalStorageDTO = {
queries: DataQuery[];
};
const logger = createMonitoringLogger('features.query-history.local-storage');
/**
* Local storage implementation for Rich History. It keeps all entries in browser's local storage.
*/
export default class RichHistoryLocalStorage implements RichHistoryStorage {
public static getLocalStorageUsageInBytes(): number {
const richHistory: RichHistoryLocalStorageDTO[] = store.get(RICH_HISTORY_KEY) || '';
// each character is 2 bytes
return richHistory.length * 2;
}
/**
* Return history entries based on provided filters, perform migration and clean up entries not matching retention policy.
*/
@@ -86,43 +77,21 @@ export default class RichHistoryLocalStorage implements RichHistoryStorage {
throw error;
}
let { queriesToKeep, limitExceeded } = cleanUpUnstarredQuery(currentRichHistoryDTOs, MAX_HISTORY_ITEMS);
const { queriesToKeep, limitExceeded } = checkLimits(currentRichHistoryDTOs);
let updatedHistory: RichHistoryLocalStorageDTO[] = [newRichHistoryQueryDTO, ...queriesToKeep];
const updatedHistory: RichHistoryLocalStorageDTO[] = [newRichHistoryQueryDTO, ...queriesToKeep];
let saveRetriesLeft = 3;
let saved = false;
while (!saved && saveRetriesLeft >= 0) {
try {
store.setObject(RICH_HISTORY_KEY, updatedHistory);
saved = true;
} catch (error) {
await this.trackLocalStorageUsage('Failed to save rich history to local storage', {
saveRetriesLeft: saveRetriesLeft.toString(),
quotaExceededError: error instanceof Error && error.name === 'QuotaExceededError' ? 'true' : 'false',
errorMessage: error instanceof Error ? error?.message : 'unknown',
});
if (saveRetriesLeft >= 1) {
saveRetriesLeft--;
const { queriesToKeep: newQueriesToKeep } = cleanUpUnstarredQuery(queriesToKeep, queriesToKeep.length - 1);
updatedHistory = [newRichHistoryQueryDTO, ...newQueriesToKeep];
queriesToKeep = newQueriesToKeep;
continue;
}
if (error instanceof Error && error.name === 'QuotaExceededError') {
throwError(RichHistoryServiceError.StorageFull, `Saving rich history failed: ${error.message}`);
} else {
throw error;
}
try {
store.setObject(RICH_HISTORY_KEY, updatedHistory);
} catch (error) {
if (error instanceof Error && error.name === 'QuotaExceededError') {
throwError(RichHistoryServiceError.StorageFull, `Saving rich history failed: ${error.message}`);
} else {
throw error;
}
}
if (limitExceeded) {
await this.trackLocalStorageUsage('Rich history query limit exceeded.');
return {
warning: {
type: RichHistoryStorageWarning.LimitExceeded,
@@ -179,33 +148,6 @@ export default class RichHistoryLocalStorage implements RichHistoryStorage {
})
);
}
private async trackLocalStorageUsage(message: string, additionalInfo?: Record<string, string>) {
const allQueriesCount =
(
await this.getRichHistory({
search: '',
sortOrder: SortOrder.Ascending,
datasourceFilters: [],
starred: false,
})
).total || -1;
const allQueriesSizeInBytes = RichHistoryLocalStorage.getLocalStorageUsageInBytes();
const totalLocalStorageSize = calculateTotalLocalStorageSize();
const localStats = {
totalLocalStorageSize: totalLocalStorageSize?.toString(),
allQueriesSizeInBytes: allQueriesSizeInBytes?.toString(),
allQueriesCount: allQueriesCount?.toString(),
};
logger.logWarning(message, {
...localStats,
...additionalInfo,
});
}
}
function updateRichHistory(
@@ -243,20 +185,17 @@ function cleanUp(richHistory: RichHistoryLocalStorageDTO[]): RichHistoryLocalSto
}
/**
* Ensures the entry can be added.
* Ensures the entry can be added. Throws an error if current limit has been hit.
* Returns queries that should be saved back giving space for one extra query.
*/
export function cleanUpUnstarredQuery(
queriesToKeep: RichHistoryLocalStorageDTO[],
max: number
): {
export function checkLimits(queriesToKeep: RichHistoryLocalStorageDTO[]): {
queriesToKeep: RichHistoryLocalStorageDTO[];
limitExceeded: boolean;
} {
// remove oldest non-starred items to give space for the recent query
let limitExceeded = false;
let current = queriesToKeep.length - 1;
while (current >= 0 && queriesToKeep.length >= max) {
while (current >= 0 && queriesToKeep.length >= MAX_HISTORY_ITEMS) {
if (!queriesToKeep[current].starred) {
queriesToKeep.splice(current, 1);
limitExceeded = true;
@@ -308,26 +247,3 @@ function throwError(name: string, message: string) {
error.name = name;
throw error;
}
function calculateTotalLocalStorageSize() {
try {
let total = 0;
// eslint-disable-next-line
const ls = window.localStorage;
for (let i = 0; i < ls.length; i++) {
const key = ls.key(i);
if (key) {
const value = ls.getItem(key);
if (value) {
total += key.length + value.length;
}
}
}
// each character is 2 bytes
return total * 2;
} catch (e) {
return -1;
}
}
@@ -28,7 +28,6 @@ export type RichHistorySearchFilters = {
// so the resulting timerange from this will be [now - from, now - to].
from?: number;
to?: number;
// true if only starred entries should be returned, false if ALL entries should be returned,
starred: boolean;
page?: number;
};
@@ -29,6 +29,7 @@ export function PanelHeaderMenu({ items }: Props) {
key={item.text}
label={item.text}
icon={item.iconClassName}
prefix={item.prefix}
childItems={item.subMenu ? renderItems(item.subMenu) : undefined}
url={item.href}
onClick={item.onClick}
@@ -200,7 +200,7 @@ describe('Explore: Query History', () => {
await waitForExplore();
await openQueryHistory();
jest.spyOn(localStorage, 'cleanUpUnstarredQuery').mockImplementationOnce((queries) => {
jest.spyOn(localStorage, 'checkLimits').mockImplementationOnce((queries) => {
return { queriesToKeep: queries, limitExceeded: true };
});
@@ -19,7 +19,12 @@ export type AddedLinkRegistryItem<Context extends object = object> = {
path?: string;
onClick?: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers<Context>) => void;
configure?: PluginAddedLinksConfigureFunc<Context>;
/**
* @deprecated Use `prefix` instead. This property will be removed in a future release.
*/
icon?: IconName;
/** A React element or IconName that will be displayed before the title */
prefix?: React.ReactElement | IconName;
category?: string;
openInNewTab?: boolean;
};
@@ -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,6 +88,7 @@ export function usePluginLinks({
path: isString(path) ? getLinkExtensionPathWithTracking(pluginId, path, extensionPointId) : undefined,
category: overrides?.category || addedLink.category,
openInNewTab: overrides?.openInNewTab ?? addedLink.openInNewTab,
prefix,
};
extensions.push(extension);
@@ -421,6 +421,7 @@ export function createExtensionSubMenu(extensions: PluginExtensionLink[]): Panel
onClick: extension.onClick,
iconClassName: extension.icon,
target: extension.openInNewTab ? '_blank' : undefined,
prefix: extension.prefix,
});
continue;
}
@@ -435,6 +436,7 @@ export function createExtensionSubMenu(extensions: PluginExtensionLink[]): Panel
onClick: extension.onClick,
iconClassName: extension.icon,
target: extension.openInNewTab ? '_blank' : undefined,
prefix: extension.prefix,
});
}
@@ -484,6 +486,7 @@ export function getLinkExtensionOverrides(
icon = config.icon,
category = config.category,
openInNewTab = config.openInNewTab,
prefix = config.prefix,
...rest
} = overrides;
@@ -509,6 +512,7 @@ export function getLinkExtensionOverrides(
icon,
category,
openInNewTab,
prefix,
};
} catch (error) {
if (error instanceof Error) {