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,50 +12,69 @@ const typeMap: Record<string, string> = {
|
|||
dashboard: 'dashboards',
|
||||
};
|
||||
|
||||
const typeFilterMap: Record<string, string> = {
|
||||
folders: 'folder',
|
||||
};
|
||||
|
||||
const getSearchHandler = () =>
|
||||
http.get('/apis/dashboard.grafana.app/v0alpha1/namespaces/:namespace/search', ({ request }) => {
|
||||
const limitFilter = new URL(request.url).searchParams.get('limit') || null;
|
||||
const folderFilter = new URL(request.url).searchParams.get('folder') || null;
|
||||
const typeFilter = new URL(request.url).searchParams.get('type') || null;
|
||||
const response = mockTree
|
||||
.filter((filterItem) => {
|
||||
const filters: FilterArray = [];
|
||||
const nameFilter = new URL(request.url).searchParams.getAll('name');
|
||||
const mappedTypeFilter = typeFilter ? typeFilterMap[typeFilter] || typeFilter : null;
|
||||
|
||||
if (typeFilter) {
|
||||
filters.push(({ item }) => item.kind === typeFilter);
|
||||
}
|
||||
const filtered = mockTree.filter((filterItem) => {
|
||||
const filters: FilterArray = [
|
||||
// Filter UI items out of fixtures as... they're UI items 🤷
|
||||
({ item }) => item.kind !== 'ui',
|
||||
];
|
||||
|
||||
if (folderFilter && folderFilter !== 'general') {
|
||||
filters.push(
|
||||
({ item }) => (item.kind === 'folder' || item.kind === 'dashboard') && item.parentUID === folderFilter
|
||||
);
|
||||
}
|
||||
if (nameFilter.length > 0) {
|
||||
const filteredNameFilter = nameFilter.filter((name) => name !== 'general');
|
||||
filters.push(({ item }) => filteredNameFilter.includes(item.uid));
|
||||
}
|
||||
|
||||
if (folderFilter === 'general') {
|
||||
filters.push(
|
||||
({ item }) => (item.kind === 'folder' || item.kind === 'dashboard') && item.parentUID === undefined
|
||||
);
|
||||
}
|
||||
if (typeFilter) {
|
||||
filters.push(({ item }) => item.kind === mappedTypeFilter);
|
||||
}
|
||||
|
||||
return filters.every((filterPredicate) => filterPredicate(filterItem));
|
||||
})
|
||||
if (folderFilter && folderFilter !== 'general') {
|
||||
filters.push(
|
||||
({ item }) => (item.kind === 'folder' || item.kind === 'dashboard') && item.parentUID === folderFilter
|
||||
);
|
||||
}
|
||||
|
||||
.map(({ item }) => {
|
||||
const random = Chance(item.uid);
|
||||
return {
|
||||
resource: typeMap[item.kind],
|
||||
name: item.uid,
|
||||
title: item.title,
|
||||
field: {
|
||||
// Generate mock deprecated IDs only in the mock handlers - not generating in
|
||||
// mock data as it would require updating/tracking in the types as well
|
||||
'grafana.app/deprecatedInternalID': random.integer({ min: 1, max: 1000 }),
|
||||
},
|
||||
};
|
||||
});
|
||||
if (folderFilter === 'general') {
|
||||
filters.push(
|
||||
({ item }) => (item.kind === 'folder' || item.kind === 'dashboard') && item.parentUID === undefined
|
||||
);
|
||||
}
|
||||
|
||||
return filters.every((filterPredicate) => filterPredicate(filterItem));
|
||||
});
|
||||
|
||||
const mapped = filtered.map(({ item }) => {
|
||||
const random = Chance(item.uid);
|
||||
const parentFolder = 'parentUID' in item ? item.parentUID : undefined;
|
||||
return {
|
||||
resource: typeMap[item.kind],
|
||||
name: item.uid,
|
||||
title: item.title,
|
||||
folder: parentFolder,
|
||||
field: {
|
||||
// Generate mock deprecated IDs only in the mock handlers - not generating in
|
||||
// mock data as it would require updating/tracking in the types as well
|
||||
'grafana.app/deprecatedInternalID': random.integer({ min: 1, max: 1000 }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const sliced = limitFilter ? mapped.slice(0, parseInt(limitFilter, 10)) : mapped;
|
||||
|
||||
return HttpResponse.json({
|
||||
totalHits: response.length,
|
||||
hits: response,
|
||||
totalHits: sliced.length,
|
||||
hits: sliced,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,73 +33,94 @@ interface DashboardGroup {
|
|||
}
|
||||
|
||||
async function fetchDashboards(options: Options, replaceVars: InterpolateFunction) {
|
||||
let starredDashboards: Promise<DashboardSearchItem[]> = Promise.resolve([]);
|
||||
const searcher = getGrafanaSearcher();
|
||||
let starredDashboards: Promise<QueryResponse | void> = Promise.resolve();
|
||||
let recentDashboards: Promise<QueryResponse | void> = Promise.resolve();
|
||||
let searchedDashboards: Promise<QueryResponse | void> = Promise.resolve();
|
||||
|
||||
if (options.showStarred) {
|
||||
const params = { limit: options.maxItems, starred: 'true' };
|
||||
starredDashboards = getBackendSrv().search(params);
|
||||
const params: SearchQuery = { limit: options.maxItems, starred: true };
|
||||
starredDashboards = searcher.starred(params);
|
||||
}
|
||||
|
||||
let recentDashboards: Promise<DashboardSearchItem[]> = Promise.resolve([]);
|
||||
let dashUIDs: string[] = [];
|
||||
if (options.showRecentlyViewed) {
|
||||
let uids = await impressionSrv.getDashboardOpened();
|
||||
dashUIDs = take<string>(uids, options.maxItems);
|
||||
recentDashboards = getBackendSrv().search({ dashboardUIDs: dashUIDs, limit: options.maxItems });
|
||||
|
||||
recentDashboards = searcher.search({ uid: dashUIDs, limit: options.maxItems, kind: ['dashboard'] });
|
||||
}
|
||||
|
||||
let searchedDashboards: Promise<DashboardSearchItem[]> = Promise.resolve([]);
|
||||
if (options.showSearch) {
|
||||
const uid = options.folderUID === '' ? 'general' : options.folderUID;
|
||||
const params = {
|
||||
const params: SearchQuery = {
|
||||
limit: options.maxItems,
|
||||
query: replaceVars(options.query, {}, 'text'),
|
||||
folderUIDs: uid,
|
||||
tag: options.tags.map((tag: string) => replaceVars(tag, {}, 'text')),
|
||||
type: 'dash-db',
|
||||
location: uid,
|
||||
tags: options.tags.map((tag: string) => replaceVars(tag, {}, 'text')),
|
||||
kind: ['dashboard'],
|
||||
};
|
||||
|
||||
searchedDashboards = getBackendSrv().search(params);
|
||||
searchedDashboards = searcher.search(params);
|
||||
}
|
||||
|
||||
const [starred, searched, recent] = await Promise.all([starredDashboards, searchedDashboards, recentDashboards]);
|
||||
const [starred, searched, recent] = await Promise.allSettled([
|
||||
starredDashboards,
|
||||
searchedDashboards,
|
||||
recentDashboards,
|
||||
]);
|
||||
|
||||
// We deliberately deal with recent dashboards first so that the order of dash IDs is preserved
|
||||
let dashMap = new Map<string, Dashboard>();
|
||||
for (const dashUID of dashUIDs) {
|
||||
const dash = recent.find((d) => d.uid === dashUID);
|
||||
if (dash) {
|
||||
dashMap.set(dashUID, { ...dash, isRecent: true });
|
||||
let dashMap = new Map<string, DashboardQueryResult>();
|
||||
if (recent && recent.status === 'fulfilled') {
|
||||
for (const dashUID of dashUIDs) {
|
||||
const dash = recent.value?.view.find((d: DashboardQueryResult): d is DashboardQueryResult => {
|
||||
return d.uid === dashUID;
|
||||
});
|
||||
if (dash) {
|
||||
dashMap.set(dashUID, { ...dash, title: dash.name, isRecent: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
searched.forEach((dash) => {
|
||||
if (!dash.uid) {
|
||||
return;
|
||||
}
|
||||
if (dashMap.has(dash.uid)) {
|
||||
dashMap.get(dash.uid)!.isSearchResult = true;
|
||||
} else {
|
||||
dashMap.set(dash.uid, { ...dash, isSearchResult: true });
|
||||
}
|
||||
});
|
||||
if (searched && searched.status === 'fulfilled') {
|
||||
searched?.value?.view.forEach((dash) => {
|
||||
if (!dash.uid) {
|
||||
return;
|
||||
}
|
||||
if (dashMap.has(dash.uid)) {
|
||||
dashMap.get(dash.uid)!.isSearchResult = true;
|
||||
} else {
|
||||
dashMap.set(dash.uid, { ...dash, isSearchResult: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
starred.forEach((dash) => {
|
||||
if (!dash.uid) {
|
||||
return;
|
||||
}
|
||||
if (dashMap.has(dash.uid)) {
|
||||
dashMap.get(dash.uid)!.isStarred = true;
|
||||
} else {
|
||||
dashMap.set(dash.uid, { ...dash, isStarred: true });
|
||||
}
|
||||
});
|
||||
if (starred && starred.status === 'fulfilled') {
|
||||
starred?.value?.view.forEach((dash) => {
|
||||
if (!dash.uid) {
|
||||
return;
|
||||
}
|
||||
if (dashMap.has(dash.uid)) {
|
||||
dashMap.get(dash.uid)!.isStarred = true;
|
||||
} else {
|
||||
dashMap.set(dash.uid, { ...dash, isStarred: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return dashMap;
|
||||
}
|
||||
|
||||
async function fetchDashboardFolders() {
|
||||
return getGrafanaSearcher().getLocationInfo();
|
||||
}
|
||||
|
||||
const collator = new Intl.Collator();
|
||||
|
||||
export function DashList(props: PanelProps<Options>) {
|
||||
const [dashboards, setDashboards] = useState(new Map<string, Dashboard>());
|
||||
const [foldersTitleMap, setFoldersTitleMap] = useState<Record<string, LocationInfo>>({});
|
||||
const dispatch = useDispatch();
|
||||
const navIndex = useSelector((state) => state.navIndex);
|
||||
|
||||
|
|
@ -107,22 +132,30 @@ export function DashList(props: PanelProps<Options>) {
|
|||
});
|
||||
}, [props.options, props.replaceVariables, throttledRenderCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.options.showFolderNames && dashboards.size > 0) {
|
||||
fetchDashboardFolders().then((locationInfo) => {
|
||||
setFoldersTitleMap(locationInfo);
|
||||
});
|
||||
}
|
||||
}, [props.options.showFolderNames, dashboards]);
|
||||
|
||||
const toggleDashboardStar = async (e: SyntheticEvent, dash: Dashboard) => {
|
||||
const { uid, title, url } = dash;
|
||||
const { uid, name, url } = dash;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const isStarred = await getDashboardSrv().starDashboard(dash.uid, dash.isStarred);
|
||||
const isStarred = await getDashboardSrv().starDashboard(dash.uid, Boolean(dash.isStarred));
|
||||
const updatedDashboards = new Map(dashboards);
|
||||
updatedDashboards.set(dash?.uid ?? '', { ...dash, isStarred });
|
||||
setDashboards(updatedDashboards);
|
||||
dispatch(setStarred({ id: uid ?? '', title, url, isStarred }));
|
||||
dispatch(setStarred({ id: uid ?? '', title: name, url, isStarred }));
|
||||
|
||||
const starredNavItem = navIndex['starred'];
|
||||
const starredNavItem = navIndex.starred;
|
||||
if (isStarred) {
|
||||
starredNavItem.children?.push({
|
||||
id: ID_PREFIX + uid,
|
||||
text: title,
|
||||
text: name,
|
||||
url: url ?? '',
|
||||
parentItem: starredNavItem,
|
||||
});
|
||||
|
|
@ -138,10 +171,27 @@ export function DashList(props: PanelProps<Options>) {
|
|||
|
||||
const [starredDashboards, recentDashboards, searchedDashboards] = useMemo(() => {
|
||||
const dashboardList = [...dashboards.values()];
|
||||
const dashboardsGroupsMap: Record<string, Dashboard[]> = {
|
||||
starred: [],
|
||||
recent: [],
|
||||
searched: [],
|
||||
};
|
||||
|
||||
for (const dash of dashboardList) {
|
||||
if (dash.isStarred) {
|
||||
dashboardsGroupsMap.starred.push(dash);
|
||||
}
|
||||
if (dash.isRecent) {
|
||||
dashboardsGroupsMap.recent.push(dash);
|
||||
}
|
||||
if (dash.isSearchResult) {
|
||||
dashboardsGroupsMap.searched.push(dash);
|
||||
}
|
||||
}
|
||||
return [
|
||||
dashboardList.filter((dash) => dash.isStarred).sort((a, b) => a.title.localeCompare(b.title)),
|
||||
dashboardList.filter((dash) => dash.isRecent),
|
||||
dashboardList.filter((dash) => dash.isSearchResult).sort((a, b) => a.title.localeCompare(b.title)),
|
||||
dashboardsGroupsMap.starred.sort((a, b) => collator.compare(a.name, b.name)),
|
||||
dashboardsGroupsMap.recent,
|
||||
dashboardsGroupsMap.searched.sort((a, b) => collator.compare(a.name, b.name)),
|
||||
];
|
||||
}, [dashboards]);
|
||||
|
||||
|
|
@ -149,17 +199,17 @@ export function DashList(props: PanelProps<Options>) {
|
|||
|
||||
const dashboardGroups: DashboardGroup[] = [
|
||||
{
|
||||
header: 'Starred dashboards',
|
||||
header: t('panel.dashlist.starred-dashboards', 'Starred dashboards'),
|
||||
dashboards: starredDashboards,
|
||||
show: showStarred,
|
||||
},
|
||||
{
|
||||
header: 'Recently viewed dashboards',
|
||||
header: t('panel.dashlist.recently-viewed-dashboards', 'Recently viewed dashboards'),
|
||||
dashboards: recentDashboards,
|
||||
show: showRecentlyViewed,
|
||||
},
|
||||
{
|
||||
header: 'Search',
|
||||
header: t('panel.dashlist.search', 'Search'),
|
||||
dashboards: searchedDashboards,
|
||||
show: showSearch,
|
||||
},
|
||||
|
|
@ -173,21 +223,30 @@ export function DashList(props: PanelProps<Options>) {
|
|||
{dashboards.map((dash) => {
|
||||
let url = dash.url + urlParams;
|
||||
url = getConfig().disableSanitizeHtml ? url : textUtil.sanitizeUrl(url);
|
||||
const markAsStarredText = t('panel.dashlist.mark-as-starred', 'Mark "{{title}}" as favorite', {
|
||||
title: dash.title,
|
||||
});
|
||||
const unmarkAsStarredText = t('panel.dashlist.unmark-as-starred', 'Unmark "{{title}}" as favorite', {
|
||||
title: dash.title,
|
||||
});
|
||||
|
||||
const locationInfo = showFolderNames && dash.location ? foldersTitleMap[dash.location] : undefined;
|
||||
return (
|
||||
<li className={css.dashlistItem} key={`dash-${dash.uid}`}>
|
||||
<li key={`dash-${dash.uid}`}>
|
||||
<div className={css.dashlistLink}>
|
||||
<div className={css.dashlistLinkBody}>
|
||||
<a className={css.dashlistTitle} href={url}>
|
||||
{dash.title}
|
||||
</a>
|
||||
{showFolderNames && dash.folderTitle && <div className={css.dashlistFolder}>{dash.folderTitle}</div>}
|
||||
</div>
|
||||
<Box flex={1}>
|
||||
<Link href={url}>{dash.name}</Link>
|
||||
{showFolderNames && locationInfo && (
|
||||
<Text color="secondary" variant="bodySmall" element="p">
|
||||
{locationInfo?.name}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{config.featureToggles.starsFromAPIServer ? (
|
||||
<StarToolbarButtonApiServer group="dashboard.grafana.app" kind="Dashboard" id={dash.uid ?? ''} />
|
||||
) : (
|
||||
<IconButton
|
||||
tooltip={dash.isStarred ? `Unmark "${dash.title}" as favorite` : `Mark "${dash.title}" as favorite`}
|
||||
tooltip={dash.isStarred ? unmarkAsStarredText : markAsStarredText}
|
||||
name={dash.isStarred ? 'favorite' : 'star'}
|
||||
iconType={dash.isStarred ? 'mono' : 'default'}
|
||||
onClick={(e) => toggleDashboardStar(e, dash)}
|
||||
|
|
@ -200,15 +259,30 @@ export function DashList(props: PanelProps<Options>) {
|
|||
</ul>
|
||||
);
|
||||
|
||||
const showEmptyState = dashboardGroups.every(({ show }) => !show);
|
||||
|
||||
return (
|
||||
<ScrollContainer minHeight="100%">
|
||||
{showEmptyState && (
|
||||
<EmptyState
|
||||
hideImage
|
||||
variant="call-to-action"
|
||||
message={t('panel.dashlist.empty-state-message', 'No dashboard groups configured')}
|
||||
/>
|
||||
)}
|
||||
{dashboardGroups.map(
|
||||
({ show, header, dashboards }, i) =>
|
||||
show && (
|
||||
<div className={css.dashlistSection} key={`dash-group-${i}`}>
|
||||
{showHeadings && <h6 className={css.dashlistSectionHeader}>{header}</h6>}
|
||||
<Box marginBottom={2} paddingTop={0.5} key={`dash-group-${i}`}>
|
||||
{showHeadings && (
|
||||
<Box marginRight={1} paddingX={1} paddingY={0.25}>
|
||||
<Text variant="h6" element="h6">
|
||||
{header}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{renderList(dashboards)}
|
||||
</div>
|
||||
</Box>
|
||||
)
|
||||
)}
|
||||
</ScrollContainer>
|
||||
|
|
|
|||
|
|
@ -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