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/preferences/ @grafana/grafana-frontend-platform
/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/annolist/ @grafana/dashboards-squad
/public/app/plugins/panel/barchart/ @grafana/dataviz-squad

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@ import { Chance } from 'chance';
import { HttpResponse, http } from 'msw';
import { wellFormedTree } from '../../../fixtures/folders';
import { mockStarredDashboards } from '../user/handlers';
import { SORT_OPTIONS } from './constants';
@ -22,9 +23,19 @@ const getLegacySearchHandler = () =>
const typeFilter = new URL(request.url).searchParams.get('type') || null;
// Workaround for the fixture kind being 'dashboard' instead of 'dash-db'
const mappedTypeFilter = typeFilter === 'dash-db' ? 'dashboard' : typeFilter;
const starredFilter = new URL(request.url).searchParams.get('starred') || null;
const response = mockTree
.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') {
filters.push(
({ 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,50 +12,69 @@ const typeMap: Record<string, string> = {
dashboard: 'dashboards',
};
const typeFilterMap: Record<string, string> = {
folders: 'folder',
};
const getSearchHandler = () =>
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 typeFilter = new URL(request.url).searchParams.get('type') || null;
const response = mockTree
.filter((filterItem) => {
const filters: FilterArray = [];
const nameFilter = new URL(request.url).searchParams.getAll('name');
const mappedTypeFilter = typeFilter ? typeFilterMap[typeFilter] || typeFilter : null;
if (typeFilter) {
filters.push(({ item }) => item.kind === typeFilter);
}
const filtered = mockTree.filter((filterItem) => {
const filters: FilterArray = [
// Filter UI items out of fixtures as... they're UI items 🤷
({ item }) => item.kind !== 'ui',
];
if (folderFilter && folderFilter !== 'general') {
filters.push(
({ item }) => (item.kind === 'folder' || item.kind === 'dashboard') && item.parentUID === folderFilter
);
}
if (nameFilter.length > 0) {
const filteredNameFilter = nameFilter.filter((name) => name !== 'general');
filters.push(({ item }) => filteredNameFilter.includes(item.uid));
}
if (folderFilter === 'general') {
filters.push(
({ item }) => (item.kind === 'folder' || item.kind === 'dashboard') && item.parentUID === undefined
);
}
if (typeFilter) {
filters.push(({ item }) => item.kind === mappedTypeFilter);
}
return filters.every((filterPredicate) => filterPredicate(filterItem));
})
if (folderFilter && folderFilter !== 'general') {
filters.push(
({ item }) => (item.kind === 'folder' || item.kind === 'dashboard') && item.parentUID === folderFilter
);
}
.map(({ item }) => {
const random = Chance(item.uid);
return {
resource: typeMap[item.kind],
name: item.uid,
title: item.title,
field: {
// 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
'grafana.app/deprecatedInternalID': random.integer({ min: 1, max: 1000 }),
},
};
});
if (folderFilter === 'general') {
filters.push(
({ item }) => (item.kind === 'folder' || item.kind === 'dashboard') && item.parentUID === undefined
);
}
return filters.every((filterPredicate) => filterPredicate(filterItem));
});
const mapped = filtered.map(({ item }) => {
const random = Chance(item.uid);
const parentFolder = 'parentUID' in item ? item.parentUID : undefined;
return {
resource: typeMap[item.kind],
name: item.uid,
title: item.title,
folder: parentFolder,
field: {
// 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
'grafana.app/deprecatedInternalID': random.integer({ min: 1, max: 1000 }),
},
};
});
const sliced = limitFilter ? mapped.slice(0, parseInt(limitFilter, 10)) : mapped;
return HttpResponse.json({
totalHits: response.length,
hits: response,
totalHits: sliced.length,
hits: sliced,
});
});

View File

@ -85,6 +85,11 @@ export class BlugeSearcher implements GrafanaSearcher {
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
getSortOptions(): Promise<SelectableValue[]> {
const opts: SelectableValue[] = [

View File

@ -1,7 +1,7 @@
import { SelectableValue, DataFrame, DataFrameView } from '@grafana/data';
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
export class DummySearcher implements GrafanaSearcher {
@ -9,6 +9,7 @@ export class DummySearcher implements GrafanaSearcher {
expectedStarsResponse: QueryResponse | undefined;
expectedSortResponse: SelectableValue[] = [];
expectedTagsResponse: TermCount[] = [];
expectedLocationInfoResponse: Record<string, LocationInfo> = {};
setExpectedSearchResult(result: DataFrame) {
this.expectedSearchResponse = {
@ -35,6 +36,10 @@ export class DummySearcher implements GrafanaSearcher {
return Promise.resolve(this.expectedTagsResponse);
}
async getLocationInfo(): Promise<Record<string, LocationInfo>> {
return Promise.resolve(this.expectedLocationInfoResponse);
}
getFolderViewSort(): string {
return '';
}

View File

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

View File

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

View File

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

View File

@ -92,6 +92,10 @@ export class UnifiedSearcher implements GrafanaSearcher {
return resp.facets?.tags?.terms || [];
}
async getLocationInfo() {
return this.locationInfo;
}
// TODO: Implement this correctly
getSortOptions(): Promise<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 { InterpolateFunction, PanelProps, textUtil } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { useStyles2, IconButton, ScrollContainer } from '@grafana/ui';
import { updateNavIndex } from 'app/core/actions';
import { useStyles2, IconButton, ScrollContainer, Box, Text, EmptyState, Link } from '@grafana/ui';
import { getConfig } from 'app/core/config';
import { ID_PREFIX, setStarred } from 'app/core/reducers/navBarTree';
import { removeNavIndex } from 'app/core/reducers/navModel';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { removeNavIndex, updateNavIndex } from 'app/core/reducers/navModel';
import impressionSrv from 'app/core/services/impression_srv';
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 { useDispatch, useSelector } from 'app/types/store';
@ -20,7 +20,11 @@ import { Options } from './panelcfg.gen';
import { getStyles } from './styles';
import { useDashListUrlParams } from './utils';
type Dashboard = DashboardSearchItem & { id?: number; isSearchResult?: boolean; isRecent?: boolean };
type Dashboard = DashboardQueryResult & {
isSearchResult?: boolean;
isRecent?: boolean;
isStarred?: boolean;
};
interface DashboardGroup {
show: boolean;
@ -29,73 +33,94 @@ interface DashboardGroup {
}
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) {
const params = { limit: options.maxItems, starred: 'true' };
starredDashboards = getBackendSrv().search(params);
const params: SearchQuery = { limit: options.maxItems, starred: true };
starredDashboards = searcher.starred(params);
}
let recentDashboards: Promise<DashboardSearchItem[]> = Promise.resolve([]);
let dashUIDs: string[] = [];
if (options.showRecentlyViewed) {
let uids = await impressionSrv.getDashboardOpened();
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) {
const uid = options.folderUID === '' ? 'general' : options.folderUID;
const params = {
const params: SearchQuery = {
limit: options.maxItems,
query: replaceVars(options.query, {}, 'text'),
folderUIDs: uid,
tag: options.tags.map((tag: string) => replaceVars(tag, {}, 'text')),
type: 'dash-db',
location: uid,
tags: options.tags.map((tag: string) => replaceVars(tag, {}, 'text')),
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
let dashMap = new Map<string, Dashboard>();
for (const dashUID of dashUIDs) {
const dash = recent.find((d) => d.uid === dashUID);
if (dash) {
dashMap.set(dashUID, { ...dash, isRecent: true });
let dashMap = new Map<string, DashboardQueryResult>();
if (recent && recent.status === 'fulfilled') {
for (const dashUID of dashUIDs) {
const dash = recent.value?.view.find((d: DashboardQueryResult): d is DashboardQueryResult => {
return d.uid === dashUID;
});
if (dash) {
dashMap.set(dashUID, { ...dash, title: dash.name, isRecent: true });
}
}
}
searched.forEach((dash) => {
if (!dash.uid) {
return;
}
if (dashMap.has(dash.uid)) {
dashMap.get(dash.uid)!.isSearchResult = true;
} else {
dashMap.set(dash.uid, { ...dash, isSearchResult: true });
}
});
if (searched && searched.status === 'fulfilled') {
searched?.value?.view.forEach((dash) => {
if (!dash.uid) {
return;
}
if (dashMap.has(dash.uid)) {
dashMap.get(dash.uid)!.isSearchResult = true;
} else {
dashMap.set(dash.uid, { ...dash, isSearchResult: true });
}
});
}
starred.forEach((dash) => {
if (!dash.uid) {
return;
}
if (dashMap.has(dash.uid)) {
dashMap.get(dash.uid)!.isStarred = true;
} else {
dashMap.set(dash.uid, { ...dash, isStarred: true });
}
});
if (starred && starred.status === 'fulfilled') {
starred?.value?.view.forEach((dash) => {
if (!dash.uid) {
return;
}
if (dashMap.has(dash.uid)) {
dashMap.get(dash.uid)!.isStarred = true;
} else {
dashMap.set(dash.uid, { ...dash, isStarred: true });
}
});
}
return dashMap;
}
async function fetchDashboardFolders() {
return getGrafanaSearcher().getLocationInfo();
}
const collator = new Intl.Collator();
export function DashList(props: PanelProps<Options>) {
const [dashboards, setDashboards] = useState(new Map<string, Dashboard>());
const [foldersTitleMap, setFoldersTitleMap] = useState<Record<string, LocationInfo>>({});
const dispatch = useDispatch();
const navIndex = useSelector((state) => state.navIndex);
@ -107,22 +132,30 @@ export function DashList(props: PanelProps<Options>) {
});
}, [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 { uid, title, url } = dash;
const { uid, name, url } = dash;
e.preventDefault();
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);
updatedDashboards.set(dash?.uid ?? '', { ...dash, isStarred });
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) {
starredNavItem.children?.push({
id: ID_PREFIX + uid,
text: title,
text: name,
url: url ?? '',
parentItem: starredNavItem,
});
@ -138,10 +171,27 @@ export function DashList(props: PanelProps<Options>) {
const [starredDashboards, recentDashboards, searchedDashboards] = useMemo(() => {
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 [
dashboardList.filter((dash) => dash.isStarred).sort((a, b) => a.title.localeCompare(b.title)),
dashboardList.filter((dash) => dash.isRecent),
dashboardList.filter((dash) => dash.isSearchResult).sort((a, b) => a.title.localeCompare(b.title)),
dashboardsGroupsMap.starred.sort((a, b) => collator.compare(a.name, b.name)),
dashboardsGroupsMap.recent,
dashboardsGroupsMap.searched.sort((a, b) => collator.compare(a.name, b.name)),
];
}, [dashboards]);
@ -149,17 +199,17 @@ export function DashList(props: PanelProps<Options>) {
const dashboardGroups: DashboardGroup[] = [
{
header: 'Starred dashboards',
header: t('panel.dashlist.starred-dashboards', 'Starred dashboards'),
dashboards: starredDashboards,
show: showStarred,
},
{
header: 'Recently viewed dashboards',
header: t('panel.dashlist.recently-viewed-dashboards', 'Recently viewed dashboards'),
dashboards: recentDashboards,
show: showRecentlyViewed,
},
{
header: 'Search',
header: t('panel.dashlist.search', 'Search'),
dashboards: searchedDashboards,
show: showSearch,
},
@ -173,21 +223,30 @@ export function DashList(props: PanelProps<Options>) {
{dashboards.map((dash) => {
let url = dash.url + urlParams;
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 (
<li className={css.dashlistItem} key={`dash-${dash.uid}`}>
<li key={`dash-${dash.uid}`}>
<div className={css.dashlistLink}>
<div className={css.dashlistLinkBody}>
<a className={css.dashlistTitle} href={url}>
{dash.title}
</a>
{showFolderNames && dash.folderTitle && <div className={css.dashlistFolder}>{dash.folderTitle}</div>}
</div>
<Box flex={1}>
<Link href={url}>{dash.name}</Link>
{showFolderNames && locationInfo && (
<Text color="secondary" variant="bodySmall" element="p">
{locationInfo?.name}
</Text>
)}
</Box>
{config.featureToggles.starsFromAPIServer ? (
<StarToolbarButtonApiServer group="dashboard.grafana.app" kind="Dashboard" id={dash.uid ?? ''} />
) : (
<IconButton
tooltip={dash.isStarred ? `Unmark "${dash.title}" as favorite` : `Mark "${dash.title}" as favorite`}
tooltip={dash.isStarred ? unmarkAsStarredText : markAsStarredText}
name={dash.isStarred ? 'favorite' : 'star'}
iconType={dash.isStarred ? 'mono' : 'default'}
onClick={(e) => toggleDashboardStar(e, dash)}
@ -200,15 +259,30 @@ export function DashList(props: PanelProps<Options>) {
</ul>
);
const showEmptyState = dashboardGroups.every(({ show }) => !show);
return (
<ScrollContainer minHeight="100%">
{showEmptyState && (
<EmptyState
hideImage
variant="call-to-action"
message={t('panel.dashlist.empty-state-message', 'No dashboard groups configured')}
/>
)}
{dashboardGroups.map(
({ show, header, dashboards }, i) =>
show && (
<div className={css.dashlistSection} key={`dash-group-${i}`}>
{showHeadings && <h6 className={css.dashlistSectionHeader}>{header}</h6>}
<Box marginBottom={2} paddingTop={0.5} key={`dash-group-${i}`}>
{showHeadings && (
<Box marginRight={1} paddingX={1} paddingY={0.25}>
<Text variant="h6" element="h6">
{header}
</Text>
</Box>
)}
{renderList(dashboards)}
</div>
</Box>
)
)}
</ScrollContainer>

View File

@ -4,14 +4,6 @@ import { GrafanaTheme2 } from '@grafana/data';
export const getStyles = (theme: GrafanaTheme2) => {
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({
display: 'flex',
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": {
"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": {
"value-calc-var": {
"label": {