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
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,
@@ -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);
});
});
});
@@ -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,
};
}