DatasourcePicker: Favorite datasources (#109404)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user