mirror of
https://github.com/grafana/grafana.git
synced 2025-09-19 02:12:52 +08:00
Filter data sources by favorite setting (#109593)
This commit is contained in:

committed by
GitHub

parent
9453cedb51
commit
d08ea58243
@ -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,
|
||||
|
@ -17,6 +17,16 @@ jest.mock('./userStorage', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../config', () => {
|
||||
return {
|
||||
config: {
|
||||
featureToggles: {
|
||||
favoriteDatasources: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('useFavoriteDatasources', () => {
|
||||
// Test data helpers
|
||||
const pluginMetaInfo: PluginMetaInfo = {
|
||||
|
@ -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,
|
||||
|
@ -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),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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 ? (
|
||||
|
@ -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 (
|
||||
<PageActionBar searchQuery={searchQuery} setSearchQuery={setSearchQuery} key="action-bar" sortPicker={sortPicker} />
|
||||
<PageActionBar
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
key="action-bar"
|
||||
sortPicker={sortPicker}
|
||||
filterCheckbox={filterCheckbox}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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"
|
||||
|
Reference in New Issue
Block a user