diff --git a/packages/grafana-runtime/src/index.ts b/packages/grafana-runtime/src/index.ts index 1678d74cdbf..c292d33ce31 100644 --- a/packages/grafana-runtime/src/index.ts +++ b/packages/grafana-runtime/src/index.ts @@ -57,7 +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 { useFavoriteDatasources, type FavoriteDatasources } from './utils/useFavoriteDatasources'; export { FolderPicker, setFolderPicker } from './components/FolderPicker'; export { type CorrelationsService, diff --git a/packages/grafana-runtime/src/utils/useFavoriteDatasources.test.ts b/packages/grafana-runtime/src/utils/useFavoriteDatasources.test.ts index 0e24c64a751..9f589cf8846 100644 --- a/packages/grafana-runtime/src/utils/useFavoriteDatasources.test.ts +++ b/packages/grafana-runtime/src/utils/useFavoriteDatasources.test.ts @@ -17,6 +17,16 @@ jest.mock('./userStorage', () => { }; }); +jest.mock('../config', () => { + return { + config: { + featureToggles: { + favoriteDatasources: true, + }, + }, + }; +}); + describe('useFavoriteDatasources', () => { // Test data helpers const pluginMetaInfo: PluginMetaInfo = { diff --git a/packages/grafana-runtime/src/utils/useFavoriteDatasources.ts b/packages/grafana-runtime/src/utils/useFavoriteDatasources.ts index 31bd7c9262e..8f891da8915 100644 --- a/packages/grafana-runtime/src/utils/useFavoriteDatasources.ts +++ b/packages/grafana-runtime/src/utils/useFavoriteDatasources.ts @@ -2,16 +2,28 @@ import { useCallback, useEffect, useState } from 'react'; import { DataSourceInstanceSettings } from '@grafana/data'; +import { config } from '../config'; + import { UserStorage } from './userStorage'; const FAVORITE_DATASOURCES_KEY = 'favoriteDatasources'; +export type FavoriteDatasources = { + enabled: boolean; + favoriteDatasources: string[]; + initialFavoriteDataSources: string[]; + addFavoriteDatasource: (ds: DataSourceInstanceSettings) => void; + removeFavoriteDatasource: (ds: DataSourceInstanceSettings) => void; + isFavoriteDatasource: (dsUid: string) => boolean; +}; + /** * 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: + * - A boolean indicating if the feature is enabled * - 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 @@ -19,13 +31,18 @@ const FAVORITE_DATASOURCES_KEY = 'favoriteDatasources'; * - 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; -} { +export function useFavoriteDatasources(): FavoriteDatasources { + if (!config.featureToggles.favoriteDatasources) { + return { + enabled: false, + favoriteDatasources: [], + initialFavoriteDataSources: [], + addFavoriteDatasource: () => {}, + removeFavoriteDatasource: () => {}, + isFavoriteDatasource: () => false, + }; + } + const [userStorage] = useState(() => new UserStorage('grafana-runtime')); const [favoriteDatasources, setFavoriteDatasources] = useState([]); const [initialFavoriteDataSources, setInitialFavoriteDataSources] = useState([]); @@ -86,6 +103,7 @@ export function useFavoriteDatasources(): { ); return { + enabled: true, favoriteDatasources, addFavoriteDatasource, removeFavoriteDatasource, diff --git a/public/app/core/components/PageActionBar/PageActionBar.tsx b/public/app/core/components/PageActionBar/PageActionBar.tsx index a9a9686fddf..7a1ce445f69 100644 --- a/public/app/core/components/PageActionBar/PageActionBar.tsx +++ b/public/app/core/components/PageActionBar/PageActionBar.tsx @@ -1,10 +1,16 @@ -import { PureComponent } from 'react'; +import { css } from '@emotion/css'; -import { SelectableValue } from '@grafana/data'; -import { LinkButton, FilterInput, InlineField } from '@grafana/ui'; +import { SelectableValue, GrafanaTheme2 } from '@grafana/data'; +import { LinkButton, FilterInput, InlineField, Checkbox, useStyles2 } from '@grafana/ui'; import { SortPicker } from '../Select/SortPicker'; +export type FilterCheckbox = { + onChange: (value: boolean) => void; + value: boolean; + label?: string; +}; + export interface Props { searchQuery: string; setSearchQuery: (value: string) => void; @@ -16,41 +22,59 @@ export interface Props { value?: string; getSortOptions?: () => Promise; }; + filterCheckbox?: FilterCheckbox; } -export default class PageActionBar extends PureComponent { - render() { - const { - searchQuery, - linkButton, - setSearchQuery, - target, - placeholder = 'Search by name or type', - sortPicker, - } = this.props; - const linkProps: Omit[0], 'children'> = { - href: linkButton?.href, - disabled: linkButton?.disabled, - }; +export default function PageActionBar({ + searchQuery, + linkButton, + setSearchQuery, + target, + placeholder = 'Search by name or type', + sortPicker, + filterCheckbox, +}: Props) { + const styles = useStyles2(getStyles); + const linkProps: Omit[0], 'children'> = { + href: linkButton?.href, + disabled: linkButton?.disabled, + }; - if (target) { - linkProps.target = target; - } - - return ( -
- - - - {sortPicker && ( - - )} - {linkButton && {linkButton.title}} -
- ); + if (target) { + linkProps.target = target; } + + return ( +
+ + + + {filterCheckbox && ( + filterCheckbox.onChange(event.currentTarget.checked)} + /> + )} + {sortPicker && ( + + )} + {linkButton && {linkButton.title}} +
+ ); } + +const getStyles = (theme: GrafanaTheme2) => { + return { + container: css({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(2), + marginBottom: theme.spacing(2), + }), + }; +}; diff --git a/public/app/features/datasources/components/DataSourcesList.test.tsx b/public/app/features/datasources/components/DataSourcesList.test.tsx index d495695d0a7..33fc05ace84 100644 --- a/public/app/features/datasources/components/DataSourcesList.test.tsx +++ b/public/app/features/datasources/components/DataSourcesList.test.tsx @@ -1,24 +1,69 @@ import { screen } from '@testing-library/react'; import { render } from 'test/test-utils'; +import { config } from '@grafana/runtime'; + import { getMockDataSources } from '../mocks/dataSourcesMocks'; -import { DataSourcesListView } from './DataSourcesList'; +import { DataSourcesListView, ViewProps } from './DataSourcesList'; -const setup = () => { - return render( - - ); +// Mock the useFavoriteDatasources hook +const mockIsFavoriteDatasource = jest.fn(); +const mockUseFavoriteDatasources = jest.fn(() => ({ + enabled: true, + isFavoriteDatasource: mockIsFavoriteDatasource, + favoriteDatasources: [], + initialFavoriteDataSources: [], + addFavoriteDatasource: jest.fn(), + removeFavoriteDatasource: jest.fn(), + toggleFavoriteDatasource: jest.fn(), +})); + +jest.mock('@grafana/runtime', () => { + const runtime = jest.requireActual('@grafana/runtime'); + return { + ...runtime, + useFavoriteDatasources: () => mockUseFavoriteDatasources(), + config: { + ...runtime.config, + featureToggles: { + ...runtime.config.featureToggles, + favoriteDatasources: true, + }, + }, + }; +}); + +// Mock the useQueryParams hook +const mockUpdateQueryParams = jest.fn(); +const mockUseQueryParams = jest.fn(() => [{ starred: undefined }, mockUpdateQueryParams]); + +jest.mock('app/core/hooks/useQueryParams', () => ({ + useQueryParams: () => mockUseQueryParams(), +})); + +const setup = (overrides: Partial = {}) => { + const defaultProps = { + dataSources: getMockDataSources(3), + dataSourcesCount: 3, + isLoading: false, + hasCreateRights: true, + hasWriteRights: true, + hasExploreRights: true, + showFavoritesOnly: false, + handleFavoritesCheckboxChange: jest.fn(), + ...overrides, + }; + + return render(); }; describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryParams.mockReturnValue([{ starred: undefined }, mockUpdateQueryParams]); + }); + it('should render action bar', async () => { setup(); @@ -41,4 +86,56 @@ describe('', () => { expect(await screen.findByRole('heading', { name: 'dataSource-0' })).toBeInTheDocument(); expect(await screen.findByRole('link', { name: 'dataSource-0' })).toBeInTheDocument(); }); + + describe('Favorites functionality', () => { + beforeEach(() => { + config.featureToggles.favoriteDatasources = true; + }); + + it('should render favorites checkbox when feature toggle is enabled', async () => { + setup({ favoriteDataSources: mockUseFavoriteDatasources() }); + + const checkbox = await screen.findByRole('checkbox', { name: 'Starred' }); + expect(checkbox).toBeInTheDocument(); + expect(checkbox).not.toBeChecked(); + }); + + it('should not render favorites checkbox when feature toggle is disabled', async () => { + config.featureToggles.favoriteDatasources = false; + + setup(); + + expect(await screen.findByPlaceholderText('Search by name or type')).toBeInTheDocument(); + expect(screen.queryByRole('checkbox', { name: 'Starred' })).not.toBeInTheDocument(); + }); + + it('should render favorites checkbox as checked when value is true', async () => { + setup({ showFavoritesOnly: true, favoriteDataSources: mockUseFavoriteDatasources() }); + + const checkbox = await screen.findByRole('checkbox', { name: 'Starred' }); + expect(checkbox).toBeChecked(); + }); + + it('should filter datasources to show only favorites when showFavoritesOnly is true', async () => { + // Mock the isFavoriteDatasource function to return true for specific datasources + const mockIsFavoriteDatasource = jest.fn((uid: string) => uid === 'uid-0' || uid === 'uid-2'); + + setup({ + showFavoritesOnly: true, + favoriteDataSources: { + ...mockUseFavoriteDatasources(), + isFavoriteDatasource: mockIsFavoriteDatasource, + }, + }); + + // Should only show 2 datasources (uid-0 and uid-2) instead of all 3 + const listItems = await screen.findAllByRole('listitem'); + expect(listItems).toHaveLength(2); + + // Verify the correct datasources are shown + expect(screen.getByRole('heading', { name: 'dataSource-0' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'dataSource-2' })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'dataSource-1' })).not.toBeInTheDocument(); + }); + }); }); diff --git a/public/app/features/datasources/components/DataSourcesList.tsx b/public/app/features/datasources/components/DataSourcesList.tsx index b4337792ebc..94e8135a872 100644 --- a/public/app/features/datasources/components/DataSourcesList.tsx +++ b/public/app/features/datasources/components/DataSourcesList.tsx @@ -1,12 +1,13 @@ import { css } from '@emotion/css'; -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { useLocation } from 'react-router-dom-v5-compat'; import { DataSourceSettings, GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { config } from '@grafana/runtime'; +import { config, useFavoriteDatasources, FavoriteDatasources } from '@grafana/runtime'; import { EmptyState, LinkButton, TextLink, useStyles2 } from '@grafana/ui'; import { contextSrv } from 'app/core/core'; +import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { AccessControlAction } from 'app/types/accessControl'; import { StoreState, useSelector } from 'app/types/store'; @@ -20,6 +21,12 @@ import { DataSourcesListHeader } from './DataSourcesListHeader'; export function DataSourcesList() { const { isLoading } = useLoadDataSources(); + const favoriteDataSources = useFavoriteDatasources(); + const [queryParams, updateQueryParams] = useQueryParams(); + const showFavoritesOnly = !!queryParams.starred; + const handleFavoritesCheckboxChange = (value: boolean) => { + updateQueryParams({ starred: value ? 'true' : undefined }); + }; const dataSources = useSelector((state) => getDataSources(state.dataSources)); const dataSourcesCount = useSelector(({ dataSources }: StoreState) => getDataSourcesCount(dataSources)); @@ -35,6 +42,9 @@ export function DataSourcesList() { hasCreateRights={hasCreateRights} hasWriteRights={hasWriteRights} hasExploreRights={hasExploreRights} + showFavoritesOnly={showFavoritesOnly} + handleFavoritesCheckboxChange={handleFavoritesCheckboxChange} + favoriteDataSources={favoriteDataSources} /> ); } @@ -46,18 +56,40 @@ export type ViewProps = { hasCreateRights: boolean; hasWriteRights: boolean; hasExploreRights: boolean; + showFavoritesOnly?: boolean; + handleFavoritesCheckboxChange?: (value: boolean) => void; + favoriteDataSources?: FavoriteDatasources; }; export function DataSourcesListView({ - dataSources, + dataSources: allDataSources, dataSourcesCount, isLoading, hasCreateRights, hasWriteRights, hasExploreRights, + showFavoritesOnly, + handleFavoritesCheckboxChange, + favoriteDataSources, }: ViewProps) { const styles = useStyles2(getStyles); const location = useLocation(); + const favoritesCheckbox = + favoriteDataSources?.enabled && handleFavoritesCheckboxChange && showFavoritesOnly !== undefined + ? { + onChange: handleFavoritesCheckboxChange, + value: showFavoritesOnly, + label: t('datasources.list.starred', 'Starred'), + } + : undefined; + + // Filter data sources based on favorites when enabled + const dataSources = useMemo(() => { + if (!showFavoritesOnly || !favoriteDataSources?.enabled) { + return allDataSources; + } + return allDataSources.filter((dataSource) => favoriteDataSources?.isFavoriteDatasource(dataSource.uid)); + }, [allDataSources, showFavoritesOnly, favoriteDataSources]); useEffect(() => { trackDataSourcesListViewed({ @@ -111,7 +143,7 @@ export function DataSourcesListView({ return ( <> {/* List Header */} - + {/* List */} {dataSources.length === 0 && !isLoading ? ( diff --git a/public/app/features/datasources/components/DataSourcesListHeader.tsx b/public/app/features/datasources/components/DataSourcesListHeader.tsx index 26db52a7a33..8735163dec4 100644 --- a/public/app/features/datasources/components/DataSourcesListHeader.tsx +++ b/public/app/features/datasources/components/DataSourcesListHeader.tsx @@ -2,7 +2,7 @@ import { debounce } from 'lodash'; import { useCallback, useMemo } from 'react'; import { SelectableValue } from '@grafana/data'; -import PageActionBar from 'app/core/components/PageActionBar/PageActionBar'; +import PageActionBar, { FilterCheckbox } from 'app/core/components/PageActionBar/PageActionBar'; import { StoreState, useSelector, useDispatch } from 'app/types/store'; import { setDataSourcesSearchQuery, setIsSortAscending } from '../state/reducers'; @@ -20,8 +20,13 @@ const sortOptions = [ { label: 'Sort by Z–A', value: descendingSortValue }, ]; -export function DataSourcesListHeader() { +export interface DataSourcesListHeaderProps { + filterCheckbox?: FilterCheckbox; +} + +export function DataSourcesListHeader({ filterCheckbox }: DataSourcesListHeaderProps) { const dispatch = useDispatch(); + const debouncedTrackSearch = useMemo( () => debounce((q) => { @@ -54,6 +59,12 @@ export function DataSourcesListHeader() { }; return ( - + ); } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index a6114f22e7d..c38a2f7a770 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -6583,6 +6583,9 @@ "hosted-graphite-prometheus-and-loki": "Hosted Graphite, Prometheus, and Loki" } }, + "list": { + "starred": "Starred" + }, "new-data-source-view": { "cancel": "Cancel", "placeholder-filter-by-name-or-type": "Filter by name or type"