mirror of
https://github.com/grafana/grafana.git
synced 2025-09-20 03:07:28 +08:00
DatasourcePicker: Favorite datasources (#109404)
This commit is contained in:

committed by
GitHub

parent
842fd44e83
commit
2e4b10e81c
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@ -608,6 +608,7 @@
|
|||||||
/packages/grafana-runtime/src/utils/returnToPrevious.ts @grafana/grafana-search-navigate-organise
|
/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/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/userStorage* @grafana/plugins-platform-frontend @grafana/grafana-frontend-platform
|
||||||
|
/packages/grafana-runtime/src/utils/useFavoriteDatasources* @grafana/plugins-platform-frontend
|
||||||
|
|
||||||
# @grafana/schema
|
# @grafana/schema
|
||||||
/packages/grafana-schema/ @grafana/grafana-app-platform-squad
|
/packages/grafana-schema/ @grafana/grafana-app-platform-squad
|
||||||
|
@ -57,6 +57,7 @@ export { hasPermission, hasPermissionInMetadata, hasAllPermissions, hasAnyPermis
|
|||||||
export { QueryEditorWithMigration } from './components/QueryEditorWithMigration';
|
export { QueryEditorWithMigration } from './components/QueryEditorWithMigration';
|
||||||
export { type MigrationHandler, isMigrationHandler, migrateQuery, migrateRequest } from './utils/migrationHandler';
|
export { type MigrationHandler, isMigrationHandler, migrateQuery, migrateRequest } from './utils/migrationHandler';
|
||||||
export { usePluginUserStorage } from './utils/userStorage';
|
export { usePluginUserStorage } from './utils/userStorage';
|
||||||
|
export { useFavoriteDatasources } from './utils/useFavoriteDatasources';
|
||||||
export { FolderPicker, setFolderPicker } from './components/FolderPicker';
|
export { FolderPicker, setFolderPicker } from './components/FolderPicker';
|
||||||
export {
|
export {
|
||||||
type CorrelationsService,
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
95
packages/grafana-runtime/src/utils/useFavoriteDatasources.ts
Normal file
95
packages/grafana-runtime/src/utils/useFavoriteDatasources.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
@ -1,16 +1,26 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
|
|
||||||
import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data';
|
import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data';
|
||||||
import { Card, TagList, useTheme2 } from '@grafana/ui';
|
import { Card, TagList, useTheme2, Icon } from '@grafana/ui';
|
||||||
|
|
||||||
interface DataSourceCardProps {
|
interface DataSourceCardProps {
|
||||||
ds: DataSourceInstanceSettings;
|
ds: DataSourceInstanceSettings;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
description?: string;
|
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 theme = useTheme2();
|
||||||
const styles = getStyles(theme, ds.meta.builtIn);
|
const styles = getStyles(theme, ds.meta.builtIn);
|
||||||
|
|
||||||
@ -26,7 +36,19 @@ export function DataSourceCard({ ds, onClick, selected, description, ...htmlProp
|
|||||||
<span className={styles.name}>
|
<span className={styles.name}>
|
||||||
{ds.name} {ds.isDefault ? <TagList tags={['default']} /> : null}
|
{ds.name} {ds.isDefault ? <TagList tags={['default']} /> : null}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</Card.Heading>
|
</Card.Heading>
|
||||||
<Card.Figure className={styles.logo}>
|
<Card.Figure className={styles.logo}>
|
||||||
@ -66,6 +88,17 @@ function getStyles(theme: GrafanaTheme2, builtIn = false) {
|
|||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
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({
|
logo: css({
|
||||||
width: '32px',
|
width: '32px',
|
||||||
@ -92,6 +125,11 @@ function getStyles(theme: GrafanaTheme2, builtIn = false) {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
}),
|
}),
|
||||||
|
favoriteButton: css({
|
||||||
|
flexShrink: 0,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 1,
|
||||||
|
}),
|
||||||
separator: css({
|
separator: css({
|
||||||
margin: theme.spacing(0, 1),
|
margin: theme.spacing(0, 1),
|
||||||
color: theme.colors.border.weak,
|
color: theme.colors.border.weak,
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import { useRef } from 'react';
|
import { useCallback, useRef } from 'react';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
import { DataSourceInstanceSettings, DataSourceJsonData, DataSourceRef, GrafanaTheme2 } from '@grafana/data';
|
import { DataSourceInstanceSettings, DataSourceJsonData, DataSourceRef, GrafanaTheme2 } from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { Trans } from '@grafana/i18n';
|
import { Trans } from '@grafana/i18n';
|
||||||
import { getTemplateSrv } from '@grafana/runtime';
|
import { config, getTemplateSrv, useFavoriteDatasources } from '@grafana/runtime';
|
||||||
import { useStyles2, useTheme2 } from '@grafana/ui';
|
import { useStyles2, useTheme2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { useDatasources, useKeyboardNavigatableList, useRecentlyUsedDataSources } from '../../hooks';
|
import { useDatasources, useKeyboardNavigatableList, useRecentlyUsedDataSources } from '../../hooks';
|
||||||
@ -74,6 +74,27 @@ export function DataSourceList(props: DataSourceListProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [recentlyUsedDataSources, pushRecentlyUsedDataSource] = useRecentlyUsedDataSources();
|
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;
|
const filteredDataSources = props.filter ? dataSources.filter(props.filter) : dataSources;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -86,7 +107,14 @@ export function DataSourceList(props: DataSourceListProps) {
|
|||||||
<EmptyState className={styles.emptyState} onClickCTA={onClickEmptyStateCTA} />
|
<EmptyState className={styles.emptyState} onClickCTA={onClickEmptyStateCTA} />
|
||||||
)}
|
)}
|
||||||
{filteredDataSources
|
{filteredDataSources
|
||||||
.sort(getDataSourceCompareFn(current, recentlyUsedDataSources, getDataSourceVariableIDs()))
|
.sort(
|
||||||
|
getDataSourceCompareFn(
|
||||||
|
current,
|
||||||
|
recentlyUsedDataSources,
|
||||||
|
getDataSourceVariableIDs(),
|
||||||
|
storedFavoriteDataSources
|
||||||
|
)
|
||||||
|
)
|
||||||
.map((ds) => (
|
.map((ds) => (
|
||||||
<DataSourceCard
|
<DataSourceCard
|
||||||
data-testid="data-source-card"
|
data-testid="data-source-card"
|
||||||
@ -97,6 +125,8 @@ export function DataSourceList(props: DataSourceListProps) {
|
|||||||
onChange(ds);
|
onChange(ds);
|
||||||
}}
|
}}
|
||||||
selected={isDataSourceMatch(ds, current)}
|
selected={isDataSourceMatch(ds, current)}
|
||||||
|
isFavorite={isFavoriteDatasource ? isFavoriteDatasource(ds.uid) : undefined}
|
||||||
|
onToggleFavorite={toggleFavoriteDatasource}
|
||||||
{...(enableKeyboardNavigation ? navigatableProps : {})}
|
{...(enableKeyboardNavigation ? navigatableProps : {})}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
@ -77,6 +77,16 @@ describe('getDataSouceCompareFn', () => {
|
|||||||
] as DataSourceInstanceSettings[]);
|
] 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', () => {
|
it('sorts variables before other datasources', () => {
|
||||||
dataSources.sort(getDataSourceCompareFn(undefined, [], ['c', 'b']));
|
dataSources.sort(getDataSourceCompareFn(undefined, [], ['c', 'b']));
|
||||||
expect(dataSources).toEqual([
|
expect(dataSources).toEqual([
|
||||||
@ -87,7 +97,7 @@ describe('getDataSouceCompareFn', () => {
|
|||||||
] as DataSourceInstanceSettings[]);
|
] 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 = [
|
const dataSources = [
|
||||||
{ uid: 'a', name: 'a', meta: { builtIn: true } },
|
{ uid: 'a', name: 'a', meta: { builtIn: true } },
|
||||||
{ uid: 'b', name: 'b', meta: { builtIn: false } },
|
{ uid: 'b', name: 'b', meta: { builtIn: false } },
|
||||||
@ -97,13 +107,13 @@ describe('getDataSouceCompareFn', () => {
|
|||||||
{ uid: 'f', name: 'f', meta: { builtIn: false } },
|
{ uid: 'f', name: 'f', meta: { builtIn: false } },
|
||||||
] as DataSourceInstanceSettings[];
|
] as DataSourceInstanceSettings[];
|
||||||
|
|
||||||
dataSources.sort(getDataSourceCompareFn('c', ['b', 'e'], ['d']));
|
dataSources.sort(getDataSourceCompareFn('c', ['b', 'e'], ['d'], ['f']));
|
||||||
expect(dataSources).toEqual([
|
expect(dataSources).toEqual([
|
||||||
{ uid: 'c', name: 'c', meta: { builtIn: false } },
|
{ uid: 'c', name: 'c', meta: { builtIn: false } },
|
||||||
|
{ uid: 'f', name: 'f', meta: { builtIn: false } },
|
||||||
{ uid: 'e', name: 'e', meta: { builtIn: false } },
|
{ uid: 'e', name: 'e', meta: { builtIn: false } },
|
||||||
{ uid: 'b', name: 'b', meta: { builtIn: false } },
|
{ uid: 'b', name: 'b', meta: { builtIn: false } },
|
||||||
{ uid: 'D', name: 'D', meta: { builtIn: false } },
|
{ uid: 'D', name: 'D', meta: { builtIn: false } },
|
||||||
{ uid: 'f', name: 'f', meta: { builtIn: false } },
|
|
||||||
{ uid: 'a', name: 'a', meta: { builtIn: true } },
|
{ uid: 'a', name: 'a', meta: { builtIn: true } },
|
||||||
] as DataSourceInstanceSettings[]);
|
] as DataSourceInstanceSettings[]);
|
||||||
});
|
});
|
||||||
|
@ -48,7 +48,8 @@ export function dataSourceLabel(
|
|||||||
export function getDataSourceCompareFn(
|
export function getDataSourceCompareFn(
|
||||||
current: DataSourceRef | DataSourceInstanceSettings | string | null | undefined,
|
current: DataSourceRef | DataSourceInstanceSettings | string | null | undefined,
|
||||||
recentlyUsedDataSources: string[],
|
recentlyUsedDataSources: string[],
|
||||||
dataSourceVariablesIDs: string[]
|
dataSourceVariablesIDs: string[],
|
||||||
|
favoriteDatasources: string[] = []
|
||||||
) {
|
) {
|
||||||
const cmpDataSources = (a: DataSourceInstanceSettings, b: DataSourceInstanceSettings) => {
|
const cmpDataSources = (a: DataSourceInstanceSettings, b: DataSourceInstanceSettings) => {
|
||||||
const nameA = a.name.toUpperCase();
|
const nameA = a.name.toUpperCase();
|
||||||
@ -61,7 +62,19 @@ export function getDataSourceCompareFn(
|
|||||||
return 1;
|
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 aIndex = recentlyUsedDataSources.indexOf(a.uid);
|
||||||
const bIndex = recentlyUsedDataSources.indexOf(b.uid);
|
const bIndex = recentlyUsedDataSources.indexOf(b.uid);
|
||||||
if (aIndex > -1 && aIndex > bIndex) {
|
if (aIndex > -1 && aIndex > bIndex) {
|
||||||
|
Reference in New Issue
Block a user