diff --git a/packages/grafana-runtime/src/utils/useFavoriteDatasources.ts b/packages/grafana-runtime/src/utils/useFavoriteDatasources.ts index 8f891da8915..bbb47da67e7 100644 --- a/packages/grafana-runtime/src/utils/useFavoriteDatasources.ts +++ b/packages/grafana-runtime/src/utils/useFavoriteDatasources.ts @@ -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([]); const [initialFavoriteDataSources, setInitialFavoriteDataSources] = useState([]); + 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, diff --git a/public/app/features/datasources/components/DataSourcesList.test.tsx b/public/app/features/datasources/components/DataSourcesList.test.tsx index 33fc05ace84..fd7cdc37f9a 100644 --- a/public/app/features/datasources/components/DataSourcesList.test.tsx +++ b/public/app/features/datasources/components/DataSourcesList.test.tsx @@ -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: [], diff --git a/public/app/features/datasources/components/EditDataSourceActions.test.tsx b/public/app/features/datasources/components/EditDataSourceActions.test.tsx index 0bd748c900e..7f39ade59ab 100644 --- a/public/app/features/datasources/components/EditDataSourceActions.test.tsx +++ b/public/app/features/datasources/components/EditDataSourceActions.test.tsx @@ -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; +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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + 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(); + + 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(); + + 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(); + + const favoriteButton = screen.getByTestId('favorite-button'); + expect(favoriteButton).toBeDisabled(); + }); + }); }); diff --git a/public/app/features/datasources/components/EditDataSourceActions.tsx b/public/app/features/datasources/components/EditDataSourceActions.tsx index bc231a4c951..fe24dae23f6 100644 --- a/public/app/features/datasources/components/EditDataSourceActions.tsx +++ b/public/app/features/datasources/components/EditDataSourceActions.tsx @@ -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 && ( + + 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 ( <> + {hasExploreRights && ( <> {!hasActions ? ( diff --git a/public/app/features/datasources/components/picker/DataSourceList.tsx b/public/app/features/datasources/components/picker/DataSourceList.tsx index a4c60c64055..139cff29df4 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 { 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 : {})} /> ))} diff --git a/public/app/features/datasources/hooks.ts b/public/app/features/datasources/hooks.ts index 73c356c4ff8..886a606f90f 100644 --- a/public/app/features/datasources/hooks.ts +++ b/public/app/features/datasources/hooks.ts @@ -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); diff --git a/public/app/features/datasources/state/selectors.ts b/public/app/features/datasources/state/selectors.ts index 53be4a748dd..74ad48924cd 100644 --- a/public/app/features/datasources/state/selectors.ts +++ b/public/app/features/datasources/state/selectors.ts @@ -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; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 53d3ce78608..7423a3f2437 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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"