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