diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a5360c3969a..c76673efd98 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 0786a4e35a6..92ab37a2efd 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/eslint.config.js b/eslint.config.js index a00588a17db..4aa4e15a0f9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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}', diff --git a/packages/grafana-test-utils/src/handlers/all-handlers.ts b/packages/grafana-test-utils/src/handlers/all-handlers.ts index 4b4bf959496..b3b70fbd4cc 100644 --- a/packages/grafana-test-utils/src/handlers/all-handlers.ts +++ b/packages/grafana-test-utils/src/handlers/all-handlers.ts @@ -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, diff --git a/packages/grafana-test-utils/src/handlers/api/search/handlers.ts b/packages/grafana-test-utils/src/handlers/api/search/handlers.ts index f8f0a0d6b4e..84cda34bb0c 100644 --- a/packages/grafana-test-utils/src/handlers/api/search/handlers.ts +++ b/packages/grafana-test-utils/src/handlers/api/search/handlers.ts @@ -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 diff --git a/packages/grafana-test-utils/src/handlers/api/user/handlers.ts b/packages/grafana-test-utils/src/handlers/api/user/handlers.ts new file mode 100644 index 00000000000..90a7ebe049a --- /dev/null +++ b/packages/grafana-test-utils/src/handlers/api/user/handlers.ts @@ -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; diff --git a/packages/grafana-test-utils/src/handlers/apis/dashboard.grafana.app/v0alpha1/handlers.ts b/packages/grafana-test-utils/src/handlers/apis/dashboard.grafana.app/v0alpha1/handlers.ts index 469cc4316bd..f6d079755c5 100644 --- a/packages/grafana-test-utils/src/handlers/apis/dashboard.grafana.app/v0alpha1/handlers.ts +++ b/packages/grafana-test-utils/src/handlers/apis/dashboard.grafana.app/v0alpha1/handlers.ts @@ -12,50 +12,69 @@ const typeMap: Record = { dashboard: 'dashboards', }; +const typeFilterMap: Record = { + 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, }); }); diff --git a/public/app/features/search/service/bluge.ts b/public/app/features/search/service/bluge.ts index 15c3f0d7119..48399a5929c 100644 --- a/public/app/features/search/service/bluge.ts +++ b/public/app/features/search/service/bluge.ts @@ -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 { const opts: SelectableValue[] = [ diff --git a/public/app/features/search/service/dummy.ts b/public/app/features/search/service/dummy.ts index d014fd22c6a..fe805bbe33a 100644 --- a/public/app/features/search/service/dummy.ts +++ b/public/app/features/search/service/dummy.ts @@ -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 = {}; setExpectedSearchResult(result: DataFrame) { this.expectedSearchResponse = { @@ -35,6 +36,10 @@ export class DummySearcher implements GrafanaSearcher { return Promise.resolve(this.expectedTagsResponse); } + async getLocationInfo(): Promise> { + return Promise.resolve(this.expectedLocationInfoResponse); + } + getFolderViewSort(): string { return ''; } diff --git a/public/app/features/search/service/frontend.ts b/public/app/features/search/service/frontend.ts index 11634988f7a..b926d0c2cb2 100644 --- a/public/app/features/search/service/frontend.ts +++ b/public/app/features/search/service/frontend.ts @@ -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(); } diff --git a/public/app/features/search/service/sql.ts b/public/app/features/search/service/sql.ts index ba0dbc9c894..137fee41620 100644 --- a/public/app/features/search/service/sql.ts +++ b/public/app/features/search/service/sql.ts @@ -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 { let rsp: DashboardSearchHit[]; diff --git a/public/app/features/search/service/types.ts b/public/app/features/search/service/types.ts index cd043a2f8fe..6617bb284f9 100644 --- a/public/app/features/search/service/types.ts +++ b/public/app/features/search/service/types.ts @@ -97,6 +97,7 @@ export interface GrafanaSearcher { tags: (query: SearchQuery) => Promise; getSortOptions: () => Promise; sortPlaceholder?: string; + getLocationInfo: () => Promise>; /** Gets the default sort used for the Folder view */ getFolderViewSort: () => string; diff --git a/public/app/features/search/service/unified.ts b/public/app/features/search/service/unified.ts index 272881a478a..c0514db43f9 100644 --- a/public/app/features/search/service/unified.ts +++ b/public/app/features/search/service/unified.ts @@ -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 { const opts: SelectableValue[] = [ diff --git a/public/app/plugins/panel/dashlist/DashList.test.tsx b/public/app/plugins/panel/dashlist/DashList.test.tsx new file mode 100644 index 00000000000..d1febc53817 --- /dev/null +++ b/public/app/plugins/panel/dashlist/DashList.test.tsx @@ -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(); + + 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(); + + // 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(); + + 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(, { + 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(, { + 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(); + + expect(await screen.findByText(dashbdE.item.title)).toBeInTheDocument(); + }); +}); diff --git a/public/app/plugins/panel/dashlist/DashList.tsx b/public/app/plugins/panel/dashlist/DashList.tsx index d997bcb36a6..76ee1497a9e 100644 --- a/public/app/plugins/panel/dashlist/DashList.tsx +++ b/public/app/plugins/panel/dashlist/DashList.tsx @@ -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 = Promise.resolve([]); + const searcher = getGrafanaSearcher(); + let starredDashboards: Promise = Promise.resolve(); + let recentDashboards: Promise = Promise.resolve(); + let searchedDashboards: Promise = 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 = Promise.resolve([]); let dashUIDs: string[] = []; if (options.showRecentlyViewed) { let uids = await impressionSrv.getDashboardOpened(); dashUIDs = take(uids, options.maxItems); - recentDashboards = getBackendSrv().search({ dashboardUIDs: dashUIDs, limit: options.maxItems }); + + recentDashboards = searcher.search({ uid: dashUIDs, limit: options.maxItems, kind: ['dashboard'] }); } - let searchedDashboards: Promise = 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(); - 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(); + 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) { const [dashboards, setDashboards] = useState(new Map()); + const [foldersTitleMap, setFoldersTitleMap] = useState>({}); const dispatch = useDispatch(); const navIndex = useSelector((state) => state.navIndex); @@ -107,22 +132,30 @@ export function DashList(props: PanelProps) { }); }, [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) { const [starredDashboards, recentDashboards, searchedDashboards] = useMemo(() => { const dashboardList = [...dashboards.values()]; + const dashboardsGroupsMap: Record = { + 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) { 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) { {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 ( -
  • +
  • -
    - - {dash.title} - - {showFolderNames && dash.folderTitle &&
    {dash.folderTitle}
    } -
    + + {dash.name} + {showFolderNames && locationInfo && ( + + {locationInfo?.name} + + )} + {config.featureToggles.starsFromAPIServer ? ( ) : ( toggleDashboardStar(e, dash)} @@ -200,15 +259,30 @@ export function DashList(props: PanelProps) { ); + const showEmptyState = dashboardGroups.every(({ show }) => !show); + return ( + {showEmptyState && ( + + )} {dashboardGroups.map( ({ show, header, dashboards }, i) => show && ( -
    - {showHeadings &&
    {header}
    } + + {showHeadings && ( + + + {header} + + + )} {renderList(dashboards)} -
    + ) )}
    diff --git a/public/app/plugins/panel/dashlist/styles.ts b/public/app/plugins/panel/dashlist/styles.ts index 8d482d09960..56becdd0684 100644 --- a/public/app/plugins/panel/dashlist/styles.ts +++ b/public/app/plugins/panel/dashlist/styles.ts @@ -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', - }), }; }; diff --git a/public/app/plugins/panel/test-utils.ts b/public/app/plugins/panel/test-utils.ts new file mode 100644 index 00000000000..ca6ac854fcf --- /dev/null +++ b/public/app/plugins/panel/test-utils.ts @@ -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 = ( + defaultOptions: T, + panelPropsOverrides?: Partial, 'options'>> +): PanelProps => { + 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, + }; +}; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index f03f0a7fe2b..95d771db840 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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": {