Dashboards: Migrate DashList panel to use grafanaSearcher (#111274)

This commit is contained in:
Tom Ratcliffe 2025-09-24 10:35:40 +01:00 committed by GitHub
parent 54a347463e
commit 053920b8b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 423 additions and 136 deletions

1
.github/CODEOWNERS vendored
View File

@ -952,6 +952,7 @@ playwright.storybook.config.ts @grafana/grafana-frontend-platform
/public/app/features/variables/ @grafana/dashboards-squad /public/app/features/variables/ @grafana/dashboards-squad
/public/app/features/preferences/ @grafana/grafana-frontend-platform /public/app/features/preferences/ @grafana/grafana-frontend-platform
/public/app/features/bookmarks/ @grafana/grafana-search-navigate-organise /public/app/features/bookmarks/ @grafana/grafana-search-navigate-organise
/public/app/plugins/panel/* @grafana/dataviz-squad
/public/app/plugins/panel/alertlist/ @grafana/alerting-frontend /public/app/plugins/panel/alertlist/ @grafana/alerting-frontend
/public/app/plugins/panel/annolist/ @grafana/dashboards-squad /public/app/plugins/panel/annolist/ @grafana/dashboards-squad
/public/app/plugins/panel/barchart/ @grafana/dataviz-squad /public/app/plugins/panel/barchart/ @grafana/dataviz-squad

View File

@ -4476,11 +4476,6 @@
"count": 2 "count": 2
} }
}, },
"public/app/plugins/panel/dashlist/DashList.tsx": {
"no-restricted-syntax": {
"count": 2
}
},
"public/app/plugins/panel/debug/CursorView.tsx": { "public/app/plugins/panel/debug/CursorView.tsx": {
"@typescript-eslint/consistent-type-assertions": { "@typescript-eslint/consistent-type-assertions": {
"count": 1 "count": 1

View File

@ -26,7 +26,7 @@ const commonTestIgnores = [
'**/__mocks__/**', '**/__mocks__/**',
'**/mocks/**/*.{ts,tsx}', '**/mocks/**/*.{ts,tsx}',
'**/public/test/**', '**/public/test/**',
'**/mocks.{ts,tsx}', '**/{mocks,test-utils}.{ts,tsx}',
'**/*.mock.{ts,tsx}', '**/*.mock.{ts,tsx}',
'**/{test-helpers,testHelpers}.{ts,tsx}', '**/{test-helpers,testHelpers}.{ts,tsx}',
'**/{spec,test-helpers}/**/*.{ts,tsx}', '**/{spec,test-helpers}/**/*.{ts,tsx}',

View File

@ -3,6 +3,7 @@ import { HttpHandler } from 'msw';
import folderHandlers from './api/folders/handlers'; import folderHandlers from './api/folders/handlers';
import searchHandlers from './api/search/handlers'; import searchHandlers from './api/search/handlers';
import teamsHandlers from './api/teams/handlers'; import teamsHandlers from './api/teams/handlers';
import userHandlers from './api/user/handlers';
import appPlatformDashboardv0alpha1Handlers from './apis/dashboard.grafana.app/v0alpha1/handlers'; import appPlatformDashboardv0alpha1Handlers from './apis/dashboard.grafana.app/v0alpha1/handlers';
import appPlatformFolderv1beta1Handlers from './apis/folder.grafana.app/v1beta1/handlers'; import appPlatformFolderv1beta1Handlers from './apis/folder.grafana.app/v1beta1/handlers';
import appPlatformIamv0alpha1Handlers from './apis/iam.grafana.app/v0alpha1/handlers'; import appPlatformIamv0alpha1Handlers from './apis/iam.grafana.app/v0alpha1/handlers';
@ -12,6 +13,7 @@ const allHandlers: HttpHandler[] = [
...teamsHandlers, ...teamsHandlers,
...folderHandlers, ...folderHandlers,
...searchHandlers, ...searchHandlers,
...userHandlers,
// App platform handlers // App platform handlers
...appPlatformDashboardv0alpha1Handlers, ...appPlatformDashboardv0alpha1Handlers,

View File

@ -2,6 +2,7 @@ import { Chance } from 'chance';
import { HttpResponse, http } from 'msw'; import { HttpResponse, http } from 'msw';
import { wellFormedTree } from '../../../fixtures/folders'; import { wellFormedTree } from '../../../fixtures/folders';
import { mockStarredDashboards } from '../user/handlers';
import { SORT_OPTIONS } from './constants'; import { SORT_OPTIONS } from './constants';
@ -22,9 +23,19 @@ const getLegacySearchHandler = () =>
const typeFilter = new URL(request.url).searchParams.get('type') || null; const typeFilter = new URL(request.url).searchParams.get('type') || null;
// Workaround for the fixture kind being 'dashboard' instead of 'dash-db' // Workaround for the fixture kind being 'dashboard' instead of 'dash-db'
const mappedTypeFilter = typeFilter === 'dash-db' ? 'dashboard' : typeFilter; const mappedTypeFilter = typeFilter === 'dash-db' ? 'dashboard' : typeFilter;
const starredFilter = new URL(request.url).searchParams.get('starred') || null;
const response = mockTree const response = mockTree
.filter((filterItem) => { .filter((filterItem) => {
const filters: FilterArray = []; const filters: FilterArray = [
// Filter UI items out of fixtures as... they're UI items 🤷
({ item }) => item.kind !== 'ui',
];
if (starredFilter) {
filters.push(({ item }) => mockStarredDashboards.includes(item.uid));
}
if (folderFilter && folderFilter !== 'general') { if (folderFilter && folderFilter !== 'general') {
filters.push( filters.push(
({ item }) => (item.kind === 'folder' || item.kind === 'dashboard') && item.parentUID === folderFilter ({ item }) => (item.kind === 'folder' || item.kind === 'dashboard') && item.parentUID === folderFilter

View File

@ -0,0 +1,26 @@
import { HttpResponse, http } from 'msw';
import { wellFormedTree } from '../../../fixtures/folders';
const [_, { folderA_dashbdD, dashbdD }] = wellFormedTree();
export const mockStarredDashboards = [dashbdD.item.uid, folderA_dashbdD.item.uid];
const getStarsHandler = () =>
http.get('/api/user/stars', async () => {
return HttpResponse.json(mockStarredDashboards);
});
const deleteDashboardStarHandler = () =>
http.delete('/api/user/stars/dashboard/uid/:uid', async () => {
return HttpResponse.json({ message: 'Dashboard unstarred' });
});
const addDashboardStarHandler = () =>
http.post('/api/user/stars/dashboard/uid/:uid', async () => {
return HttpResponse.json({ message: 'Dashboard starred!' });
});
const handlers = [getStarsHandler(), deleteDashboardStarHandler(), addDashboardStarHandler()];
export default handlers;

View File

@ -12,16 +12,31 @@ const typeMap: Record<string, string> = {
dashboard: 'dashboards', dashboard: 'dashboards',
}; };
const typeFilterMap: Record<string, string> = {
folders: 'folder',
};
const getSearchHandler = () => const getSearchHandler = () =>
http.get('/apis/dashboard.grafana.app/v0alpha1/namespaces/:namespace/search', ({ request }) => { http.get('/apis/dashboard.grafana.app/v0alpha1/namespaces/:namespace/search', ({ request }) => {
const limitFilter = new URL(request.url).searchParams.get('limit') || null;
const folderFilter = new URL(request.url).searchParams.get('folder') || null; const folderFilter = new URL(request.url).searchParams.get('folder') || null;
const typeFilter = new URL(request.url).searchParams.get('type') || null; const typeFilter = new URL(request.url).searchParams.get('type') || null;
const response = mockTree const nameFilter = new URL(request.url).searchParams.getAll('name');
.filter((filterItem) => { const mappedTypeFilter = typeFilter ? typeFilterMap[typeFilter] || typeFilter : null;
const filters: FilterArray = [];
const filtered = mockTree.filter((filterItem) => {
const filters: FilterArray = [
// Filter UI items out of fixtures as... they're UI items 🤷
({ item }) => item.kind !== 'ui',
];
if (nameFilter.length > 0) {
const filteredNameFilter = nameFilter.filter((name) => name !== 'general');
filters.push(({ item }) => filteredNameFilter.includes(item.uid));
}
if (typeFilter) { if (typeFilter) {
filters.push(({ item }) => item.kind === typeFilter); filters.push(({ item }) => item.kind === mappedTypeFilter);
} }
if (folderFilter && folderFilter !== 'general') { if (folderFilter && folderFilter !== 'general') {
@ -37,14 +52,16 @@ const getSearchHandler = () =>
} }
return filters.every((filterPredicate) => filterPredicate(filterItem)); return filters.every((filterPredicate) => filterPredicate(filterItem));
}) });
.map(({ item }) => { const mapped = filtered.map(({ item }) => {
const random = Chance(item.uid); const random = Chance(item.uid);
const parentFolder = 'parentUID' in item ? item.parentUID : undefined;
return { return {
resource: typeMap[item.kind], resource: typeMap[item.kind],
name: item.uid, name: item.uid,
title: item.title, title: item.title,
folder: parentFolder,
field: { field: {
// Generate mock deprecated IDs only in the mock handlers - not generating in // Generate mock deprecated IDs only in the mock handlers - not generating in
// mock data as it would require updating/tracking in the types as well // mock data as it would require updating/tracking in the types as well
@ -53,9 +70,11 @@ const getSearchHandler = () =>
}; };
}); });
const sliced = limitFilter ? mapped.slice(0, parseInt(limitFilter, 10)) : mapped;
return HttpResponse.json({ return HttpResponse.json({
totalHits: response.length, totalHits: sliced.length,
hits: response, hits: sliced,
}); });
}); });

View File

@ -85,6 +85,11 @@ export class BlugeSearcher implements GrafanaSearcher {
return []; return [];
} }
async getLocationInfo() {
// TODO: Implement location info, or deprecate this entire file(?)
return {};
}
// This should eventually be filled by an API call, but hardcoded is a good start // This should eventually be filled by an API call, but hardcoded is a good start
getSortOptions(): Promise<SelectableValue[]> { getSortOptions(): Promise<SelectableValue[]> {
const opts: SelectableValue[] = [ const opts: SelectableValue[] = [

View File

@ -1,7 +1,7 @@
import { SelectableValue, DataFrame, DataFrameView } from '@grafana/data'; import { SelectableValue, DataFrame, DataFrameView } from '@grafana/data';
import { TermCount } from 'app/core/components/TagFilter/TagFilter'; import { TermCount } from 'app/core/components/TagFilter/TagFilter';
import { GrafanaSearcher, QueryResponse, SearchQuery } from './types'; import { GrafanaSearcher, LocationInfo, QueryResponse, SearchQuery } from './types';
// This is a dummy search useful for tests // This is a dummy search useful for tests
export class DummySearcher implements GrafanaSearcher { export class DummySearcher implements GrafanaSearcher {
@ -9,6 +9,7 @@ export class DummySearcher implements GrafanaSearcher {
expectedStarsResponse: QueryResponse | undefined; expectedStarsResponse: QueryResponse | undefined;
expectedSortResponse: SelectableValue[] = []; expectedSortResponse: SelectableValue[] = [];
expectedTagsResponse: TermCount[] = []; expectedTagsResponse: TermCount[] = [];
expectedLocationInfoResponse: Record<string, LocationInfo> = {};
setExpectedSearchResult(result: DataFrame) { setExpectedSearchResult(result: DataFrame) {
this.expectedSearchResponse = { this.expectedSearchResponse = {
@ -35,6 +36,10 @@ export class DummySearcher implements GrafanaSearcher {
return Promise.resolve(this.expectedTagsResponse); return Promise.resolve(this.expectedTagsResponse);
} }
async getLocationInfo(): Promise<Record<string, LocationInfo>> {
return Promise.resolve(this.expectedLocationInfoResponse);
}
getFolderViewSort(): string { getFolderViewSort(): string {
return ''; return '';
} }

View File

@ -76,6 +76,10 @@ export class FrontendSearcher implements GrafanaSearcher {
return this.parent.tags(query); return this.parent.tags(query);
} }
async getLocationInfo() {
return this.parent.getLocationInfo();
}
getFolderViewSort(): string { getFolderViewSort(): string {
return this.parent.getFolderViewSort(); return this.parent.getFolderViewSort();
} }

View File

@ -130,6 +130,10 @@ export class SQLSearcher implements GrafanaSearcher {
return terms.sort((a, b) => b.count - a.count); return terms.sort((a, b) => b.count - a.count);
} }
async getLocationInfo() {
return this.locationInfo;
}
async doAPIQuery(query: APIQuery): Promise<QueryResponse> { async doAPIQuery(query: APIQuery): Promise<QueryResponse> {
let rsp: DashboardSearchHit[]; let rsp: DashboardSearchHit[];

View File

@ -97,6 +97,7 @@ export interface GrafanaSearcher {
tags: (query: SearchQuery) => Promise<TermCount[]>; tags: (query: SearchQuery) => Promise<TermCount[]>;
getSortOptions: () => Promise<SelectableValue[]>; getSortOptions: () => Promise<SelectableValue[]>;
sortPlaceholder?: string; sortPlaceholder?: string;
getLocationInfo: () => Promise<Record<string, LocationInfo>>;
/** Gets the default sort used for the Folder view */ /** Gets the default sort used for the Folder view */
getFolderViewSort: () => string; getFolderViewSort: () => string;

View File

@ -92,6 +92,10 @@ export class UnifiedSearcher implements GrafanaSearcher {
return resp.facets?.tags?.terms || []; return resp.facets?.tags?.terms || [];
} }
async getLocationInfo() {
return this.locationInfo;
}
// TODO: Implement this correctly // TODO: Implement this correctly
getSortOptions(): Promise<SelectableValue[]> { getSortOptions(): Promise<SelectableValue[]> {
const opts: SelectableValue[] = [ const opts: SelectableValue[] = [

View File

@ -0,0 +1,128 @@
import { render, screen } from 'test/test-utils';
import { setBackendSrv } from '@grafana/runtime';
import { setupMockServer } from '@grafana/test-utils/server';
import { getFolderFixtures } from '@grafana/test-utils/unstable';
import { backendSrv } from 'app/core/services/backend_srv';
import impressionSrv from 'app/core/services/impression_srv';
import { testWithFeatureToggles } from 'app/features/alerting/unified/test/test-utils';
import { getPanelProps } from '../test-utils';
import { DashList } from './DashList';
import { Options } from './panelcfg.gen';
const [_, { folderA, folderA_dashbdD, dashbdE }] = getFolderFixtures();
setBackendSrv(backendSrv);
setupMockServer();
const defaultOptions: Options = {
includeVars: false,
keepTime: false,
maxItems: 10,
query: '*',
showFolderNames: false,
showHeadings: false,
showRecentlyViewed: false,
showSearch: false,
showStarred: false,
tags: [],
};
const findStarButton = (title: string, isStarred: boolean) =>
screen.findByRole('button', { name: new RegExp(`^${isStarred ? 'unmark' : 'mark'} "${title}" as favorite`, 'i') });
describe.each([
// App platform APIs
true,
// Legacy APIs
false,
])('DashList - app platform APIs: %s', (featureTogglesEnabled) => {
testWithFeatureToggles(featureTogglesEnabled ? ['unifiedStorageSearchUI'] : []);
it('renders different groups of dashboards', async () => {
const props = getPanelProps({
...defaultOptions,
showHeadings: true,
showRecentlyViewed: true,
showStarred: true,
showSearch: true,
});
render(<DashList {...props} />);
const headings = (await screen.findAllByRole('heading')).map((heading) => heading.textContent);
expect(headings).toEqual(['Starred dashboards', 'Recently viewed dashboards', 'Search']);
});
it('renders folder names', async () => {
const props = getPanelProps({ ...defaultOptions, showStarred: true, showFolderNames: true });
render(<DashList {...props} />);
// Based on the fixtures, we expect to see a dashboard that's contained in folderA
const [folderTitle] = await screen.findAllByText(folderA.item.title);
expect(folderTitle).toBeInTheDocument();
});
it('renders empty state', async () => {
const props = getPanelProps({
...defaultOptions,
showStarred: false,
showRecentlyViewed: false,
showSearch: false,
});
render(<DashList {...props} />);
expect(await screen.findByText('No dashboard groups configured')).toBeInTheDocument();
});
it('allows un-starring a dashboard', async () => {
const props = getPanelProps({
...defaultOptions,
showStarred: true,
});
const { user } = render(<DashList {...props} />, {
preloadedState: { navIndex: { starred: { text: 'Starred', children: [] } } },
});
const starButton = await findStarButton(folderA_dashbdD.item.title, true);
await user.click(starButton);
expect(screen.queryByText(folderA_dashbdD.item.title)).not.toBeInTheDocument();
});
it('allows starring a dashboard', async () => {
const props = getPanelProps({
...defaultOptions,
showStarred: true,
showSearch: true,
});
const { user } = render(<DashList {...props} />, {
preloadedState: { navIndex: { starred: { text: 'Starred', children: [] } } },
});
const starButton = await findStarButton(dashbdE.item.title, false);
await user.click(starButton);
// We use `findAll` because the dashboard will appear in two sections (starred and search)
// but this is fine, because there will have been none before starring it
const [unmarkButton] = await screen.findAllByRole('button', {
name: new RegExp(`^unmark "${dashbdE.item.title}" as favorite`, 'i'),
});
expect(unmarkButton).toBeInTheDocument();
});
it('shows recently viewed dashboards', async () => {
impressionSrv.addDashboardImpression(dashbdE.item.uid);
const props = getPanelProps({
...defaultOptions,
showRecentlyViewed: true,
});
render(<DashList {...props} />);
expect(await screen.findByText(dashbdE.item.title)).toBeInTheDocument();
});
});

View File

@ -3,16 +3,16 @@ import { SyntheticEvent, useEffect, useMemo, useState } from 'react';
import { useThrottle } from 'react-use'; import { useThrottle } from 'react-use';
import { InterpolateFunction, PanelProps, textUtil } from '@grafana/data'; import { InterpolateFunction, PanelProps, textUtil } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { useStyles2, IconButton, ScrollContainer } from '@grafana/ui'; import { useStyles2, IconButton, ScrollContainer, Box, Text, EmptyState, Link } from '@grafana/ui';
import { updateNavIndex } from 'app/core/actions';
import { getConfig } from 'app/core/config'; import { getConfig } from 'app/core/config';
import { ID_PREFIX, setStarred } from 'app/core/reducers/navBarTree'; import { ID_PREFIX, setStarred } from 'app/core/reducers/navBarTree';
import { removeNavIndex } from 'app/core/reducers/navModel'; import { removeNavIndex, updateNavIndex } from 'app/core/reducers/navModel';
import { getBackendSrv } from 'app/core/services/backend_srv';
import impressionSrv from 'app/core/services/impression_srv'; import impressionSrv from 'app/core/services/impression_srv';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { DashboardSearchItem } from 'app/features/search/types'; import { getGrafanaSearcher } from 'app/features/search/service/searcher';
import { DashboardQueryResult, LocationInfo, QueryResponse, SearchQuery } from 'app/features/search/service/types';
import { StarToolbarButtonApiServer } from 'app/features/stars/StarToolbarButton'; import { StarToolbarButtonApiServer } from 'app/features/stars/StarToolbarButton';
import { useDispatch, useSelector } from 'app/types/store'; import { useDispatch, useSelector } from 'app/types/store';
@ -20,7 +20,11 @@ import { Options } from './panelcfg.gen';
import { getStyles } from './styles'; import { getStyles } from './styles';
import { useDashListUrlParams } from './utils'; import { useDashListUrlParams } from './utils';
type Dashboard = DashboardSearchItem & { id?: number; isSearchResult?: boolean; isRecent?: boolean }; type Dashboard = DashboardQueryResult & {
isSearchResult?: boolean;
isRecent?: boolean;
isStarred?: boolean;
};
interface DashboardGroup { interface DashboardGroup {
show: boolean; show: boolean;
@ -29,47 +33,58 @@ interface DashboardGroup {
} }
async function fetchDashboards(options: Options, replaceVars: InterpolateFunction) { async function fetchDashboards(options: Options, replaceVars: InterpolateFunction) {
let starredDashboards: Promise<DashboardSearchItem[]> = Promise.resolve([]); const searcher = getGrafanaSearcher();
let starredDashboards: Promise<QueryResponse | void> = Promise.resolve();
let recentDashboards: Promise<QueryResponse | void> = Promise.resolve();
let searchedDashboards: Promise<QueryResponse | void> = Promise.resolve();
if (options.showStarred) { if (options.showStarred) {
const params = { limit: options.maxItems, starred: 'true' }; const params: SearchQuery = { limit: options.maxItems, starred: true };
starredDashboards = getBackendSrv().search(params); starredDashboards = searcher.starred(params);
} }
let recentDashboards: Promise<DashboardSearchItem[]> = Promise.resolve([]);
let dashUIDs: string[] = []; let dashUIDs: string[] = [];
if (options.showRecentlyViewed) { if (options.showRecentlyViewed) {
let uids = await impressionSrv.getDashboardOpened(); let uids = await impressionSrv.getDashboardOpened();
dashUIDs = take<string>(uids, options.maxItems); dashUIDs = take<string>(uids, options.maxItems);
recentDashboards = getBackendSrv().search({ dashboardUIDs: dashUIDs, limit: options.maxItems });
recentDashboards = searcher.search({ uid: dashUIDs, limit: options.maxItems, kind: ['dashboard'] });
} }
let searchedDashboards: Promise<DashboardSearchItem[]> = Promise.resolve([]);
if (options.showSearch) { if (options.showSearch) {
const uid = options.folderUID === '' ? 'general' : options.folderUID; const uid = options.folderUID === '' ? 'general' : options.folderUID;
const params = { const params: SearchQuery = {
limit: options.maxItems, limit: options.maxItems,
query: replaceVars(options.query, {}, 'text'), query: replaceVars(options.query, {}, 'text'),
folderUIDs: uid, location: uid,
tag: options.tags.map((tag: string) => replaceVars(tag, {}, 'text')), tags: options.tags.map((tag: string) => replaceVars(tag, {}, 'text')),
type: 'dash-db', kind: ['dashboard'],
}; };
searchedDashboards = getBackendSrv().search(params); searchedDashboards = searcher.search(params);
} }
const [starred, searched, recent] = await Promise.all([starredDashboards, searchedDashboards, recentDashboards]); const [starred, searched, recent] = await Promise.allSettled([
starredDashboards,
searchedDashboards,
recentDashboards,
]);
// We deliberately deal with recent dashboards first so that the order of dash IDs is preserved // We deliberately deal with recent dashboards first so that the order of dash IDs is preserved
let dashMap = new Map<string, Dashboard>(); let dashMap = new Map<string, DashboardQueryResult>();
if (recent && recent.status === 'fulfilled') {
for (const dashUID of dashUIDs) { for (const dashUID of dashUIDs) {
const dash = recent.find((d) => d.uid === dashUID); const dash = recent.value?.view.find((d: DashboardQueryResult): d is DashboardQueryResult => {
return d.uid === dashUID;
});
if (dash) { if (dash) {
dashMap.set(dashUID, { ...dash, isRecent: true }); dashMap.set(dashUID, { ...dash, title: dash.name, isRecent: true });
}
} }
} }
searched.forEach((dash) => { if (searched && searched.status === 'fulfilled') {
searched?.value?.view.forEach((dash) => {
if (!dash.uid) { if (!dash.uid) {
return; return;
} }
@ -79,8 +94,10 @@ async function fetchDashboards(options: Options, replaceVars: InterpolateFunctio
dashMap.set(dash.uid, { ...dash, isSearchResult: true }); dashMap.set(dash.uid, { ...dash, isSearchResult: true });
} }
}); });
}
starred.forEach((dash) => { if (starred && starred.status === 'fulfilled') {
starred?.value?.view.forEach((dash) => {
if (!dash.uid) { if (!dash.uid) {
return; return;
} }
@ -90,12 +107,20 @@ async function fetchDashboards(options: Options, replaceVars: InterpolateFunctio
dashMap.set(dash.uid, { ...dash, isStarred: true }); dashMap.set(dash.uid, { ...dash, isStarred: true });
} }
}); });
}
return dashMap; return dashMap;
} }
async function fetchDashboardFolders() {
return getGrafanaSearcher().getLocationInfo();
}
const collator = new Intl.Collator();
export function DashList(props: PanelProps<Options>) { export function DashList(props: PanelProps<Options>) {
const [dashboards, setDashboards] = useState(new Map<string, Dashboard>()); const [dashboards, setDashboards] = useState(new Map<string, Dashboard>());
const [foldersTitleMap, setFoldersTitleMap] = useState<Record<string, LocationInfo>>({});
const dispatch = useDispatch(); const dispatch = useDispatch();
const navIndex = useSelector((state) => state.navIndex); const navIndex = useSelector((state) => state.navIndex);
@ -107,22 +132,30 @@ export function DashList(props: PanelProps<Options>) {
}); });
}, [props.options, props.replaceVariables, throttledRenderCount]); }, [props.options, props.replaceVariables, throttledRenderCount]);
useEffect(() => {
if (props.options.showFolderNames && dashboards.size > 0) {
fetchDashboardFolders().then((locationInfo) => {
setFoldersTitleMap(locationInfo);
});
}
}, [props.options.showFolderNames, dashboards]);
const toggleDashboardStar = async (e: SyntheticEvent, dash: Dashboard) => { const toggleDashboardStar = async (e: SyntheticEvent, dash: Dashboard) => {
const { uid, title, url } = dash; const { uid, name, url } = dash;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const isStarred = await getDashboardSrv().starDashboard(dash.uid, dash.isStarred); const isStarred = await getDashboardSrv().starDashboard(dash.uid, Boolean(dash.isStarred));
const updatedDashboards = new Map(dashboards); const updatedDashboards = new Map(dashboards);
updatedDashboards.set(dash?.uid ?? '', { ...dash, isStarred }); updatedDashboards.set(dash?.uid ?? '', { ...dash, isStarred });
setDashboards(updatedDashboards); setDashboards(updatedDashboards);
dispatch(setStarred({ id: uid ?? '', title, url, isStarred })); dispatch(setStarred({ id: uid ?? '', title: name, url, isStarred }));
const starredNavItem = navIndex['starred']; const starredNavItem = navIndex.starred;
if (isStarred) { if (isStarred) {
starredNavItem.children?.push({ starredNavItem.children?.push({
id: ID_PREFIX + uid, id: ID_PREFIX + uid,
text: title, text: name,
url: url ?? '', url: url ?? '',
parentItem: starredNavItem, parentItem: starredNavItem,
}); });
@ -138,10 +171,27 @@ export function DashList(props: PanelProps<Options>) {
const [starredDashboards, recentDashboards, searchedDashboards] = useMemo(() => { const [starredDashboards, recentDashboards, searchedDashboards] = useMemo(() => {
const dashboardList = [...dashboards.values()]; const dashboardList = [...dashboards.values()];
const dashboardsGroupsMap: Record<string, Dashboard[]> = {
starred: [],
recent: [],
searched: [],
};
for (const dash of dashboardList) {
if (dash.isStarred) {
dashboardsGroupsMap.starred.push(dash);
}
if (dash.isRecent) {
dashboardsGroupsMap.recent.push(dash);
}
if (dash.isSearchResult) {
dashboardsGroupsMap.searched.push(dash);
}
}
return [ return [
dashboardList.filter((dash) => dash.isStarred).sort((a, b) => a.title.localeCompare(b.title)), dashboardsGroupsMap.starred.sort((a, b) => collator.compare(a.name, b.name)),
dashboardList.filter((dash) => dash.isRecent), dashboardsGroupsMap.recent,
dashboardList.filter((dash) => dash.isSearchResult).sort((a, b) => a.title.localeCompare(b.title)), dashboardsGroupsMap.searched.sort((a, b) => collator.compare(a.name, b.name)),
]; ];
}, [dashboards]); }, [dashboards]);
@ -149,17 +199,17 @@ export function DashList(props: PanelProps<Options>) {
const dashboardGroups: DashboardGroup[] = [ const dashboardGroups: DashboardGroup[] = [
{ {
header: 'Starred dashboards', header: t('panel.dashlist.starred-dashboards', 'Starred dashboards'),
dashboards: starredDashboards, dashboards: starredDashboards,
show: showStarred, show: showStarred,
}, },
{ {
header: 'Recently viewed dashboards', header: t('panel.dashlist.recently-viewed-dashboards', 'Recently viewed dashboards'),
dashboards: recentDashboards, dashboards: recentDashboards,
show: showRecentlyViewed, show: showRecentlyViewed,
}, },
{ {
header: 'Search', header: t('panel.dashlist.search', 'Search'),
dashboards: searchedDashboards, dashboards: searchedDashboards,
show: showSearch, show: showSearch,
}, },
@ -173,21 +223,30 @@ export function DashList(props: PanelProps<Options>) {
{dashboards.map((dash) => { {dashboards.map((dash) => {
let url = dash.url + urlParams; let url = dash.url + urlParams;
url = getConfig().disableSanitizeHtml ? url : textUtil.sanitizeUrl(url); url = getConfig().disableSanitizeHtml ? url : textUtil.sanitizeUrl(url);
const markAsStarredText = t('panel.dashlist.mark-as-starred', 'Mark "{{title}}" as favorite', {
title: dash.title,
});
const unmarkAsStarredText = t('panel.dashlist.unmark-as-starred', 'Unmark "{{title}}" as favorite', {
title: dash.title,
});
const locationInfo = showFolderNames && dash.location ? foldersTitleMap[dash.location] : undefined;
return ( return (
<li className={css.dashlistItem} key={`dash-${dash.uid}`}> <li key={`dash-${dash.uid}`}>
<div className={css.dashlistLink}> <div className={css.dashlistLink}>
<div className={css.dashlistLinkBody}> <Box flex={1}>
<a className={css.dashlistTitle} href={url}> <Link href={url}>{dash.name}</Link>
{dash.title} {showFolderNames && locationInfo && (
</a> <Text color="secondary" variant="bodySmall" element="p">
{showFolderNames && dash.folderTitle && <div className={css.dashlistFolder}>{dash.folderTitle}</div>} {locationInfo?.name}
</div> </Text>
)}
</Box>
{config.featureToggles.starsFromAPIServer ? ( {config.featureToggles.starsFromAPIServer ? (
<StarToolbarButtonApiServer group="dashboard.grafana.app" kind="Dashboard" id={dash.uid ?? ''} /> <StarToolbarButtonApiServer group="dashboard.grafana.app" kind="Dashboard" id={dash.uid ?? ''} />
) : ( ) : (
<IconButton <IconButton
tooltip={dash.isStarred ? `Unmark "${dash.title}" as favorite` : `Mark "${dash.title}" as favorite`} tooltip={dash.isStarred ? unmarkAsStarredText : markAsStarredText}
name={dash.isStarred ? 'favorite' : 'star'} name={dash.isStarred ? 'favorite' : 'star'}
iconType={dash.isStarred ? 'mono' : 'default'} iconType={dash.isStarred ? 'mono' : 'default'}
onClick={(e) => toggleDashboardStar(e, dash)} onClick={(e) => toggleDashboardStar(e, dash)}
@ -200,15 +259,30 @@ export function DashList(props: PanelProps<Options>) {
</ul> </ul>
); );
const showEmptyState = dashboardGroups.every(({ show }) => !show);
return ( return (
<ScrollContainer minHeight="100%"> <ScrollContainer minHeight="100%">
{showEmptyState && (
<EmptyState
hideImage
variant="call-to-action"
message={t('panel.dashlist.empty-state-message', 'No dashboard groups configured')}
/>
)}
{dashboardGroups.map( {dashboardGroups.map(
({ show, header, dashboards }, i) => ({ show, header, dashboards }, i) =>
show && ( show && (
<div className={css.dashlistSection} key={`dash-group-${i}`}> <Box marginBottom={2} paddingTop={0.5} key={`dash-group-${i}`}>
{showHeadings && <h6 className={css.dashlistSectionHeader}>{header}</h6>} {showHeadings && (
<Box marginRight={1} paddingX={1} paddingY={0.25}>
<Text variant="h6" element="h6">
{header}
</Text>
</Box>
)}
{renderList(dashboards)} {renderList(dashboards)}
</div> </Box>
) )
)} )}
</ScrollContainer> </ScrollContainer>

View File

@ -4,14 +4,6 @@ import { GrafanaTheme2 } from '@grafana/data';
export const getStyles = (theme: GrafanaTheme2) => { export const getStyles = (theme: GrafanaTheme2) => {
return { return {
dashlistSectionHeader: css({
padding: theme.spacing(0.25, 1),
marginRight: theme.spacing(1),
}),
dashlistSection: css({
marginBottom: theme.spacing(2),
paddingTop: theme.spacing(0.5),
}),
dashlistLink: css({ dashlistLink: css({
display: 'flex', display: 'flex',
cursor: 'pointer', cursor: 'pointer',
@ -27,27 +19,5 @@ export const getStyles = (theme: GrafanaTheme2) => {
}, },
}, },
}), }),
dashlistFolder: css({
color: theme.colors.text.secondary,
fontSize: theme.typography.bodySmall.fontSize,
lineHeight: theme.typography.body.lineHeight,
}),
dashlistTitle: css({
'&::after': {
position: 'absolute',
content: '""',
left: 0,
top: 0,
bottom: 0,
right: 0,
},
}),
dashlistLinkBody: css({
flexGrow: 1,
}),
dashlistItem: css({
position: 'relative',
listStyle: 'none',
}),
}; };
}; };

View File

@ -0,0 +1,30 @@
import { PanelProps, LoadingState, getDefaultTimeRange, FieldConfigSource } from '@grafana/data';
import { getAppEvents } from '@grafana/runtime';
/**
* Get mock panel props for test purposes
*/
export const getPanelProps = <T>(
defaultOptions: T,
panelPropsOverrides?: Partial<Omit<PanelProps<T>, 'options'>>
): PanelProps<T> => {
return {
id: 1,
data: { state: LoadingState.Done, series: [], timeRange: getDefaultTimeRange() },
options: defaultOptions,
eventBus: getAppEvents(),
fieldConfig: {} as unknown as FieldConfigSource,
height: 400,
onChangeTimeRange: jest.fn(),
onFieldConfigChange: jest.fn(),
onOptionsChange: jest.fn(),
replaceVariables: jest.fn(),
renderCounter: 1,
timeRange: getDefaultTimeRange(),
timeZone: 'utc',
title: 'DashList test title',
transparent: false,
width: 320,
...panelPropsOverrides,
};
};

View File

@ -10651,6 +10651,14 @@
} }
}, },
"panel": { "panel": {
"dashlist": {
"empty-state-message": "No dashboard groups configured",
"mark-as-starred": "Mark \"{{title}}\" as favorite",
"recently-viewed-dashboards": "Recently viewed dashboards",
"search": "Search",
"starred-dashboards": "Starred dashboards",
"unmark-as-starred": "Unmark \"{{title}}\" as favorite"
},
"get-calculation-value-data-links-variable-suggestions": { "get-calculation-value-data-links-variable-suggestions": {
"value-calc-var": { "value-calc-var": {
"label": { "label": {