DatasourcePicker: Favorite datasources (#109404)

This commit is contained in:
Andres Martinez Gotor
2025-08-13 15:34:19 +02:00
committed by GitHub
parent 842fd44e83
commit 2e4b10e81c
8 changed files with 470 additions and 11 deletions

1
.github/CODEOWNERS vendored
View File

@ -608,6 +608,7 @@
/packages/grafana-runtime/src/utils/returnToPrevious.ts @grafana/grafana-search-navigate-organise
/packages/grafana-runtime/src/utils/toDataQueryError.ts @grafana/grafana-datasources-core-services
/packages/grafana-runtime/src/utils/userStorage* @grafana/plugins-platform-frontend @grafana/grafana-frontend-platform
/packages/grafana-runtime/src/utils/useFavoriteDatasources* @grafana/plugins-platform-frontend
# @grafana/schema
/packages/grafana-schema/ @grafana/grafana-app-platform-squad

View File

@ -57,6 +57,7 @@ export { hasPermission, hasPermissionInMetadata, hasAllPermissions, hasAnyPermis
export { QueryEditorWithMigration } from './components/QueryEditorWithMigration';
export { type MigrationHandler, isMigrationHandler, migrateQuery, migrateRequest } from './utils/migrationHandler';
export { usePluginUserStorage } from './utils/userStorage';
export { useFavoriteDatasources } from './utils/useFavoriteDatasources';
export { FolderPicker, setFolderPicker } from './components/FolderPicker';
export {
type CorrelationsService,

View File

@ -0,0 +1,271 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { DataSourceInstanceSettings, DataSourcePluginMeta, PluginType, PluginMetaInfo } from '@grafana/data';
import { useFavoriteDatasources } from './useFavoriteDatasources';
// Mock UserStorage
const mockGetItem = jest.fn();
const mockSetItem = jest.fn();
jest.mock('./userStorage', () => {
return {
UserStorage: jest.fn().mockImplementation(() => ({
getItem: mockGetItem,
setItem: mockSetItem,
})),
};
});
describe('useFavoriteDatasources', () => {
// Test data helpers
const pluginMetaInfo: PluginMetaInfo = {
author: { name: '', url: '' },
description: '',
version: '',
updated: '',
links: [],
logos: { small: '', large: '' },
screenshots: [],
};
function createPluginMeta(name: string, builtIn: boolean): DataSourcePluginMeta {
return {
builtIn,
name,
id: name,
type: PluginType.datasource,
baseUrl: '',
info: pluginMetaInfo,
module: '',
};
}
function createDataSource(name: string, id: number, builtIn = false): DataSourceInstanceSettings {
return {
name: name,
uid: `${name}-uid`,
meta: createPluginMeta(name, builtIn),
id,
access: 'direct',
jsonData: {},
type: name,
readOnly: false,
};
}
const mockDS1 = createDataSource('prometheus', 1);
const mockBuiltInDS = createDataSource('grafana', 4, true);
beforeEach(() => {
mockGetItem.mockReset();
mockSetItem.mockReset();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('initial loading', () => {
it('should initialize with empty favorites when no stored data exists', async () => {
mockGetItem.mockResolvedValue(null);
const { result } = renderHook(() => useFavoriteDatasources());
await waitFor(() => {
expect(result.current.favoriteDatasources).toEqual([]);
expect(result.current.initialFavoriteDataSources).toEqual([]);
});
expect(mockGetItem).toHaveBeenCalledWith('favoriteDatasources');
});
it('should load existing favorites from storage', async () => {
const storedFavorites = ['prometheus-uid', 'loki-uid'];
mockGetItem.mockResolvedValue(JSON.stringify(storedFavorites));
const { result } = renderHook(() => useFavoriteDatasources());
await waitFor(() => {
expect(result.current.favoriteDatasources).toEqual(storedFavorites);
expect(result.current.initialFavoriteDataSources).toEqual(storedFavorites);
});
});
it('should handle non-array data in storage gracefully', async () => {
mockGetItem.mockResolvedValue(JSON.stringify({ not: 'an array' }));
const { result } = renderHook(() => useFavoriteDatasources());
await waitFor(() => {
expect(result.current.favoriteDatasources).toEqual([]);
expect(result.current.initialFavoriteDataSources).toEqual([]);
});
});
});
describe('addFavoriteDatasource', () => {
it('should add a new datasource to favorites', async () => {
mockGetItem.mockResolvedValue(JSON.stringify([]));
mockSetItem.mockResolvedValue(undefined);
const { result } = renderHook(() => useFavoriteDatasources());
await waitFor(() => {
expect(result.current.favoriteDatasources).toEqual([]);
});
act(() => {
result.current.addFavoriteDatasource(mockDS1);
});
await waitFor(() => {
expect(result.current.favoriteDatasources).toEqual(['prometheus-uid']);
expect(result.current.initialFavoriteDataSources).toEqual([]);
});
expect(mockSetItem).toHaveBeenCalledWith('favoriteDatasources', JSON.stringify(['prometheus-uid']));
});
it('should not add duplicate datasources', async () => {
mockGetItem.mockResolvedValue(JSON.stringify(['prometheus-uid']));
mockSetItem.mockResolvedValue(undefined);
const { result } = renderHook(() => useFavoriteDatasources());
await waitFor(() => {
expect(result.current.favoriteDatasources).toEqual(['prometheus-uid']);
});
act(() => {
result.current.addFavoriteDatasource(mockDS1);
});
// Should not change
expect(result.current.favoriteDatasources).toEqual(['prometheus-uid']);
expect(mockSetItem).not.toHaveBeenCalled();
});
it('should not add built-in datasources', async () => {
mockGetItem.mockResolvedValue(JSON.stringify([]));
mockSetItem.mockResolvedValue(undefined);
const { result } = renderHook(() => useFavoriteDatasources());
await waitFor(() => {
expect(result.current.favoriteDatasources).toEqual([]);
});
act(() => {
result.current.addFavoriteDatasource(mockBuiltInDS);
});
// Should not change
expect(result.current.favoriteDatasources).toEqual([]);
expect(mockSetItem).not.toHaveBeenCalled();
});
});
describe('removeFavoriteDatasource', () => {
it('should remove a datasource from favorites', async () => {
mockGetItem.mockResolvedValue(JSON.stringify(['prometheus-uid', 'loki-uid']));
mockSetItem.mockResolvedValue(undefined);
const { result } = renderHook(() => useFavoriteDatasources());
await waitFor(() => {
expect(result.current.favoriteDatasources).toEqual(['prometheus-uid', 'loki-uid']);
});
act(() => {
result.current.removeFavoriteDatasource(mockDS1);
});
await waitFor(() => {
expect(result.current.favoriteDatasources).toEqual(['loki-uid']);
});
expect(mockSetItem).toHaveBeenCalledWith('favoriteDatasources', JSON.stringify(['loki-uid']));
});
it('should handle removing non-existent datasource gracefully', async () => {
mockGetItem.mockResolvedValue(JSON.stringify(['loki-uid']));
mockSetItem.mockResolvedValue(undefined);
const { result } = renderHook(() => useFavoriteDatasources());
await waitFor(() => {
expect(result.current.favoriteDatasources).toEqual(['loki-uid']);
});
act(() => {
result.current.removeFavoriteDatasource(mockDS1); // prometheus not in list
});
// Should not change or call setItem since nothing was removed
expect(result.current.favoriteDatasources).toEqual(['loki-uid']);
expect(mockSetItem).not.toHaveBeenCalled();
});
it('should remove all datasources when removing the last one', async () => {
mockGetItem.mockResolvedValue(JSON.stringify(['prometheus-uid']));
mockSetItem.mockResolvedValue(undefined);
const { result } = renderHook(() => useFavoriteDatasources());
await waitFor(() => {
expect(result.current.favoriteDatasources).toEqual(['prometheus-uid']);
});
act(() => {
result.current.removeFavoriteDatasource(mockDS1);
});
await waitFor(() => {
expect(result.current.favoriteDatasources).toEqual([]);
});
expect(mockSetItem).toHaveBeenCalledWith('favoriteDatasources', JSON.stringify([]));
});
});
describe('isFavoriteDatasource', () => {
it('should return true for favorited datasource', async () => {
mockGetItem.mockResolvedValue(JSON.stringify(['prometheus-uid', 'loki-uid']));
const { result } = renderHook(() => useFavoriteDatasources());
await waitFor(() => {
expect(result.current.favoriteDatasources).toEqual(['prometheus-uid', 'loki-uid']);
});
expect(result.current.isFavoriteDatasource('prometheus-uid')).toBe(true);
expect(result.current.isFavoriteDatasource('loki-uid')).toBe(true);
});
it('should return false for non-favorited datasource', async () => {
mockGetItem.mockResolvedValue(JSON.stringify(['prometheus-uid']));
const { result } = renderHook(() => useFavoriteDatasources());
await waitFor(() => {
expect(result.current.favoriteDatasources).toEqual(['prometheus-uid']);
});
expect(result.current.isFavoriteDatasource('loki-uid')).toBe(false);
expect(result.current.isFavoriteDatasource('elasticsearch-uid')).toBe(false);
});
it('should return false when no favorites exist', async () => {
mockGetItem.mockResolvedValue(null);
const { result } = renderHook(() => useFavoriteDatasources());
await waitFor(() => {
expect(result.current.favoriteDatasources).toEqual([]);
});
expect(result.current.isFavoriteDatasource('prometheus-uid')).toBe(false);
});
});
});

View File

@ -0,0 +1,95 @@
import { useCallback, useEffect, useState } from 'react';
import { DataSourceInstanceSettings } from '@grafana/data';
import { UserStorage } from './userStorage';
const FAVORITE_DATASOURCES_KEY = 'favoriteDatasources';
/**
* A hook for managing favorite data sources using user storage.
* This hook provides functionality to store and retrieve a list of favorite data source UIDs
* using the backend user storage (with localStorage fallback).
*
* @returns An object containing:
* - An array of favorite data source UIDs
* - An array of favorite data source UIDs that were initially loaded from storage
* - A function to add a data source to favorites
* - A function to remove a data source from favorites
* - A function to check if a data source is favorited
* @public
*/
export function useFavoriteDatasources(): {
favoriteDatasources: string[];
initialFavoriteDataSources: string[];
addFavoriteDatasource: (ds: DataSourceInstanceSettings) => void;
removeFavoriteDatasource: (ds: DataSourceInstanceSettings) => void;
isFavoriteDatasource: (dsUid: string) => boolean;
} {
const [userStorage] = useState(() => new UserStorage('grafana-runtime'));
const [favoriteDatasources, setFavoriteDatasources] = useState<string[]>([]);
const [initialFavoriteDataSources, setInitialFavoriteDataSources] = useState<string[]>([]);
// Load favorites from storage on mount
useEffect(() => {
const loadFavorites = async () => {
const stored = await userStorage.getItem(FAVORITE_DATASOURCES_KEY);
if (stored) {
const parsed = JSON.parse(stored);
setFavoriteDatasources(parsed);
setInitialFavoriteDataSources(parsed);
}
};
loadFavorites();
}, [userStorage]);
// Helper function to save favorites to storage
const saveFavorites = useCallback(
async (newFavorites: string[]) => {
await userStorage.setItem(FAVORITE_DATASOURCES_KEY, JSON.stringify(newFavorites));
setFavoriteDatasources(newFavorites);
},
[userStorage]
);
const addFavoriteDatasource = useCallback(
(ds: DataSourceInstanceSettings) => {
if (ds.meta.builtIn) {
// Prevent storing built-in datasources (-- Grafana --, -- Mixed --, -- Dashboard --)
return;
}
if (!favoriteDatasources.includes(ds.uid)) {
const newFavorites = [...favoriteDatasources, ds.uid];
saveFavorites(newFavorites);
}
},
[favoriteDatasources, saveFavorites]
);
const removeFavoriteDatasource = useCallback(
(ds: DataSourceInstanceSettings) => {
const newFavorites = favoriteDatasources.filter((uid) => uid !== ds.uid);
if (newFavorites.length !== favoriteDatasources.length) {
saveFavorites(newFavorites);
}
},
[favoriteDatasources, saveFavorites]
);
const isFavoriteDatasource = useCallback(
(dsUid: string) => {
return favoriteDatasources.includes(dsUid);
},
[favoriteDatasources]
);
return {
favoriteDatasources,
addFavoriteDatasource,
removeFavoriteDatasource,
isFavoriteDatasource,
initialFavoriteDataSources,
};
}

View File

@ -1,16 +1,26 @@
import { css, cx } from '@emotion/css';
import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data';
import { Card, TagList, useTheme2 } from '@grafana/ui';
import { Card, TagList, useTheme2, Icon } from '@grafana/ui';
interface DataSourceCardProps {
ds: DataSourceInstanceSettings;
onClick: () => void;
selected: boolean;
description?: string;
isFavorite?: boolean;
onToggleFavorite?: (ds: DataSourceInstanceSettings) => void;
}
export function DataSourceCard({ ds, onClick, selected, description, ...htmlProps }: DataSourceCardProps) {
export function DataSourceCard({
ds,
onClick,
selected,
description,
isFavorite = false,
onToggleFavorite,
...htmlProps
}: DataSourceCardProps) {
const theme = useTheme2();
const styles = getStyles(theme, ds.meta.builtIn);
@ -26,7 +36,19 @@ export function DataSourceCard({ ds, onClick, selected, description, ...htmlProp
<span className={styles.name}>
{ds.name} {ds.isDefault ? <TagList tags={['default']} /> : null}
</span>
<small className={styles.type}>{description || ds.meta.name}</small>
<div className={styles.rightSection}>
<small className={styles.type}>{description || ds.meta.name}</small>
{onToggleFavorite && !ds.meta.builtIn && (
<Icon
name={isFavorite ? 'favorite' : 'star'}
onClick={(e) => {
e.stopPropagation();
onToggleFavorite(ds);
}}
className={styles.favoriteButton}
/>
)}
</div>
</div>
</Card.Heading>
<Card.Figure className={styles.logo}>
@ -66,6 +88,17 @@ function getStyles(theme: GrafanaTheme2, builtIn = false) {
whiteSpace: 'nowrap',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}),
rightSection: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
minWidth: 0,
flex: 1,
justifyContent: 'flex-end',
overflow: 'hidden',
textOverflow: 'ellipsis',
}),
logo: css({
width: '32px',
@ -92,6 +125,11 @@ function getStyles(theme: GrafanaTheme2, builtIn = false) {
display: 'flex',
alignItems: 'center',
}),
favoriteButton: css({
flexShrink: 0,
pointerEvents: 'auto',
zIndex: 1,
}),
separator: css({
margin: theme.spacing(0, 1),
color: theme.colors.border.weak,

View File

@ -1,12 +1,12 @@
import { css, cx } from '@emotion/css';
import { useRef } from 'react';
import { useCallback, useRef } from 'react';
import * as React from 'react';
import { Observable } from 'rxjs';
import { DataSourceInstanceSettings, DataSourceJsonData, DataSourceRef, GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans } from '@grafana/i18n';
import { getTemplateSrv } from '@grafana/runtime';
import { config, getTemplateSrv, useFavoriteDatasources } from '@grafana/runtime';
import { useStyles2, useTheme2 } from '@grafana/ui';
import { useDatasources, useKeyboardNavigatableList, useRecentlyUsedDataSources } from '../../hooks';
@ -74,6 +74,27 @@ export function DataSourceList(props: DataSourceListProps) {
});
const [recentlyUsedDataSources, pushRecentlyUsedDataSource] = useRecentlyUsedDataSources();
const favoriteDataSourcesHook = config.featureToggles.favoriteDatasources ? useFavoriteDatasources() : null;
const storedFavoriteDataSources = favoriteDataSourcesHook?.initialFavoriteDataSources;
const isFavoriteDatasource = favoriteDataSourcesHook?.isFavoriteDatasource;
const toggleFavoriteDatasource = useCallback(
(ds: DataSourceInstanceSettings) => {
if (!favoriteDataSourcesHook) {
return;
}
const { isFavoriteDatasource, addFavoriteDatasource, removeFavoriteDatasource } = favoriteDataSourcesHook;
if (isFavoriteDatasource(ds.uid)) {
removeFavoriteDatasource(ds);
} else {
addFavoriteDatasource(ds);
}
},
[favoriteDataSourcesHook]
);
const filteredDataSources = props.filter ? dataSources.filter(props.filter) : dataSources;
return (
@ -86,7 +107,14 @@ export function DataSourceList(props: DataSourceListProps) {
<EmptyState className={styles.emptyState} onClickCTA={onClickEmptyStateCTA} />
)}
{filteredDataSources
.sort(getDataSourceCompareFn(current, recentlyUsedDataSources, getDataSourceVariableIDs()))
.sort(
getDataSourceCompareFn(
current,
recentlyUsedDataSources,
getDataSourceVariableIDs(),
storedFavoriteDataSources
)
)
.map((ds) => (
<DataSourceCard
data-testid="data-source-card"
@ -97,6 +125,8 @@ export function DataSourceList(props: DataSourceListProps) {
onChange(ds);
}}
selected={isDataSourceMatch(ds, current)}
isFavorite={isFavoriteDatasource ? isFavoriteDatasource(ds.uid) : undefined}
onToggleFavorite={toggleFavoriteDatasource}
{...(enableKeyboardNavigation ? navigatableProps : {})}
/>
))}

View File

@ -77,6 +77,16 @@ describe('getDataSouceCompareFn', () => {
] as DataSourceInstanceSettings[]);
});
it('sorts favorite datasources first', () => {
dataSources.sort(getDataSourceCompareFn(undefined, [], [], ['c', 'a']));
expect(dataSources).toEqual([
{ uid: 'a', name: 'a', meta: { builtIn: true } },
{ uid: 'c', name: 'c', meta: { builtIn: false } },
{ uid: 'b', name: 'b', meta: { builtIn: false } },
{ uid: 'D', name: 'D', meta: { builtIn: false } },
] as DataSourceInstanceSettings[]);
});
it('sorts variables before other datasources', () => {
dataSources.sort(getDataSourceCompareFn(undefined, [], ['c', 'b']));
expect(dataSources).toEqual([
@ -87,7 +97,7 @@ describe('getDataSouceCompareFn', () => {
] as DataSourceInstanceSettings[]);
});
it('sorts datasources current -> recently used -> variables -> others -> built in', () => {
it('sorts datasources current -> favorite -> recently used -> variables -> others -> built in', () => {
const dataSources = [
{ uid: 'a', name: 'a', meta: { builtIn: true } },
{ uid: 'b', name: 'b', meta: { builtIn: false } },
@ -97,13 +107,13 @@ describe('getDataSouceCompareFn', () => {
{ uid: 'f', name: 'f', meta: { builtIn: false } },
] as DataSourceInstanceSettings[];
dataSources.sort(getDataSourceCompareFn('c', ['b', 'e'], ['d']));
dataSources.sort(getDataSourceCompareFn('c', ['b', 'e'], ['d'], ['f']));
expect(dataSources).toEqual([
{ uid: 'c', name: 'c', meta: { builtIn: false } },
{ uid: 'f', name: 'f', meta: { builtIn: false } },
{ uid: 'e', name: 'e', meta: { builtIn: false } },
{ uid: 'b', name: 'b', meta: { builtIn: false } },
{ uid: 'D', name: 'D', meta: { builtIn: false } },
{ uid: 'f', name: 'f', meta: { builtIn: false } },
{ uid: 'a', name: 'a', meta: { builtIn: true } },
] as DataSourceInstanceSettings[]);
});

View File

@ -48,7 +48,8 @@ export function dataSourceLabel(
export function getDataSourceCompareFn(
current: DataSourceRef | DataSourceInstanceSettings | string | null | undefined,
recentlyUsedDataSources: string[],
dataSourceVariablesIDs: string[]
dataSourceVariablesIDs: string[],
favoriteDatasources: string[] = []
) {
const cmpDataSources = (a: DataSourceInstanceSettings, b: DataSourceInstanceSettings) => {
const nameA = a.name.toUpperCase();
@ -61,7 +62,19 @@ export function getDataSourceCompareFn(
return 1;
}
// Sort recently used data sources by latest used, but after current.
// Sort favorite data sources after current but before recently used.
const aIsFavorite = favoriteDatasources.includes(a.uid);
const bIsFavorite = favoriteDatasources.includes(b.uid);
if (aIsFavorite && !bIsFavorite) {
return -1;
} else if (bIsFavorite && !aIsFavorite) {
return 1;
} else if (aIsFavorite && bIsFavorite) {
// If both are favorites, sort alphabetically by name.
return nameA < nameB ? -1 : 1;
}
// Sort recently used data sources by latest used, but after current and favorites.
const aIndex = recentlyUsedDataSources.indexOf(a.uid);
const bIndex = recentlyUsedDataSources.indexOf(b.uid);
if (aIndex > -1 && aIndex > bIndex) {