Filter data sources by favorite setting (#109593)

This commit is contained in:
Andres Martinez Gotor
2025-08-14 13:39:09 +02:00
committed by GitHub
parent 9453cedb51
commit d08ea58243
8 changed files with 258 additions and 63 deletions

View File

@ -57,7 +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 { useFavoriteDatasources, type FavoriteDatasources } from './utils/useFavoriteDatasources';
export { FolderPicker, setFolderPicker } from './components/FolderPicker'; export { FolderPicker, setFolderPicker } from './components/FolderPicker';
export { export {
type CorrelationsService, type CorrelationsService,

View File

@ -17,6 +17,16 @@ jest.mock('./userStorage', () => {
}; };
}); });
jest.mock('../config', () => {
return {
config: {
featureToggles: {
favoriteDatasources: true,
},
},
};
});
describe('useFavoriteDatasources', () => { describe('useFavoriteDatasources', () => {
// Test data helpers // Test data helpers
const pluginMetaInfo: PluginMetaInfo = { const pluginMetaInfo: PluginMetaInfo = {

View File

@ -2,16 +2,28 @@ import { useCallback, useEffect, useState } from 'react';
import { DataSourceInstanceSettings } from '@grafana/data'; import { DataSourceInstanceSettings } from '@grafana/data';
import { config } from '../config';
import { UserStorage } from './userStorage'; import { UserStorage } from './userStorage';
const FAVORITE_DATASOURCES_KEY = 'favoriteDatasources'; 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. * 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 * This hook provides functionality to store and retrieve a list of favorite data source UIDs
* using the backend user storage (with localStorage fallback). * using the backend user storage (with localStorage fallback).
* *
* @returns An object containing: * @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
* - An array of favorite data source UIDs that were initially loaded from storage * - 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 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 * - A function to check if a data source is favorited
* @public * @public
*/ */
export function useFavoriteDatasources(): { export function useFavoriteDatasources(): FavoriteDatasources {
favoriteDatasources: string[]; if (!config.featureToggles.favoriteDatasources) {
initialFavoriteDataSources: string[]; return {
addFavoriteDatasource: (ds: DataSourceInstanceSettings) => void; enabled: false,
removeFavoriteDatasource: (ds: DataSourceInstanceSettings) => void; favoriteDatasources: [],
isFavoriteDatasource: (dsUid: string) => boolean; initialFavoriteDataSources: [],
} { addFavoriteDatasource: () => {},
removeFavoriteDatasource: () => {},
isFavoriteDatasource: () => false,
};
}
const [userStorage] = useState(() => new UserStorage('grafana-runtime')); const [userStorage] = useState(() => new UserStorage('grafana-runtime'));
const [favoriteDatasources, setFavoriteDatasources] = useState<string[]>([]); const [favoriteDatasources, setFavoriteDatasources] = useState<string[]>([]);
const [initialFavoriteDataSources, setInitialFavoriteDataSources] = useState<string[]>([]); const [initialFavoriteDataSources, setInitialFavoriteDataSources] = useState<string[]>([]);
@ -86,6 +103,7 @@ export function useFavoriteDatasources(): {
); );
return { return {
enabled: true,
favoriteDatasources, favoriteDatasources,
addFavoriteDatasource, addFavoriteDatasource,
removeFavoriteDatasource, removeFavoriteDatasource,

View File

@ -1,10 +1,16 @@
import { PureComponent } from 'react'; import { css } from '@emotion/css';
import { SelectableValue } from '@grafana/data'; import { SelectableValue, GrafanaTheme2 } from '@grafana/data';
import { LinkButton, FilterInput, InlineField } from '@grafana/ui'; import { LinkButton, FilterInput, InlineField, Checkbox, useStyles2 } from '@grafana/ui';
import { SortPicker } from '../Select/SortPicker'; import { SortPicker } from '../Select/SortPicker';
export type FilterCheckbox = {
onChange: (value: boolean) => void;
value: boolean;
label?: string;
};
export interface Props { export interface Props {
searchQuery: string; searchQuery: string;
setSearchQuery: (value: string) => void; setSearchQuery: (value: string) => void;
@ -16,41 +22,59 @@ export interface Props {
value?: string; value?: string;
getSortOptions?: () => Promise<SelectableValue[]>; getSortOptions?: () => Promise<SelectableValue[]>;
}; };
filterCheckbox?: FilterCheckbox;
} }
export default class PageActionBar extends PureComponent<Props> { export default function PageActionBar({
render() { searchQuery,
const { linkButton,
searchQuery, setSearchQuery,
linkButton, target,
setSearchQuery, placeholder = 'Search by name or type',
target, sortPicker,
placeholder = 'Search by name or type', filterCheckbox,
sortPicker, }: Props) {
} = this.props; const styles = useStyles2(getStyles);
const linkProps: Omit<Parameters<typeof LinkButton>[0], 'children'> = { const linkProps: Omit<Parameters<typeof LinkButton>[0], 'children'> = {
href: linkButton?.href, href: linkButton?.href,
disabled: linkButton?.disabled, disabled: linkButton?.disabled,
}; };
if (target) { if (target) {
linkProps.target = target; linkProps.target = target;
}
return (
<div className="page-action-bar">
<InlineField grow>
<FilterInput value={searchQuery} onChange={setSearchQuery} placeholder={placeholder} />
</InlineField>
{sortPicker && (
<SortPicker
onChange={sortPicker.onChange}
value={sortPicker.value}
getSortOptions={sortPicker.getSortOptions}
/>
)}
{linkButton && <LinkButton {...linkProps}>{linkButton.title}</LinkButton>}
</div>
);
} }
return (
<div className={styles.container}>
<InlineField grow>
<FilterInput value={searchQuery} onChange={setSearchQuery} placeholder={placeholder} />
</InlineField>
{filterCheckbox && (
<Checkbox
label={filterCheckbox.label}
value={filterCheckbox.value}
onChange={(event) => filterCheckbox.onChange(event.currentTarget.checked)}
/>
)}
{sortPicker && (
<SortPicker
onChange={sortPicker.onChange}
value={sortPicker.value}
getSortOptions={sortPicker.getSortOptions}
/>
)}
{linkButton && <LinkButton {...linkProps}>{linkButton.title}</LinkButton>}
</div>
);
} }
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(2),
marginBottom: theme.spacing(2),
}),
};
};

View File

@ -1,24 +1,69 @@
import { screen } from '@testing-library/react'; import { screen } from '@testing-library/react';
import { render } from 'test/test-utils'; import { render } from 'test/test-utils';
import { config } from '@grafana/runtime';
import { getMockDataSources } from '../mocks/dataSourcesMocks'; import { getMockDataSources } from '../mocks/dataSourcesMocks';
import { DataSourcesListView } from './DataSourcesList'; import { DataSourcesListView, ViewProps } from './DataSourcesList';
const setup = () => { // Mock the useFavoriteDatasources hook
return render( const mockIsFavoriteDatasource = jest.fn();
<DataSourcesListView const mockUseFavoriteDatasources = jest.fn(() => ({
dataSources={getMockDataSources(3)} enabled: true,
dataSourcesCount={3} isFavoriteDatasource: mockIsFavoriteDatasource,
isLoading={false} favoriteDatasources: [],
hasCreateRights={true} initialFavoriteDataSources: [],
hasWriteRights={true} addFavoriteDatasource: jest.fn(),
hasExploreRights={true} 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<ViewProps> = {}) => {
const defaultProps = {
dataSources: getMockDataSources(3),
dataSourcesCount: 3,
isLoading: false,
hasCreateRights: true,
hasWriteRights: true,
hasExploreRights: true,
showFavoritesOnly: false,
handleFavoritesCheckboxChange: jest.fn(),
...overrides,
};
return render(<DataSourcesListView {...defaultProps} />);
}; };
describe('<DataSourcesList>', () => { describe('<DataSourcesList>', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseQueryParams.mockReturnValue([{ starred: undefined }, mockUpdateQueryParams]);
});
it('should render action bar', async () => { it('should render action bar', async () => {
setup(); setup();
@ -41,4 +86,56 @@ describe('<DataSourcesList>', () => {
expect(await screen.findByRole('heading', { name: 'dataSource-0' })).toBeInTheDocument(); expect(await screen.findByRole('heading', { name: 'dataSource-0' })).toBeInTheDocument();
expect(await screen.findByRole('link', { 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();
});
});
}); });

View File

@ -1,12 +1,13 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { useEffect } from 'react'; import { useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom-v5-compat'; import { useLocation } from 'react-router-dom-v5-compat';
import { DataSourceSettings, GrafanaTheme2 } from '@grafana/data'; import { DataSourceSettings, GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n'; 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 { EmptyState, LinkButton, TextLink, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/core'; import { contextSrv } from 'app/core/core';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { AccessControlAction } from 'app/types/accessControl'; import { AccessControlAction } from 'app/types/accessControl';
import { StoreState, useSelector } from 'app/types/store'; import { StoreState, useSelector } from 'app/types/store';
@ -20,6 +21,12 @@ import { DataSourcesListHeader } from './DataSourcesListHeader';
export function DataSourcesList() { export function DataSourcesList() {
const { isLoading } = useLoadDataSources(); 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 dataSources = useSelector((state) => getDataSources(state.dataSources));
const dataSourcesCount = useSelector(({ dataSources }: StoreState) => getDataSourcesCount(dataSources)); const dataSourcesCount = useSelector(({ dataSources }: StoreState) => getDataSourcesCount(dataSources));
@ -35,6 +42,9 @@ export function DataSourcesList() {
hasCreateRights={hasCreateRights} hasCreateRights={hasCreateRights}
hasWriteRights={hasWriteRights} hasWriteRights={hasWriteRights}
hasExploreRights={hasExploreRights} hasExploreRights={hasExploreRights}
showFavoritesOnly={showFavoritesOnly}
handleFavoritesCheckboxChange={handleFavoritesCheckboxChange}
favoriteDataSources={favoriteDataSources}
/> />
); );
} }
@ -46,18 +56,40 @@ export type ViewProps = {
hasCreateRights: boolean; hasCreateRights: boolean;
hasWriteRights: boolean; hasWriteRights: boolean;
hasExploreRights: boolean; hasExploreRights: boolean;
showFavoritesOnly?: boolean;
handleFavoritesCheckboxChange?: (value: boolean) => void;
favoriteDataSources?: FavoriteDatasources;
}; };
export function DataSourcesListView({ export function DataSourcesListView({
dataSources, dataSources: allDataSources,
dataSourcesCount, dataSourcesCount,
isLoading, isLoading,
hasCreateRights, hasCreateRights,
hasWriteRights, hasWriteRights,
hasExploreRights, hasExploreRights,
showFavoritesOnly,
handleFavoritesCheckboxChange,
favoriteDataSources,
}: ViewProps) { }: ViewProps) {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const location = useLocation(); 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(() => { useEffect(() => {
trackDataSourcesListViewed({ trackDataSourcesListViewed({
@ -111,7 +143,7 @@ export function DataSourcesListView({
return ( return (
<> <>
{/* List Header */} {/* List Header */}
<DataSourcesListHeader /> <DataSourcesListHeader filterCheckbox={favoritesCheckbox} />
{/* List */} {/* List */}
{dataSources.length === 0 && !isLoading ? ( {dataSources.length === 0 && !isLoading ? (

View File

@ -2,7 +2,7 @@ import { debounce } from 'lodash';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { SelectableValue } from '@grafana/data'; 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 { StoreState, useSelector, useDispatch } from 'app/types/store';
import { setDataSourcesSearchQuery, setIsSortAscending } from '../state/reducers'; import { setDataSourcesSearchQuery, setIsSortAscending } from '../state/reducers';
@ -20,8 +20,13 @@ const sortOptions = [
{ label: 'Sort by ZA', value: descendingSortValue }, { label: 'Sort by ZA', value: descendingSortValue },
]; ];
export function DataSourcesListHeader() { export interface DataSourcesListHeaderProps {
filterCheckbox?: FilterCheckbox;
}
export function DataSourcesListHeader({ filterCheckbox }: DataSourcesListHeaderProps) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const debouncedTrackSearch = useMemo( const debouncedTrackSearch = useMemo(
() => () =>
debounce((q) => { debounce((q) => {
@ -54,6 +59,12 @@ export function DataSourcesListHeader() {
}; };
return ( return (
<PageActionBar searchQuery={searchQuery} setSearchQuery={setSearchQuery} key="action-bar" sortPicker={sortPicker} /> <PageActionBar
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
key="action-bar"
sortPicker={sortPicker}
filterCheckbox={filterCheckbox}
/>
); );
} }

View File

@ -6583,6 +6583,9 @@
"hosted-graphite-prometheus-and-loki": "Hosted Graphite, Prometheus, and Loki" "hosted-graphite-prometheus-and-loki": "Hosted Graphite, Prometheus, and Loki"
} }
}, },
"list": {
"starred": "Starred"
},
"new-data-source-view": { "new-data-source-view": {
"cancel": "Cancel", "cancel": "Cancel",
"placeholder-filter-by-name-or-type": "Filter by name or type" "placeholder-filter-by-name-or-type": "Filter by name or type"