Add favorite button to EditDatasource page (#109609)

This commit is contained in:
Andres Martinez Gotor 2025-08-19 13:40:19 +02:00 committed by GitHub
parent 06115478f9
commit f5b9d93610
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 251 additions and 38 deletions

View File

@ -10,6 +10,7 @@ const FAVORITE_DATASOURCES_KEY = 'favoriteDatasources';
export type FavoriteDatasources = {
enabled: boolean;
isLoading: boolean;
favoriteDatasources: string[];
initialFavoriteDataSources: string[];
addFavoriteDatasource: (ds: DataSourceInstanceSettings) => void;
@ -35,6 +36,7 @@ export function useFavoriteDatasources(): FavoriteDatasources {
if (!config.featureToggles.favoriteDatasources) {
return {
enabled: false,
isLoading: false,
favoriteDatasources: [],
initialFavoriteDataSources: [],
addFavoriteDatasource: () => {},
@ -46,16 +48,19 @@ export function useFavoriteDatasources(): FavoriteDatasources {
const [userStorage] = useState(() => new UserStorage('grafana-runtime'));
const [favoriteDatasources, setFavoriteDatasources] = useState<string[]>([]);
const [initialFavoriteDataSources, setInitialFavoriteDataSources] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
// Load favorites from storage on mount
useEffect(() => {
const loadFavorites = async () => {
setIsLoading(true);
const stored = await userStorage.getItem(FAVORITE_DATASOURCES_KEY);
if (stored) {
const parsed = JSON.parse(stored);
setFavoriteDatasources(parsed);
setInitialFavoriteDataSources(parsed);
}
setIsLoading(false);
};
loadFavorites();
@ -64,8 +69,10 @@ export function useFavoriteDatasources(): FavoriteDatasources {
// Helper function to save favorites to storage
const saveFavorites = useCallback(
async (newFavorites: string[]) => {
setIsLoading(true);
await userStorage.setItem(FAVORITE_DATASOURCES_KEY, JSON.stringify(newFavorites));
setFavoriteDatasources(newFavorites);
setIsLoading(false);
},
[userStorage]
);
@ -104,6 +111,7 @@ export function useFavoriteDatasources(): FavoriteDatasources {
return {
enabled: true,
isLoading,
favoriteDatasources,
addFavoriteDatasource,
removeFavoriteDatasource,

View File

@ -11,6 +11,7 @@ import { DataSourcesListView, ViewProps } from './DataSourcesList';
const mockIsFavoriteDatasource = jest.fn();
const mockUseFavoriteDatasources = jest.fn(() => ({
enabled: true,
isLoading: false,
isFavoriteDatasource: mockIsFavoriteDatasource,
favoriteDatasources: [],
initialFavoriteDataSources: [],

View File

@ -1,7 +1,7 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { PluginExtensionTypes, IconName } from '@grafana/data';
import { setPluginLinksHook } from '@grafana/runtime';
import { setPluginLinksHook, config, getDataSourceSrv } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import { getMockDataSource } from '../mocks/dataSourcesMocks';
@ -16,11 +16,48 @@ jest.mock('../utils', () => ({
),
}));
// Mock @grafana/runtime
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
config: {
featureToggles: {
favoriteDatasources: false,
},
},
getDataSourceSrv: jest.fn(),
useFavoriteDatasources: jest.fn(),
}));
// Set default plugin links hook
setPluginLinksHook(() => ({ links: [], isLoading: false }));
// Mock contextSrv
const mockContextSrv = contextSrv as jest.Mocked<typeof contextSrv>;
const mockContextSrv = jest.mocked(contextSrv);
// Mock getDataSourceSrv and favorite hooks
const mockGetDataSourceSrv = jest.mocked(getDataSourceSrv);
const mockUseFavoriteDatasources = jest.mocked(require('@grafana/runtime').useFavoriteDatasources);
// Create mock datasource instance
const mockDataSourceInstance = {
uid: 'test-uid',
name: 'Test Prometheus',
type: 'prometheus',
meta: {
name: 'Prometheus',
builtIn: false,
},
};
// Mock favorite datasources hook return value
const mockFavoriteHook = {
enabled: true,
favoriteDatasources: [],
initialFavoriteDataSources: [],
isFavoriteDatasource: jest.fn(),
addFavoriteDatasource: jest.fn(),
removeFavoriteDatasource: jest.fn(),
};
// Helper function to create mock plugin link extensions with all required properties
const createMockPluginLink = (
@ -63,6 +100,24 @@ describe('EditDataSourceActions', () => {
setPluginLinksHook(() => ({ links: [], isLoading: false }));
// Default contextSrv mock - user has explore rights
mockContextSrv.hasAccessToExplore.mockReturnValue(true);
// Setup default mocks for favorite functionality
mockGetDataSourceSrv.mockReturnValue({
getInstanceSettings: jest.fn().mockReturnValue(mockDataSourceInstance),
get: jest.fn(),
getList: jest.fn(),
reload: jest.fn(),
registerRuntimeDataSource: jest.fn(),
});
// Reset favorite hook mocks
mockFavoriteHook.isFavoriteDatasource.mockReturnValue(false);
mockFavoriteHook.addFavoriteDatasource.mockClear();
mockFavoriteHook.removeFavoriteDatasource.mockClear();
// Default: feature toggle disabled, so no favorite hook
mockUseFavoriteDatasources.mockReturnValue({ ...mockFavoriteHook, enabled: false });
config.featureToggles.favoriteDatasources = false;
});
describe('Core Actions', () => {
@ -236,7 +291,7 @@ describe('EditDataSourceActions', () => {
title: 'Test Action',
description: 'Test description',
path: '/test-path',
icon: 'external-link-alt' as IconName,
icon: 'external-link-alt',
pluginId: 'grafana-lokiexplore-app',
}),
];
@ -318,4 +373,123 @@ describe('EditDataSourceActions', () => {
expect(screen.getByText('Explore data')).toBeInTheDocument();
});
});
describe('Favorite Actions', () => {
it('should not render favorite button when feature toggle is disabled', () => {
config.featureToggles.favoriteDatasources = false;
mockUseFavoriteDatasources.mockReturnValue({ ...mockFavoriteHook, enabled: false });
render(<EditDataSourceActions uid="test-uid" />);
// Should not find any favorite button
expect(screen.queryByTestId('favorite-button')).not.toBeInTheDocument();
// Core actions should still be rendered
expect(screen.getByText('Explore data')).toBeInTheDocument();
expect(screen.getByText('Build a dashboard')).toBeInTheDocument();
});
it('should not render favorite button for built-in datasources', () => {
config.featureToggles.favoriteDatasources = true;
mockUseFavoriteDatasources.mockReturnValue(mockFavoriteHook);
// Mock built-in datasource
const builtInDataSource = { ...mockDataSourceInstance, meta: { ...mockDataSourceInstance.meta, builtIn: true } };
mockGetDataSourceSrv.mockReturnValue({
getInstanceSettings: jest.fn().mockReturnValue(builtInDataSource),
get: jest.fn(),
getList: jest.fn(),
reload: jest.fn(),
registerRuntimeDataSource: jest.fn(),
});
render(<EditDataSourceActions uid="test-uid" />);
// Should not find any favorite button for built-in datasources
expect(screen.queryByTestId('favorite-button')).not.toBeInTheDocument();
});
it('should render favorite button when feature toggle is enabled and datasource is not built-in', () => {
config.featureToggles.favoriteDatasources = true;
mockUseFavoriteDatasources.mockReturnValue(mockFavoriteHook);
mockFavoriteHook.isFavoriteDatasource.mockReturnValue(false);
render(<EditDataSourceActions uid="test-uid" />);
// Should find star icon for non-favorite datasource
const favoriteButton = screen.getByTestId('favorite-button');
expect(favoriteButton).toBeInTheDocument();
// Should have correct aria-label for non-favorite datasource
expect(favoriteButton).toHaveAttribute('aria-label', 'Add to favorites');
});
it('should show favorite icon when datasource is favorited', () => {
config.featureToggles.favoriteDatasources = true;
mockUseFavoriteDatasources.mockReturnValue(mockFavoriteHook);
mockFavoriteHook.isFavoriteDatasource.mockReturnValue(true);
render(<EditDataSourceActions uid="test-uid" />);
// Should find favorite button for favorited datasource
const favoriteButton = screen.getByTestId('favorite-button');
expect(favoriteButton).toBeInTheDocument();
// Should have correct aria-label for favorited datasource
expect(favoriteButton).toHaveAttribute('aria-label', 'Remove from favorites');
});
it('should add datasource to favorites when star button is clicked', () => {
config.featureToggles.favoriteDatasources = true;
mockUseFavoriteDatasources.mockReturnValue(mockFavoriteHook);
mockFavoriteHook.isFavoriteDatasource.mockReturnValue(false);
render(<EditDataSourceActions uid="test-uid" />);
const favoriteButton = screen.getByTestId('favorite-button');
fireEvent.click(favoriteButton);
expect(mockFavoriteHook.addFavoriteDatasource).toHaveBeenCalledTimes(1);
expect(mockFavoriteHook.addFavoriteDatasource).toHaveBeenCalledWith(mockDataSourceInstance);
expect(mockFavoriteHook.removeFavoriteDatasource).not.toHaveBeenCalled();
});
it('should remove datasource from favorites when favorite button is clicked', () => {
config.featureToggles.favoriteDatasources = true;
mockUseFavoriteDatasources.mockReturnValue(mockFavoriteHook);
mockFavoriteHook.isFavoriteDatasource.mockReturnValue(true);
render(<EditDataSourceActions uid="test-uid" />);
const favoriteButton = screen.getByTestId('favorite-button');
fireEvent.click(favoriteButton);
expect(mockFavoriteHook.removeFavoriteDatasource).toHaveBeenCalledTimes(1);
expect(mockFavoriteHook.removeFavoriteDatasource).toHaveBeenCalledWith(mockDataSourceInstance);
expect(mockFavoriteHook.addFavoriteDatasource).not.toHaveBeenCalled();
});
it('should call isFavoriteDatasource with correct uid', () => {
config.featureToggles.favoriteDatasources = true;
mockUseFavoriteDatasources.mockReturnValue(mockFavoriteHook);
mockFavoriteHook.isFavoriteDatasource.mockReturnValue(false);
render(<EditDataSourceActions uid="test-uid" />);
expect(mockFavoriteHook.isFavoriteDatasource).toHaveBeenCalledWith('test-uid');
});
it('should disable favorite button when isLoading is true', () => {
config.featureToggles.favoriteDatasources = true;
mockUseFavoriteDatasources.mockReturnValue({
...mockFavoriteHook,
isLoading: true,
});
mockFavoriteHook.isFavoriteDatasource.mockReturnValue(false);
render(<EditDataSourceActions uid="test-uid" />);
const favoriteButton = screen.getByTestId('favorite-button');
expect(favoriteButton).toBeDisabled();
});
});
});

View File

@ -1,7 +1,7 @@
import { PluginExtensionPoints } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config, usePluginLinks } from '@grafana/runtime';
import { Button, Dropdown, LinkButton, Menu, Icon } from '@grafana/ui';
import { config, usePluginLinks, useFavoriteDatasources, getDataSourceSrv } from '@grafana/runtime';
import { Button, Dropdown, LinkButton, Menu, Icon, IconButton } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { ALLOWED_DATASOURCE_EXTENSION_PLUGINS } from '../constants';
@ -13,6 +13,36 @@ interface Props {
uid: string;
}
const FavoriteButton = ({ uid }: { uid: string }) => {
const favoriteDataSources = useFavoriteDatasources();
const dataSourceInstance = getDataSourceSrv().getInstanceSettings(uid);
const isFavorite = dataSourceInstance ? favoriteDataSources.isFavoriteDatasource(dataSourceInstance.uid) : false;
return (
favoriteDataSources.enabled &&
dataSourceInstance &&
!dataSourceInstance.meta.builtIn && (
<IconButton
key={`favorite-${isFavorite ? 'favorite-mono' : 'star-default'}`}
name={isFavorite ? 'favorite' : 'star'}
iconType={isFavorite ? 'mono' : 'default'}
onClick={() =>
isFavorite
? favoriteDataSources.removeFavoriteDatasource(dataSourceInstance)
: favoriteDataSources.addFavoriteDatasource(dataSourceInstance)
}
disabled={favoriteDataSources.isLoading}
tooltip={
isFavorite
? t('datasources.edit-data-source-actions.remove-favorite', 'Remove from favorites')
: t('datasources.edit-data-source-actions.add-favorite', 'Add to favorites')
}
data-testid="favorite-button"
/>
)
);
};
export function EditDataSourceActions({ uid }: Props) {
const dataSource = useDataSource(uid);
const hasExploreRights = contextSrv.hasAccessToExplore();
@ -62,6 +92,7 @@ export function EditDataSourceActions({ uid }: Props) {
return (
<>
<FavoriteButton uid={uid} />
{hasExploreRights && (
<>
{!hasActions ? (

View File

@ -1,12 +1,12 @@
import { css, cx } from '@emotion/css';
import { useCallback, useRef } from 'react';
import { 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 { config, getTemplateSrv, useFavoriteDatasources } from '@grafana/runtime';
import { getTemplateSrv, useFavoriteDatasources } from '@grafana/runtime';
import { useStyles2, useTheme2 } from '@grafana/ui';
import { useDatasources, useKeyboardNavigatableList, useRecentlyUsedDataSources } from '../../hooks';
@ -58,9 +58,8 @@ export function DataSourceList(props: DataSourceListProps) {
const styles = getStyles(theme, selectedItemCssSelector);
const { className, current, onChange, enableKeyboardNavigation, onClickEmptyStateCTA } = props;
const dataSources =
props.dataSources ||
useDatasources({
const dataSources = useDatasources(
{
alerting: props.alerting,
annotations: props.annotations,
dashboard: props.dashboard,
@ -71,29 +70,12 @@ export function DataSourceList(props: DataSourceListProps) {
tracing: props.tracing,
type: props.type,
variables: props.variables,
});
},
props.dataSources
);
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 favoriteDataSources = useFavoriteDatasources();
const filteredDataSources = props.filter ? dataSources.filter(props.filter) : dataSources;
@ -112,7 +94,7 @@ export function DataSourceList(props: DataSourceListProps) {
current,
recentlyUsedDataSources,
getDataSourceVariableIDs(),
storedFavoriteDataSources
favoriteDataSources.enabled ? favoriteDataSources.initialFavoriteDataSources : undefined
)
)
.map((ds) => (
@ -125,8 +107,16 @@ export function DataSourceList(props: DataSourceListProps) {
onChange(ds);
}}
selected={isDataSourceMatch(ds, current)}
isFavorite={isFavoriteDatasource ? isFavoriteDatasource(ds.uid) : undefined}
onToggleFavorite={toggleFavoriteDatasource}
isFavorite={favoriteDataSources.isFavoriteDatasource(ds.uid)}
onToggleFavorite={
favoriteDataSources.enabled
? () => {
favoriteDataSources.isFavoriteDatasource(ds.uid)
? favoriteDataSources.removeFavoriteDatasource(ds)
: favoriteDataSources.addFavoriteDatasource(ds);
}
: undefined
}
{...(enableKeyboardNavigation ? navigatableProps : {})}
/>
))}

View File

@ -42,7 +42,10 @@ export function useRecentlyUsedDataSources(): [string[], (ds: DataSourceInstance
return [value, pushRecentlyUsedDataSource];
}
export function useDatasources(filters: GetDataSourceListFilters) {
export function useDatasources(filters: GetDataSourceListFilters, datasources?: DataSourceInstanceSettings[]) {
if (datasources) {
return datasources;
}
const dataSourceSrv = getDataSourceSrv();
const dataSources = dataSourceSrv.getList(filters);

View File

@ -3,6 +3,10 @@ import memoizeOne from 'memoize-one';
import { DataSourcePluginMeta, DataSourceSettings, UrlQueryValue } from '@grafana/data';
import { DataSourcesState } from 'app/types/datasources';
// Use consistent references for empty objects to prevent infinite re-renders
const EMPTY_DATASOURCE = {} as DataSourceSettings;
const EMPTY_DATASOURCE_META = {} as DataSourcePluginMeta;
export const getDataSources = memoizeOne((state: DataSourcesState) => {
const regex = new RegExp(state.searchQuery, 'i');
@ -27,7 +31,7 @@ export const getDataSource = (state: DataSourcesState, dataSourceId: UrlQueryVal
if (state.dataSource.uid === dataSourceId) {
return state.dataSource;
}
return {} as DataSourceSettings;
return EMPTY_DATASOURCE;
};
export const getDataSourceMeta = (state: DataSourcesState, type: string): DataSourcePluginMeta => {
@ -35,7 +39,7 @@ export const getDataSourceMeta = (state: DataSourcesState, type: string): DataSo
return state.dataSourceMeta;
}
return {} as DataSourcePluginMeta;
return EMPTY_DATASOURCE_META;
};
export const getDataSourcesSearchQuery = (state: DataSourcesState) => state.searchQuery;

View File

@ -6552,9 +6552,11 @@
"explore": "Explore"
},
"edit-data-source-actions": {
"add-favorite": "Add to favorites",
"build-a-dashboard": "Build a dashboard",
"explore-data": "Explore data",
"open-in-explore": "Open in Explore View"
"open-in-explore": "Open in Explore View",
"remove-favorite": "Remove from favorites"
},
"error-details-link": {
"aria-label-more-details-about-the-error": "More details about the error"