mirror of https://github.com/grafana/grafana.git
				
				
				
			Dashboards: Migrate DashList panel to use grafanaSearcher (#111274)
This commit is contained in:
		
							parent
							
								
									54a347463e
								
							
						
					
					
						commit
						053920b8b7
					
				|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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}', | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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; | ||||
|  | @ -12,16 +12,31 @@ 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; | ||||
| 
 | ||||
|     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) { | ||||
|           filters.push(({ item }) => item.kind === typeFilter); | ||||
|         filters.push(({ item }) => item.kind === mappedTypeFilter); | ||||
|       } | ||||
| 
 | ||||
|       if (folderFilter && folderFilter !== 'general') { | ||||
|  | @ -37,14 +52,16 @@ const getSearchHandler = () => | |||
|       } | ||||
| 
 | ||||
|       return filters.every((filterPredicate) => filterPredicate(filterItem)); | ||||
|       }) | ||||
|     }); | ||||
| 
 | ||||
|       .map(({ item }) => { | ||||
|     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
 | ||||
|  | @ -53,9 +70,11 @@ const getSearchHandler = () => | |||
|       }; | ||||
|     }); | ||||
| 
 | ||||
|     const sliced = limitFilter ? mapped.slice(0, parseInt(limitFilter, 10)) : mapped; | ||||
| 
 | ||||
|     return HttpResponse.json({ | ||||
|       totalHits: response.length, | ||||
|       hits: response, | ||||
|       totalHits: sliced.length, | ||||
|       hits: sliced, | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -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[] = [ | ||||
|  |  | |||
|  | @ -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 ''; | ||||
|   } | ||||
|  |  | |||
|  | @ -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(); | ||||
|   } | ||||
|  |  | |||
|  | @ -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[]; | ||||
| 
 | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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[] = [ | ||||
|  |  | |||
|  | @ -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(); | ||||
|   }); | ||||
| }); | ||||
|  | @ -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,47 +33,58 @@ 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>(); | ||||
|   let dashMap = new Map<string, DashboardQueryResult>(); | ||||
|   if (recent && recent.status === 'fulfilled') { | ||||
|     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) { | ||||
|       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) { | ||||
|         return; | ||||
|       } | ||||
|  | @ -79,8 +94,10 @@ async function fetchDashboards(options: Options, replaceVars: InterpolateFunctio | |||
|         dashMap.set(dash.uid, { ...dash, isSearchResult: true }); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   starred.forEach((dash) => { | ||||
|   if (starred && starred.status === 'fulfilled') { | ||||
|     starred?.value?.view.forEach((dash) => { | ||||
|       if (!dash.uid) { | ||||
|         return; | ||||
|       } | ||||
|  | @ -90,12 +107,20 @@ async function fetchDashboards(options: Options, replaceVars: InterpolateFunctio | |||
|         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> | ||||
|  |  | |||
|  | @ -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', | ||||
|     }), | ||||
|   }; | ||||
| }; | ||||
|  |  | |||
|  | @ -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, | ||||
|   }; | ||||
| }; | ||||
|  | @ -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": { | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue