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 { 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,

View File

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

View File

@ -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<string[]>([]);
const [initialFavoriteDataSources, setInitialFavoriteDataSources] = useState<string[]>([]);
@ -86,6 +103,7 @@ export function useFavoriteDatasources(): {
);
return {
enabled: true,
favoriteDatasources,
addFavoriteDatasource,
removeFavoriteDatasource,

View File

@ -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<SelectableValue[]>;
};
filterCheckbox?: FilterCheckbox;
}
export default class PageActionBar extends PureComponent<Props> {
render() {
const {
searchQuery,
linkButton,
setSearchQuery,
target,
placeholder = 'Search by name or type',
sortPicker,
} = this.props;
const linkProps: Omit<Parameters<typeof LinkButton>[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<Parameters<typeof LinkButton>[0], 'children'> = {
href: linkButton?.href,
disabled: linkButton?.disabled,
};
if (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>
);
if (target) {
linkProps.target = target;
}
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 { 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(
<DataSourcesListView
dataSources={getMockDataSources(3)}
dataSourcesCount={3}
isLoading={false}
hasCreateRights={true}
hasWriteRights={true}
hasExploreRights={true}
/>
);
// 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<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>', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseQueryParams.mockReturnValue([{ starred: undefined }, mockUpdateQueryParams]);
});
it('should render action bar', async () => {
setup();
@ -41,4 +86,56 @@ describe('<DataSourcesList>', () => {
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();
});
});
});

View File

@ -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 */}
<DataSourcesListHeader />
<DataSourcesListHeader filterCheckbox={favoritesCheckbox} />
{/* List */}
{dataSources.length === 0 && !isLoading ? (

View File

@ -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 ZA', 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 (
<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"
}
},
"list": {
"starred": "Starred"
},
"new-data-source-view": {
"cancel": "Cancel",
"placeholder-filter-by-name-or-type": "Filter by name or type"