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/variables/ @grafana/dashboards-squad | ||||||
| /public/app/features/preferences/ @grafana/grafana-frontend-platform | /public/app/features/preferences/ @grafana/grafana-frontend-platform | ||||||
| /public/app/features/bookmarks/ @grafana/grafana-search-navigate-organise | /public/app/features/bookmarks/ @grafana/grafana-search-navigate-organise | ||||||
|  | /public/app/plugins/panel/* @grafana/dataviz-squad | ||||||
| /public/app/plugins/panel/alertlist/ @grafana/alerting-frontend | /public/app/plugins/panel/alertlist/ @grafana/alerting-frontend | ||||||
| /public/app/plugins/panel/annolist/ @grafana/dashboards-squad | /public/app/plugins/panel/annolist/ @grafana/dashboards-squad | ||||||
| /public/app/plugins/panel/barchart/ @grafana/dataviz-squad | /public/app/plugins/panel/barchart/ @grafana/dataviz-squad | ||||||
|  |  | ||||||
|  | @ -4476,11 +4476,6 @@ | ||||||
|       "count": 2 |       "count": 2 | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   "public/app/plugins/panel/dashlist/DashList.tsx": { |  | ||||||
|     "no-restricted-syntax": { |  | ||||||
|       "count": 2 |  | ||||||
|     } |  | ||||||
|   }, |  | ||||||
|   "public/app/plugins/panel/debug/CursorView.tsx": { |   "public/app/plugins/panel/debug/CursorView.tsx": { | ||||||
|     "@typescript-eslint/consistent-type-assertions": { |     "@typescript-eslint/consistent-type-assertions": { | ||||||
|       "count": 1 |       "count": 1 | ||||||
|  |  | ||||||
|  | @ -26,7 +26,7 @@ const commonTestIgnores = [ | ||||||
|   '**/__mocks__/**', |   '**/__mocks__/**', | ||||||
|   '**/mocks/**/*.{ts,tsx}', |   '**/mocks/**/*.{ts,tsx}', | ||||||
|   '**/public/test/**', |   '**/public/test/**', | ||||||
|   '**/mocks.{ts,tsx}', |   '**/{mocks,test-utils}.{ts,tsx}', | ||||||
|   '**/*.mock.{ts,tsx}', |   '**/*.mock.{ts,tsx}', | ||||||
|   '**/{test-helpers,testHelpers}.{ts,tsx}', |   '**/{test-helpers,testHelpers}.{ts,tsx}', | ||||||
|   '**/{spec,test-helpers}/**/*.{ts,tsx}', |   '**/{spec,test-helpers}/**/*.{ts,tsx}', | ||||||
|  |  | ||||||
|  | @ -3,6 +3,7 @@ import { HttpHandler } from 'msw'; | ||||||
| import folderHandlers from './api/folders/handlers'; | import folderHandlers from './api/folders/handlers'; | ||||||
| import searchHandlers from './api/search/handlers'; | import searchHandlers from './api/search/handlers'; | ||||||
| import teamsHandlers from './api/teams/handlers'; | import teamsHandlers from './api/teams/handlers'; | ||||||
|  | import userHandlers from './api/user/handlers'; | ||||||
| import appPlatformDashboardv0alpha1Handlers from './apis/dashboard.grafana.app/v0alpha1/handlers'; | import appPlatformDashboardv0alpha1Handlers from './apis/dashboard.grafana.app/v0alpha1/handlers'; | ||||||
| import appPlatformFolderv1beta1Handlers from './apis/folder.grafana.app/v1beta1/handlers'; | import appPlatformFolderv1beta1Handlers from './apis/folder.grafana.app/v1beta1/handlers'; | ||||||
| import appPlatformIamv0alpha1Handlers from './apis/iam.grafana.app/v0alpha1/handlers'; | import appPlatformIamv0alpha1Handlers from './apis/iam.grafana.app/v0alpha1/handlers'; | ||||||
|  | @ -12,6 +13,7 @@ const allHandlers: HttpHandler[] = [ | ||||||
|   ...teamsHandlers, |   ...teamsHandlers, | ||||||
|   ...folderHandlers, |   ...folderHandlers, | ||||||
|   ...searchHandlers, |   ...searchHandlers, | ||||||
|  |   ...userHandlers, | ||||||
| 
 | 
 | ||||||
|   // App platform handlers
 |   // App platform handlers
 | ||||||
|   ...appPlatformDashboardv0alpha1Handlers, |   ...appPlatformDashboardv0alpha1Handlers, | ||||||
|  |  | ||||||
|  | @ -2,6 +2,7 @@ import { Chance } from 'chance'; | ||||||
| import { HttpResponse, http } from 'msw'; | import { HttpResponse, http } from 'msw'; | ||||||
| 
 | 
 | ||||||
| import { wellFormedTree } from '../../../fixtures/folders'; | import { wellFormedTree } from '../../../fixtures/folders'; | ||||||
|  | import { mockStarredDashboards } from '../user/handlers'; | ||||||
| 
 | 
 | ||||||
| import { SORT_OPTIONS } from './constants'; | import { SORT_OPTIONS } from './constants'; | ||||||
| 
 | 
 | ||||||
|  | @ -22,9 +23,19 @@ const getLegacySearchHandler = () => | ||||||
|     const typeFilter = new URL(request.url).searchParams.get('type') || null; |     const typeFilter = new URL(request.url).searchParams.get('type') || null; | ||||||
|     // Workaround for the fixture kind being 'dashboard' instead of 'dash-db'
 |     // Workaround for the fixture kind being 'dashboard' instead of 'dash-db'
 | ||||||
|     const mappedTypeFilter = typeFilter === 'dash-db' ? 'dashboard' : typeFilter; |     const mappedTypeFilter = typeFilter === 'dash-db' ? 'dashboard' : typeFilter; | ||||||
|  |     const starredFilter = new URL(request.url).searchParams.get('starred') || null; | ||||||
|  | 
 | ||||||
|     const response = mockTree |     const response = mockTree | ||||||
|       .filter((filterItem) => { |       .filter((filterItem) => { | ||||||
|         const filters: FilterArray = []; |         const filters: FilterArray = [ | ||||||
|  |           // Filter UI items out of fixtures as... they're UI items 🤷
 | ||||||
|  |           ({ item }) => item.kind !== 'ui', | ||||||
|  |         ]; | ||||||
|  | 
 | ||||||
|  |         if (starredFilter) { | ||||||
|  |           filters.push(({ item }) => mockStarredDashboards.includes(item.uid)); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|         if (folderFilter && folderFilter !== 'general') { |         if (folderFilter && folderFilter !== 'general') { | ||||||
|           filters.push( |           filters.push( | ||||||
|             ({ item }) => (item.kind === 'folder' || item.kind === 'dashboard') && item.parentUID === folderFilter |             ({ item }) => (item.kind === 'folder' || item.kind === 'dashboard') && item.parentUID === folderFilter | ||||||
|  |  | ||||||
|  | @ -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', |   dashboard: 'dashboards', | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | const typeFilterMap: Record<string, string> = { | ||||||
|  |   folders: 'folder', | ||||||
|  | }; | ||||||
|  | 
 | ||||||
| const getSearchHandler = () => | const getSearchHandler = () => | ||||||
|   http.get('/apis/dashboard.grafana.app/v0alpha1/namespaces/:namespace/search', ({ request }) => { |   http.get('/apis/dashboard.grafana.app/v0alpha1/namespaces/:namespace/search', ({ request }) => { | ||||||
|  |     const limitFilter = new URL(request.url).searchParams.get('limit') || null; | ||||||
|     const folderFilter = new URL(request.url).searchParams.get('folder') || null; |     const folderFilter = new URL(request.url).searchParams.get('folder') || null; | ||||||
|     const typeFilter = new URL(request.url).searchParams.get('type') || null; |     const typeFilter = new URL(request.url).searchParams.get('type') || null; | ||||||
|     const response = mockTree |     const nameFilter = new URL(request.url).searchParams.getAll('name'); | ||||||
|       .filter((filterItem) => { |     const mappedTypeFilter = typeFilter ? typeFilterMap[typeFilter] || typeFilter : null; | ||||||
|         const filters: FilterArray = []; | 
 | ||||||
|  |     const filtered = mockTree.filter((filterItem) => { | ||||||
|  |       const filters: FilterArray = [ | ||||||
|  |         // Filter UI items out of fixtures as... they're UI items 🤷
 | ||||||
|  |         ({ item }) => item.kind !== 'ui', | ||||||
|  |       ]; | ||||||
|  | 
 | ||||||
|  |       if (nameFilter.length > 0) { | ||||||
|  |         const filteredNameFilter = nameFilter.filter((name) => name !== 'general'); | ||||||
|  |         filters.push(({ item }) => filteredNameFilter.includes(item.uid)); | ||||||
|  |       } | ||||||
| 
 | 
 | ||||||
|       if (typeFilter) { |       if (typeFilter) { | ||||||
|           filters.push(({ item }) => item.kind === typeFilter); |         filters.push(({ item }) => item.kind === mappedTypeFilter); | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       if (folderFilter && folderFilter !== 'general') { |       if (folderFilter && folderFilter !== 'general') { | ||||||
|  | @ -37,14 +52,16 @@ const getSearchHandler = () => | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       return filters.every((filterPredicate) => filterPredicate(filterItem)); |       return filters.every((filterPredicate) => filterPredicate(filterItem)); | ||||||
|       }) |     }); | ||||||
| 
 | 
 | ||||||
|       .map(({ item }) => { |     const mapped = filtered.map(({ item }) => { | ||||||
|       const random = Chance(item.uid); |       const random = Chance(item.uid); | ||||||
|  |       const parentFolder = 'parentUID' in item ? item.parentUID : undefined; | ||||||
|       return { |       return { | ||||||
|         resource: typeMap[item.kind], |         resource: typeMap[item.kind], | ||||||
|         name: item.uid, |         name: item.uid, | ||||||
|         title: item.title, |         title: item.title, | ||||||
|  |         folder: parentFolder, | ||||||
|         field: { |         field: { | ||||||
|           // Generate mock deprecated IDs only in the mock handlers - not generating in
 |           // Generate mock deprecated IDs only in the mock handlers - not generating in
 | ||||||
|           // mock data as it would require updating/tracking in the types as well
 |           // mock data as it would require updating/tracking in the types as well
 | ||||||
|  | @ -53,9 +70,11 @@ const getSearchHandler = () => | ||||||
|       }; |       }; | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  |     const sliced = limitFilter ? mapped.slice(0, parseInt(limitFilter, 10)) : mapped; | ||||||
|  | 
 | ||||||
|     return HttpResponse.json({ |     return HttpResponse.json({ | ||||||
|       totalHits: response.length, |       totalHits: sliced.length, | ||||||
|       hits: response, |       hits: sliced, | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -85,6 +85,11 @@ export class BlugeSearcher implements GrafanaSearcher { | ||||||
|     return []; |     return []; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   async getLocationInfo() { | ||||||
|  |     // TODO: Implement location info, or deprecate this entire file(?)
 | ||||||
|  |     return {}; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   // This should eventually be filled by an API call, but hardcoded is a good start
 |   // This should eventually be filled by an API call, but hardcoded is a good start
 | ||||||
|   getSortOptions(): Promise<SelectableValue[]> { |   getSortOptions(): Promise<SelectableValue[]> { | ||||||
|     const opts: SelectableValue[] = [ |     const opts: SelectableValue[] = [ | ||||||
|  |  | ||||||
|  | @ -1,7 +1,7 @@ | ||||||
| import { SelectableValue, DataFrame, DataFrameView } from '@grafana/data'; | import { SelectableValue, DataFrame, DataFrameView } from '@grafana/data'; | ||||||
| import { TermCount } from 'app/core/components/TagFilter/TagFilter'; | import { TermCount } from 'app/core/components/TagFilter/TagFilter'; | ||||||
| 
 | 
 | ||||||
| import { GrafanaSearcher, QueryResponse, SearchQuery } from './types'; | import { GrafanaSearcher, LocationInfo, QueryResponse, SearchQuery } from './types'; | ||||||
| 
 | 
 | ||||||
| // This is a dummy search useful for tests
 | // This is a dummy search useful for tests
 | ||||||
| export class DummySearcher implements GrafanaSearcher { | export class DummySearcher implements GrafanaSearcher { | ||||||
|  | @ -9,6 +9,7 @@ export class DummySearcher implements GrafanaSearcher { | ||||||
|   expectedStarsResponse: QueryResponse | undefined; |   expectedStarsResponse: QueryResponse | undefined; | ||||||
|   expectedSortResponse: SelectableValue[] = []; |   expectedSortResponse: SelectableValue[] = []; | ||||||
|   expectedTagsResponse: TermCount[] = []; |   expectedTagsResponse: TermCount[] = []; | ||||||
|  |   expectedLocationInfoResponse: Record<string, LocationInfo> = {}; | ||||||
| 
 | 
 | ||||||
|   setExpectedSearchResult(result: DataFrame) { |   setExpectedSearchResult(result: DataFrame) { | ||||||
|     this.expectedSearchResponse = { |     this.expectedSearchResponse = { | ||||||
|  | @ -35,6 +36,10 @@ export class DummySearcher implements GrafanaSearcher { | ||||||
|     return Promise.resolve(this.expectedTagsResponse); |     return Promise.resolve(this.expectedTagsResponse); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   async getLocationInfo(): Promise<Record<string, LocationInfo>> { | ||||||
|  |     return Promise.resolve(this.expectedLocationInfoResponse); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   getFolderViewSort(): string { |   getFolderViewSort(): string { | ||||||
|     return ''; |     return ''; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  | @ -76,6 +76,10 @@ export class FrontendSearcher implements GrafanaSearcher { | ||||||
|     return this.parent.tags(query); |     return this.parent.tags(query); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   async getLocationInfo() { | ||||||
|  |     return this.parent.getLocationInfo(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   getFolderViewSort(): string { |   getFolderViewSort(): string { | ||||||
|     return this.parent.getFolderViewSort(); |     return this.parent.getFolderViewSort(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  | @ -130,6 +130,10 @@ export class SQLSearcher implements GrafanaSearcher { | ||||||
|     return terms.sort((a, b) => b.count - a.count); |     return terms.sort((a, b) => b.count - a.count); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   async getLocationInfo() { | ||||||
|  |     return this.locationInfo; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   async doAPIQuery(query: APIQuery): Promise<QueryResponse> { |   async doAPIQuery(query: APIQuery): Promise<QueryResponse> { | ||||||
|     let rsp: DashboardSearchHit[]; |     let rsp: DashboardSearchHit[]; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -97,6 +97,7 @@ export interface GrafanaSearcher { | ||||||
|   tags: (query: SearchQuery) => Promise<TermCount[]>; |   tags: (query: SearchQuery) => Promise<TermCount[]>; | ||||||
|   getSortOptions: () => Promise<SelectableValue[]>; |   getSortOptions: () => Promise<SelectableValue[]>; | ||||||
|   sortPlaceholder?: string; |   sortPlaceholder?: string; | ||||||
|  |   getLocationInfo: () => Promise<Record<string, LocationInfo>>; | ||||||
| 
 | 
 | ||||||
|   /** Gets the default sort used for the Folder view */ |   /** Gets the default sort used for the Folder view */ | ||||||
|   getFolderViewSort: () => string; |   getFolderViewSort: () => string; | ||||||
|  |  | ||||||
|  | @ -92,6 +92,10 @@ export class UnifiedSearcher implements GrafanaSearcher { | ||||||
|     return resp.facets?.tags?.terms || []; |     return resp.facets?.tags?.terms || []; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   async getLocationInfo() { | ||||||
|  |     return this.locationInfo; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   // TODO: Implement this correctly
 |   // TODO: Implement this correctly
 | ||||||
|   getSortOptions(): Promise<SelectableValue[]> { |   getSortOptions(): Promise<SelectableValue[]> { | ||||||
|     const opts: SelectableValue[] = [ |     const opts: SelectableValue[] = [ | ||||||
|  |  | ||||||
|  | @ -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 { useThrottle } from 'react-use'; | ||||||
| 
 | 
 | ||||||
| import { InterpolateFunction, PanelProps, textUtil } from '@grafana/data'; | import { InterpolateFunction, PanelProps, textUtil } from '@grafana/data'; | ||||||
|  | import { t } from '@grafana/i18n'; | ||||||
| import { config } from '@grafana/runtime'; | import { config } from '@grafana/runtime'; | ||||||
| import { useStyles2, IconButton, ScrollContainer } from '@grafana/ui'; | import { useStyles2, IconButton, ScrollContainer, Box, Text, EmptyState, Link } from '@grafana/ui'; | ||||||
| import { updateNavIndex } from 'app/core/actions'; |  | ||||||
| import { getConfig } from 'app/core/config'; | import { getConfig } from 'app/core/config'; | ||||||
| import { ID_PREFIX, setStarred } from 'app/core/reducers/navBarTree'; | import { ID_PREFIX, setStarred } from 'app/core/reducers/navBarTree'; | ||||||
| import { removeNavIndex } from 'app/core/reducers/navModel'; | import { removeNavIndex, updateNavIndex } from 'app/core/reducers/navModel'; | ||||||
| import { getBackendSrv } from 'app/core/services/backend_srv'; |  | ||||||
| import impressionSrv from 'app/core/services/impression_srv'; | import impressionSrv from 'app/core/services/impression_srv'; | ||||||
| import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; | import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; | ||||||
| import { DashboardSearchItem } from 'app/features/search/types'; | import { getGrafanaSearcher } from 'app/features/search/service/searcher'; | ||||||
|  | import { DashboardQueryResult, LocationInfo, QueryResponse, SearchQuery } from 'app/features/search/service/types'; | ||||||
| import { StarToolbarButtonApiServer } from 'app/features/stars/StarToolbarButton'; | import { StarToolbarButtonApiServer } from 'app/features/stars/StarToolbarButton'; | ||||||
| import { useDispatch, useSelector } from 'app/types/store'; | import { useDispatch, useSelector } from 'app/types/store'; | ||||||
| 
 | 
 | ||||||
|  | @ -20,7 +20,11 @@ import { Options } from './panelcfg.gen'; | ||||||
| import { getStyles } from './styles'; | import { getStyles } from './styles'; | ||||||
| import { useDashListUrlParams } from './utils'; | import { useDashListUrlParams } from './utils'; | ||||||
| 
 | 
 | ||||||
| type Dashboard = DashboardSearchItem & { id?: number; isSearchResult?: boolean; isRecent?: boolean }; | type Dashboard = DashboardQueryResult & { | ||||||
|  |   isSearchResult?: boolean; | ||||||
|  |   isRecent?: boolean; | ||||||
|  |   isStarred?: boolean; | ||||||
|  | }; | ||||||
| 
 | 
 | ||||||
| interface DashboardGroup { | interface DashboardGroup { | ||||||
|   show: boolean; |   show: boolean; | ||||||
|  | @ -29,47 +33,58 @@ interface DashboardGroup { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async function fetchDashboards(options: Options, replaceVars: InterpolateFunction) { | async function fetchDashboards(options: Options, replaceVars: InterpolateFunction) { | ||||||
|   let starredDashboards: Promise<DashboardSearchItem[]> = Promise.resolve([]); |   const searcher = getGrafanaSearcher(); | ||||||
|  |   let starredDashboards: Promise<QueryResponse | void> = Promise.resolve(); | ||||||
|  |   let recentDashboards: Promise<QueryResponse | void> = Promise.resolve(); | ||||||
|  |   let searchedDashboards: Promise<QueryResponse | void> = Promise.resolve(); | ||||||
| 
 | 
 | ||||||
|   if (options.showStarred) { |   if (options.showStarred) { | ||||||
|     const params = { limit: options.maxItems, starred: 'true' }; |     const params: SearchQuery = { limit: options.maxItems, starred: true }; | ||||||
|     starredDashboards = getBackendSrv().search(params); |     starredDashboards = searcher.starred(params); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   let recentDashboards: Promise<DashboardSearchItem[]> = Promise.resolve([]); |  | ||||||
|   let dashUIDs: string[] = []; |   let dashUIDs: string[] = []; | ||||||
|   if (options.showRecentlyViewed) { |   if (options.showRecentlyViewed) { | ||||||
|     let uids = await impressionSrv.getDashboardOpened(); |     let uids = await impressionSrv.getDashboardOpened(); | ||||||
|     dashUIDs = take<string>(uids, options.maxItems); |     dashUIDs = take<string>(uids, options.maxItems); | ||||||
|     recentDashboards = getBackendSrv().search({ dashboardUIDs: dashUIDs, limit: options.maxItems }); | 
 | ||||||
|  |     recentDashboards = searcher.search({ uid: dashUIDs, limit: options.maxItems, kind: ['dashboard'] }); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   let searchedDashboards: Promise<DashboardSearchItem[]> = Promise.resolve([]); |  | ||||||
|   if (options.showSearch) { |   if (options.showSearch) { | ||||||
|     const uid = options.folderUID === '' ? 'general' : options.folderUID; |     const uid = options.folderUID === '' ? 'general' : options.folderUID; | ||||||
|     const params = { |     const params: SearchQuery = { | ||||||
|       limit: options.maxItems, |       limit: options.maxItems, | ||||||
|       query: replaceVars(options.query, {}, 'text'), |       query: replaceVars(options.query, {}, 'text'), | ||||||
|       folderUIDs: uid, |       location: uid, | ||||||
|       tag: options.tags.map((tag: string) => replaceVars(tag, {}, 'text')), |       tags: options.tags.map((tag: string) => replaceVars(tag, {}, 'text')), | ||||||
|       type: 'dash-db', |       kind: ['dashboard'], | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     searchedDashboards = getBackendSrv().search(params); |     searchedDashboards = searcher.search(params); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   const [starred, searched, recent] = await Promise.all([starredDashboards, searchedDashboards, recentDashboards]); |   const [starred, searched, recent] = await Promise.allSettled([ | ||||||
|  |     starredDashboards, | ||||||
|  |     searchedDashboards, | ||||||
|  |     recentDashboards, | ||||||
|  |   ]); | ||||||
| 
 | 
 | ||||||
|   // We deliberately deal with recent dashboards first so that the order of dash IDs is preserved
 |   // We deliberately deal with recent dashboards first so that the order of dash IDs is preserved
 | ||||||
|   let dashMap = new Map<string, Dashboard>(); |   let dashMap = new Map<string, DashboardQueryResult>(); | ||||||
|  |   if (recent && recent.status === 'fulfilled') { | ||||||
|     for (const dashUID of dashUIDs) { |     for (const dashUID of dashUIDs) { | ||||||
|     const dash = recent.find((d) => d.uid === dashUID); |       const dash = recent.value?.view.find((d: DashboardQueryResult): d is DashboardQueryResult => { | ||||||
|  |         return d.uid === dashUID; | ||||||
|  |       }); | ||||||
|       if (dash) { |       if (dash) { | ||||||
|       dashMap.set(dashUID, { ...dash, isRecent: true }); |         dashMap.set(dashUID, { ...dash, title: dash.name, isRecent: true }); | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   searched.forEach((dash) => { |   if (searched && searched.status === 'fulfilled') { | ||||||
|  |     searched?.value?.view.forEach((dash) => { | ||||||
|       if (!dash.uid) { |       if (!dash.uid) { | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
|  | @ -79,8 +94,10 @@ async function fetchDashboards(options: Options, replaceVars: InterpolateFunctio | ||||||
|         dashMap.set(dash.uid, { ...dash, isSearchResult: true }); |         dashMap.set(dash.uid, { ...dash, isSearchResult: true }); | ||||||
|       } |       } | ||||||
|     }); |     }); | ||||||
|  |   } | ||||||
| 
 | 
 | ||||||
|   starred.forEach((dash) => { |   if (starred && starred.status === 'fulfilled') { | ||||||
|  |     starred?.value?.view.forEach((dash) => { | ||||||
|       if (!dash.uid) { |       if (!dash.uid) { | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
|  | @ -90,12 +107,20 @@ async function fetchDashboards(options: Options, replaceVars: InterpolateFunctio | ||||||
|         dashMap.set(dash.uid, { ...dash, isStarred: true }); |         dashMap.set(dash.uid, { ...dash, isStarred: true }); | ||||||
|       } |       } | ||||||
|     }); |     }); | ||||||
|  |   } | ||||||
| 
 | 
 | ||||||
|   return dashMap; |   return dashMap; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | async function fetchDashboardFolders() { | ||||||
|  |   return getGrafanaSearcher().getLocationInfo(); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const collator = new Intl.Collator(); | ||||||
|  | 
 | ||||||
| export function DashList(props: PanelProps<Options>) { | export function DashList(props: PanelProps<Options>) { | ||||||
|   const [dashboards, setDashboards] = useState(new Map<string, Dashboard>()); |   const [dashboards, setDashboards] = useState(new Map<string, Dashboard>()); | ||||||
|  |   const [foldersTitleMap, setFoldersTitleMap] = useState<Record<string, LocationInfo>>({}); | ||||||
|   const dispatch = useDispatch(); |   const dispatch = useDispatch(); | ||||||
|   const navIndex = useSelector((state) => state.navIndex); |   const navIndex = useSelector((state) => state.navIndex); | ||||||
| 
 | 
 | ||||||
|  | @ -107,22 +132,30 @@ export function DashList(props: PanelProps<Options>) { | ||||||
|     }); |     }); | ||||||
|   }, [props.options, props.replaceVariables, throttledRenderCount]); |   }, [props.options, props.replaceVariables, throttledRenderCount]); | ||||||
| 
 | 
 | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (props.options.showFolderNames && dashboards.size > 0) { | ||||||
|  |       fetchDashboardFolders().then((locationInfo) => { | ||||||
|  |         setFoldersTitleMap(locationInfo); | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |   }, [props.options.showFolderNames, dashboards]); | ||||||
|  | 
 | ||||||
|   const toggleDashboardStar = async (e: SyntheticEvent, dash: Dashboard) => { |   const toggleDashboardStar = async (e: SyntheticEvent, dash: Dashboard) => { | ||||||
|     const { uid, title, url } = dash; |     const { uid, name, url } = dash; | ||||||
|     e.preventDefault(); |     e.preventDefault(); | ||||||
|     e.stopPropagation(); |     e.stopPropagation(); | ||||||
| 
 | 
 | ||||||
|     const isStarred = await getDashboardSrv().starDashboard(dash.uid, dash.isStarred); |     const isStarred = await getDashboardSrv().starDashboard(dash.uid, Boolean(dash.isStarred)); | ||||||
|     const updatedDashboards = new Map(dashboards); |     const updatedDashboards = new Map(dashboards); | ||||||
|     updatedDashboards.set(dash?.uid ?? '', { ...dash, isStarred }); |     updatedDashboards.set(dash?.uid ?? '', { ...dash, isStarred }); | ||||||
|     setDashboards(updatedDashboards); |     setDashboards(updatedDashboards); | ||||||
|     dispatch(setStarred({ id: uid ?? '', title, url, isStarred })); |     dispatch(setStarred({ id: uid ?? '', title: name, url, isStarred })); | ||||||
| 
 | 
 | ||||||
|     const starredNavItem = navIndex['starred']; |     const starredNavItem = navIndex.starred; | ||||||
|     if (isStarred) { |     if (isStarred) { | ||||||
|       starredNavItem.children?.push({ |       starredNavItem.children?.push({ | ||||||
|         id: ID_PREFIX + uid, |         id: ID_PREFIX + uid, | ||||||
|         text: title, |         text: name, | ||||||
|         url: url ?? '', |         url: url ?? '', | ||||||
|         parentItem: starredNavItem, |         parentItem: starredNavItem, | ||||||
|       }); |       }); | ||||||
|  | @ -138,10 +171,27 @@ export function DashList(props: PanelProps<Options>) { | ||||||
| 
 | 
 | ||||||
|   const [starredDashboards, recentDashboards, searchedDashboards] = useMemo(() => { |   const [starredDashboards, recentDashboards, searchedDashboards] = useMemo(() => { | ||||||
|     const dashboardList = [...dashboards.values()]; |     const dashboardList = [...dashboards.values()]; | ||||||
|  |     const dashboardsGroupsMap: Record<string, Dashboard[]> = { | ||||||
|  |       starred: [], | ||||||
|  |       recent: [], | ||||||
|  |       searched: [], | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     for (const dash of dashboardList) { | ||||||
|  |       if (dash.isStarred) { | ||||||
|  |         dashboardsGroupsMap.starred.push(dash); | ||||||
|  |       } | ||||||
|  |       if (dash.isRecent) { | ||||||
|  |         dashboardsGroupsMap.recent.push(dash); | ||||||
|  |       } | ||||||
|  |       if (dash.isSearchResult) { | ||||||
|  |         dashboardsGroupsMap.searched.push(dash); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|     return [ |     return [ | ||||||
|       dashboardList.filter((dash) => dash.isStarred).sort((a, b) => a.title.localeCompare(b.title)), |       dashboardsGroupsMap.starred.sort((a, b) => collator.compare(a.name, b.name)), | ||||||
|       dashboardList.filter((dash) => dash.isRecent), |       dashboardsGroupsMap.recent, | ||||||
|       dashboardList.filter((dash) => dash.isSearchResult).sort((a, b) => a.title.localeCompare(b.title)), |       dashboardsGroupsMap.searched.sort((a, b) => collator.compare(a.name, b.name)), | ||||||
|     ]; |     ]; | ||||||
|   }, [dashboards]); |   }, [dashboards]); | ||||||
| 
 | 
 | ||||||
|  | @ -149,17 +199,17 @@ export function DashList(props: PanelProps<Options>) { | ||||||
| 
 | 
 | ||||||
|   const dashboardGroups: DashboardGroup[] = [ |   const dashboardGroups: DashboardGroup[] = [ | ||||||
|     { |     { | ||||||
|       header: 'Starred dashboards', |       header: t('panel.dashlist.starred-dashboards', 'Starred dashboards'), | ||||||
|       dashboards: starredDashboards, |       dashboards: starredDashboards, | ||||||
|       show: showStarred, |       show: showStarred, | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       header: 'Recently viewed dashboards', |       header: t('panel.dashlist.recently-viewed-dashboards', 'Recently viewed dashboards'), | ||||||
|       dashboards: recentDashboards, |       dashboards: recentDashboards, | ||||||
|       show: showRecentlyViewed, |       show: showRecentlyViewed, | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       header: 'Search', |       header: t('panel.dashlist.search', 'Search'), | ||||||
|       dashboards: searchedDashboards, |       dashboards: searchedDashboards, | ||||||
|       show: showSearch, |       show: showSearch, | ||||||
|     }, |     }, | ||||||
|  | @ -173,21 +223,30 @@ export function DashList(props: PanelProps<Options>) { | ||||||
|       {dashboards.map((dash) => { |       {dashboards.map((dash) => { | ||||||
|         let url = dash.url + urlParams; |         let url = dash.url + urlParams; | ||||||
|         url = getConfig().disableSanitizeHtml ? url : textUtil.sanitizeUrl(url); |         url = getConfig().disableSanitizeHtml ? url : textUtil.sanitizeUrl(url); | ||||||
|  |         const markAsStarredText = t('panel.dashlist.mark-as-starred', 'Mark "{{title}}" as favorite', { | ||||||
|  |           title: dash.title, | ||||||
|  |         }); | ||||||
|  |         const unmarkAsStarredText = t('panel.dashlist.unmark-as-starred', 'Unmark "{{title}}" as favorite', { | ||||||
|  |           title: dash.title, | ||||||
|  |         }); | ||||||
| 
 | 
 | ||||||
|  |         const locationInfo = showFolderNames && dash.location ? foldersTitleMap[dash.location] : undefined; | ||||||
|         return ( |         return ( | ||||||
|           <li className={css.dashlistItem} key={`dash-${dash.uid}`}> |           <li key={`dash-${dash.uid}`}> | ||||||
|             <div className={css.dashlistLink}> |             <div className={css.dashlistLink}> | ||||||
|               <div className={css.dashlistLinkBody}> |               <Box flex={1}> | ||||||
|                 <a className={css.dashlistTitle} href={url}> |                 <Link href={url}>{dash.name}</Link> | ||||||
|                   {dash.title} |                 {showFolderNames && locationInfo && ( | ||||||
|                 </a> |                   <Text color="secondary" variant="bodySmall" element="p"> | ||||||
|                 {showFolderNames && dash.folderTitle && <div className={css.dashlistFolder}>{dash.folderTitle}</div>} |                     {locationInfo?.name} | ||||||
|               </div> |                   </Text> | ||||||
|  |                 )} | ||||||
|  |               </Box> | ||||||
|               {config.featureToggles.starsFromAPIServer ? ( |               {config.featureToggles.starsFromAPIServer ? ( | ||||||
|                 <StarToolbarButtonApiServer group="dashboard.grafana.app" kind="Dashboard" id={dash.uid ?? ''} /> |                 <StarToolbarButtonApiServer group="dashboard.grafana.app" kind="Dashboard" id={dash.uid ?? ''} /> | ||||||
|               ) : ( |               ) : ( | ||||||
|                 <IconButton |                 <IconButton | ||||||
|                   tooltip={dash.isStarred ? `Unmark "${dash.title}" as favorite` : `Mark "${dash.title}" as favorite`} |                   tooltip={dash.isStarred ? unmarkAsStarredText : markAsStarredText} | ||||||
|                   name={dash.isStarred ? 'favorite' : 'star'} |                   name={dash.isStarred ? 'favorite' : 'star'} | ||||||
|                   iconType={dash.isStarred ? 'mono' : 'default'} |                   iconType={dash.isStarred ? 'mono' : 'default'} | ||||||
|                   onClick={(e) => toggleDashboardStar(e, dash)} |                   onClick={(e) => toggleDashboardStar(e, dash)} | ||||||
|  | @ -200,15 +259,30 @@ export function DashList(props: PanelProps<Options>) { | ||||||
|     </ul> |     </ul> | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|  |   const showEmptyState = dashboardGroups.every(({ show }) => !show); | ||||||
|  | 
 | ||||||
|   return ( |   return ( | ||||||
|     <ScrollContainer minHeight="100%"> |     <ScrollContainer minHeight="100%"> | ||||||
|  |       {showEmptyState && ( | ||||||
|  |         <EmptyState | ||||||
|  |           hideImage | ||||||
|  |           variant="call-to-action" | ||||||
|  |           message={t('panel.dashlist.empty-state-message', 'No dashboard groups configured')} | ||||||
|  |         /> | ||||||
|  |       )} | ||||||
|       {dashboardGroups.map( |       {dashboardGroups.map( | ||||||
|         ({ show, header, dashboards }, i) => |         ({ show, header, dashboards }, i) => | ||||||
|           show && ( |           show && ( | ||||||
|             <div className={css.dashlistSection} key={`dash-group-${i}`}> |             <Box marginBottom={2} paddingTop={0.5} key={`dash-group-${i}`}> | ||||||
|               {showHeadings && <h6 className={css.dashlistSectionHeader}>{header}</h6>} |               {showHeadings && ( | ||||||
|  |                 <Box marginRight={1} paddingX={1} paddingY={0.25}> | ||||||
|  |                   <Text variant="h6" element="h6"> | ||||||
|  |                     {header} | ||||||
|  |                   </Text> | ||||||
|  |                 </Box> | ||||||
|  |               )} | ||||||
|               {renderList(dashboards)} |               {renderList(dashboards)} | ||||||
|             </div> |             </Box> | ||||||
|           ) |           ) | ||||||
|       )} |       )} | ||||||
|     </ScrollContainer> |     </ScrollContainer> | ||||||
|  |  | ||||||
|  | @ -4,14 +4,6 @@ import { GrafanaTheme2 } from '@grafana/data'; | ||||||
| 
 | 
 | ||||||
| export const getStyles = (theme: GrafanaTheme2) => { | export const getStyles = (theme: GrafanaTheme2) => { | ||||||
|   return { |   return { | ||||||
|     dashlistSectionHeader: css({ |  | ||||||
|       padding: theme.spacing(0.25, 1), |  | ||||||
|       marginRight: theme.spacing(1), |  | ||||||
|     }), |  | ||||||
|     dashlistSection: css({ |  | ||||||
|       marginBottom: theme.spacing(2), |  | ||||||
|       paddingTop: theme.spacing(0.5), |  | ||||||
|     }), |  | ||||||
|     dashlistLink: css({ |     dashlistLink: css({ | ||||||
|       display: 'flex', |       display: 'flex', | ||||||
|       cursor: 'pointer', |       cursor: 'pointer', | ||||||
|  | @ -27,27 +19,5 @@ export const getStyles = (theme: GrafanaTheme2) => { | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|     }), |     }), | ||||||
|     dashlistFolder: css({ |  | ||||||
|       color: theme.colors.text.secondary, |  | ||||||
|       fontSize: theme.typography.bodySmall.fontSize, |  | ||||||
|       lineHeight: theme.typography.body.lineHeight, |  | ||||||
|     }), |  | ||||||
|     dashlistTitle: css({ |  | ||||||
|       '&::after': { |  | ||||||
|         position: 'absolute', |  | ||||||
|         content: '""', |  | ||||||
|         left: 0, |  | ||||||
|         top: 0, |  | ||||||
|         bottom: 0, |  | ||||||
|         right: 0, |  | ||||||
|       }, |  | ||||||
|     }), |  | ||||||
|     dashlistLinkBody: css({ |  | ||||||
|       flexGrow: 1, |  | ||||||
|     }), |  | ||||||
|     dashlistItem: css({ |  | ||||||
|       position: 'relative', |  | ||||||
|       listStyle: 'none', |  | ||||||
|     }), |  | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | @ -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": { |   "panel": { | ||||||
|  |     "dashlist": { | ||||||
|  |       "empty-state-message": "No dashboard groups configured", | ||||||
|  |       "mark-as-starred": "Mark \"{{title}}\" as favorite", | ||||||
|  |       "recently-viewed-dashboards": "Recently viewed dashboards", | ||||||
|  |       "search": "Search", | ||||||
|  |       "starred-dashboards": "Starred dashboards", | ||||||
|  |       "unmark-as-starred": "Unmark \"{{title}}\" as favorite" | ||||||
|  |     }, | ||||||
|     "get-calculation-value-data-links-variable-suggestions": { |     "get-calculation-value-data-links-variable-suggestions": { | ||||||
|       "value-calc-var": { |       "value-calc-var": { | ||||||
|         "label": { |         "label": { | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue