From 2e4b10e81cc2aeed0f5f52f7729762e46693c84b Mon Sep 17 00:00:00 2001 From: Andres Martinez Gotor Date: Wed, 13 Aug 2025 15:34:19 +0200 Subject: [PATCH] DatasourcePicker: Favorite datasources (#109404) --- .github/CODEOWNERS | 1 + packages/grafana-runtime/src/index.ts | 1 + .../src/utils/useFavoriteDatasources.test.ts | 271 ++++++++++++++++++ .../src/utils/useFavoriteDatasources.ts | 95 ++++++ .../components/picker/DataSourceCard.tsx | 44 ++- .../components/picker/DataSourceList.tsx | 36 ++- .../components/picker/utils.test.ts | 16 +- .../datasources/components/picker/utils.ts | 17 +- 8 files changed, 470 insertions(+), 11 deletions(-) create mode 100644 packages/grafana-runtime/src/utils/useFavoriteDatasources.test.ts create mode 100644 packages/grafana-runtime/src/utils/useFavoriteDatasources.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2f1e67251d0..61aaa071c43 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/packages/grafana-runtime/src/index.ts b/packages/grafana-runtime/src/index.ts index 2250f0db26f..1678d74cdbf 100644 --- a/packages/grafana-runtime/src/index.ts +++ b/packages/grafana-runtime/src/index.ts @@ -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, diff --git a/packages/grafana-runtime/src/utils/useFavoriteDatasources.test.ts b/packages/grafana-runtime/src/utils/useFavoriteDatasources.test.ts new file mode 100644 index 00000000000..0e24c64a751 --- /dev/null +++ b/packages/grafana-runtime/src/utils/useFavoriteDatasources.test.ts @@ -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); + }); + }); +}); diff --git a/packages/grafana-runtime/src/utils/useFavoriteDatasources.ts b/packages/grafana-runtime/src/utils/useFavoriteDatasources.ts new file mode 100644 index 00000000000..31bd7c9262e --- /dev/null +++ b/packages/grafana-runtime/src/utils/useFavoriteDatasources.ts @@ -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([]); + const [initialFavoriteDataSources, setInitialFavoriteDataSources] = useState([]); + + // 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, + }; +} diff --git a/public/app/features/datasources/components/picker/DataSourceCard.tsx b/public/app/features/datasources/components/picker/DataSourceCard.tsx index 84403ae4298..c1a5f4d3345 100644 --- a/public/app/features/datasources/components/picker/DataSourceCard.tsx +++ b/public/app/features/datasources/components/picker/DataSourceCard.tsx @@ -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 {ds.name} {ds.isDefault ? : null} - {description || ds.meta.name} +
+ {description || ds.meta.name} + {onToggleFavorite && !ds.meta.builtIn && ( + { + e.stopPropagation(); + onToggleFavorite(ds); + }} + className={styles.favoriteButton} + /> + )} +
@@ -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, diff --git a/public/app/features/datasources/components/picker/DataSourceList.tsx b/public/app/features/datasources/components/picker/DataSourceList.tsx index 067c64fb250..a4c60c64055 100644 --- a/public/app/features/datasources/components/picker/DataSourceList.tsx +++ b/public/app/features/datasources/components/picker/DataSourceList.tsx @@ -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) { )} {filteredDataSources - .sort(getDataSourceCompareFn(current, recentlyUsedDataSources, getDataSourceVariableIDs())) + .sort( + getDataSourceCompareFn( + current, + recentlyUsedDataSources, + getDataSourceVariableIDs(), + storedFavoriteDataSources + ) + ) .map((ds) => ( ))} diff --git a/public/app/features/datasources/components/picker/utils.test.ts b/public/app/features/datasources/components/picker/utils.test.ts index 079fc69f9c0..2e80dc03f34 100644 --- a/public/app/features/datasources/components/picker/utils.test.ts +++ b/public/app/features/datasources/components/picker/utils.test.ts @@ -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[]); }); diff --git a/public/app/features/datasources/components/picker/utils.ts b/public/app/features/datasources/components/picker/utils.ts index ff987c26f5b..a3a49a453a5 100644 --- a/public/app/features/datasources/components/picker/utils.ts +++ b/public/app/features/datasources/components/picker/utils.ts @@ -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) {