Dashboard Picker: Update to use correct search + dashboards APIs (#112341)

This commit is contained in:
Tom Ratcliffe
2025-10-24 14:00:16 +01:00
committed by GitHub
parent 466f1b8271
commit 546d3ec313
12 changed files with 363 additions and 395 deletions
-5
View File
@@ -1722,11 +1722,6 @@
"count": 2
}
},
"public/app/features/alerting/unified/components/rule-editor/DashboardPicker.tsx": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/alerting/unified/components/rule-editor/ExpressionEditor.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
@@ -1,11 +1,13 @@
import { HttpHandler } from 'msw';
import dashboardsHandlers from './api/dashboards/handlers';
import folderHandlers from './api/folders/handlers';
import pluginsHandlers from './api/plugins/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 appPlatformDashboardv1beta1Handlers from './apis/dashboard.grafana.app/v1beta1/handlers';
import appPlatformFolderv1beta1Handlers from './apis/folder.grafana.app/v1beta1/handlers';
import appPlatformIamv0alpha1Handlers from './apis/iam.grafana.app/v0alpha1/handlers';
import appPlatformPreferencesv1alpha1Handlers from './apis/preferences.grafana.app/v1alpha1/handlers';
@@ -13,6 +15,7 @@ import appPlatformPreferencesv1alpha1Handlers from './apis/preferences.grafana.a
const allHandlers: HttpHandler[] = [
// Legacy handlers
...teamsHandlers,
...dashboardsHandlers,
...folderHandlers,
...searchHandlers,
...pluginsHandlers,
@@ -20,6 +23,7 @@ const allHandlers: HttpHandler[] = [
// App platform handlers
...appPlatformDashboardv0alpha1Handlers,
...appPlatformDashboardv1beta1Handlers,
...appPlatformFolderv1beta1Handlers,
...appPlatformIamv0alpha1Handlers,
...appPlatformPreferencesv1alpha1Handlers,
@@ -0,0 +1,34 @@
import { HttpResponse, http } from 'msw';
import { wellFormedTree } from '../../../fixtures/folders';
const [mockTree] = wellFormedTree();
const getDashboardHandler = () =>
http.get<{ uid: string }>('/api/dashboards/uid/:uid', ({ params }) => {
const { uid } = params;
const dashboard = mockTree.find((v) => v.item.uid === uid);
if (!dashboard || dashboard.item.kind !== 'dashboard') {
return HttpResponse.json({ message: 'Dashboard not found' }, { status: 404 });
}
const { item } = dashboard;
const parentFolder = mockTree.find((v) => v.item.kind === 'folder' && v.item.uid === item.parentUID);
return HttpResponse.json({
meta: {
folderTitle: parentFolder?.item.title,
folderUid: parentFolder?.item.uid,
},
dashboard: {
title: item.title,
uid: item.uid,
},
});
});
const handlers = [getDashboardHandler()];
export default handlers;
@@ -1,6 +1,8 @@
import { HttpResponse, http } from 'msw';
import { wellFormedTree } from '../../../fixtures/folders';
import { mockStarredDashboardsMap } from '../../../fixtures/starred';
const [_, { dashbdD }] = wellFormedTree();
const getStarsHandler = () =>
http.get('/api/user/stars', async () => {
@@ -21,6 +23,31 @@ const addDashboardStarHandler = () =>
return HttpResponse.json({ message: 'Dashboard starred!' });
});
const handlers = [getStarsHandler(), deleteDashboardStarHandler(), addDashboardStarHandler()];
const getPreferencesHandler = () =>
http.get('/api/user/preferences', async () => {
return HttpResponse.json({
homeDashboardUID: dashbdD.item.uid,
theme: 'light',
timezone: 'browser',
weekStart: 'monday',
queryHistory: {
homeTab: '',
},
language: '',
});
});
const updatePreferencesHandler = () =>
http.put('/api/user/preferences', async () => {
return HttpResponse.json({ message: 'Preferences updated' });
});
const handlers = [
getPreferencesHandler(),
updatePreferencesHandler(),
getStarsHandler(),
deleteDashboardStarHandler(),
addDashboardStarHandler(),
];
export default handlers;
@@ -0,0 +1,53 @@
import { HttpResponse, http } from 'msw';
import { wellFormedTree } from '../../../../fixtures/folders';
import { getErrorResponse } from '../../../helpers';
const [mockTree] = wellFormedTree();
const dashboardsTree = mockTree.filter(({ item }) => item.kind === 'dashboard');
const dashboardToAppPlatform = (dashboard: (typeof mockTree)[number]['item']) => {
return {
kind: 'DashboardWithAccessInfo',
apiVersion: 'dashboard.grafana.app/v1beta1',
metadata: {
name: dashboard.uid,
namespace: 'default',
uid: dashboard.uid,
creationTimestamp: '2023-01-01T00:00:00Z',
annotations: {
// TODO: Eventually generalise annotations in fixture data, as required by tests
'grafana.app/folder': dashboard.kind === 'dashboard' ? dashboard.parentUID : undefined,
},
labels: {},
},
spec: {
title: dashboard.title,
// TODO: Eventually add more fields to be more accurate to API response, as required by tests
},
status: {},
// TODO: Eventually add access properties, as required by tests
};
};
const getDashboardDto = () =>
http.get<{ namespace: string; uid: string }>(
'/apis/dashboard.grafana.app/v1beta1/namespaces/:namespace/dashboards/:uid/dto',
({ params }) => {
const { uid } = params;
const matchingDashboard = dashboardsTree.find(({ item }) => {
return item.uid === uid;
});
if (!matchingDashboard) {
return HttpResponse.json(getErrorResponse(`dashboards.dashboard.grafana.app "${uid}" not found`, 404), {
status: 404,
});
}
return HttpResponse.json(dashboardToAppPlatform(matchingDashboard.item));
}
);
export default [getDashboardDto()];
@@ -1,133 +1,68 @@
import { noop } from 'lodash';
import { Props } from 'react-virtualized-auto-sizer';
import { render, screen, userEvent, waitFor } from 'test/test-utils';
import { render, screen, testWithFeatureToggles } from 'test/test-utils';
import { defaultDashboard as defaultDashboardData } from '@grafana/schema';
import {
Spec as DashboardV2Spec,
defaultSpec as defaultDashboardV2Spec,
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
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 { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { DashboardSearchItemType } from 'app/features/search/types';
import { DashboardDTO } from 'app/types/dashboard';
import { DashboardPicker } from './DashboardPicker';
jest.mock('app/core/services/backend_srv', () => ({
...jest.requireActual('app/core/services/backend_srv'),
backendSrv: {
...jest.requireActual('app/core/services/backend_srv').backendSrv,
search: jest.fn(),
},
}));
setBackendSrv(backendSrv);
setupMockServer();
const getDashboardDTO = jest.fn();
jest.mock('app/features/dashboard/api/dashboard_api', () => ({
getDashboardAPI: () => ({
getDashboardDTO: getDashboardDTO,
}),
}));
jest.mock('react-virtualized-auto-sizer', () => {
return ({ children }: Props) =>
children({
height: 600,
scaledHeight: 600,
scaledWidth: 1,
width: 1,
});
});
jest.mocked(backendSrv.search).mockResolvedValue([
{
uid: 'dash-1',
type: DashboardSearchItemType.DashDB,
title: 'Dashboard 1',
uri: '',
url: '',
tags: [],
isStarred: false,
},
{
uid: 'dash-2',
type: DashboardSearchItemType.DashDB,
title: 'Dashboard 2',
uri: '',
url: '',
tags: [],
isStarred: false,
},
{
uid: 'dash-3',
type: DashboardSearchItemType.DashDB,
title: 'Dashboard 3',
uri: '',
url: '',
tags: [],
isStarred: false,
},
]);
const mockDashboard: DashboardDTO = {
dashboard: {
...defaultDashboardData,
uid: 'dash-2',
title: 'Dashboard 2',
},
meta: {},
};
const mockDashboardV2: DashboardWithAccessInfo<DashboardV2Spec> = {
apiVersion: 'v2beta1',
kind: 'DashboardWithAccessInfo',
spec: {
...defaultDashboardV2Spec(),
title: 'Dashboard 2',
},
metadata: {
name: 'dash-2',
resourceVersion: '0',
creationTimestamp: '0',
annotations: {},
},
access: {
canEdit: true,
canSave: true,
canStar: true,
canShare: true,
},
};
const [_, { folderA, folderA_dashbdD }] = getFolderFixtures();
const fixtures: Array<
[
// Test title
string,
// Feature toggle setup
Parameters<typeof testWithFeatureToggles>[0],
]
> = [
['app platform APIs enabled', { enable: ['kubernetesDashboards'] }],
['app platform APIs disabled', {}],
];
describe('DashboardPicker', () => {
describe.each([
['v1', mockDashboard],
['v2', mockDashboardV2],
])('Dashboard %s', (format, dashboard) => {
beforeEach(() => {
getDashboardDTO.mockResolvedValue(dashboard);
});
describe.each(fixtures)('%s', (_title, featureToggleSetup) => {
const onChange = jest.fn();
testWithFeatureToggles(featureToggleSetup);
it('should fetch and display dashboards', async () => {
render(<DashboardPicker value="dash-2" onChange={noop} />);
render(<DashboardPicker value={folderA_dashbdD.item.uid} />);
await waitFor(() => {
expect(screen.getByText('Dashboards/Dashboard 2')).toBeInTheDocument();
expect(getDashboardDTO).toHaveBeenCalledWith('dash-2', undefined);
});
expect(await screen.findByText(`${folderA.item.title}/${folderA_dashbdD.item.title}`)).toBeInTheDocument();
});
it('should search for dashboards', async () => {
render(<DashboardPicker onChange={noop} />);
it('should search for dashboards and allow selection', async () => {
const { user } = render(<DashboardPicker onChange={onChange} />);
await userEvent.type(screen.getByRole('combobox'), 'Dashboard 2');
const expectedDash = folderA_dashbdD.item;
const expectedFolder = folderA.item;
await waitFor(() => {
expect(screen.getByText('Dashboards/Dashboard 2')).toBeInTheDocument();
});
await user.type(screen.getByRole('combobox'), expectedDash.title);
expect(backendSrv.search).toHaveBeenCalledWith({ type: 'dash-db', query: 'Dashboard 2', limit: 100 });
expect(await screen.findByText(`${expectedFolder.title}/${expectedDash.title}`)).toBeInTheDocument();
await user.click(screen.getByText(`${expectedFolder.title}/${expectedDash.title}`));
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
folderTitle: expectedFolder.title,
folderUid: expectedFolder.uid,
name: expectedDash.title,
uid: expectedDash.uid,
})
);
});
});
xdescribe('dashboard v2 (v2beta1 API)', () => {
testWithFeatureToggles({ enable: ['dashboardNewLayouts', 'kubernetesDashboards'] });
it('renders dashboard correctly', async () => {
render(<DashboardPicker value="v2-special-case-override" />);
expect(await screen.findByText('TODO')).toBeInTheDocument();
});
});
});
@@ -3,11 +3,11 @@ import { forwardRef, useCallback, useEffect, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { AsyncSelectProps, AsyncSelect } from '@grafana/ui';
import { backendSrv } from 'app/core/services/backend_srv';
import { AnnoKeyFolder, AnnoKeyFolderTitle } from 'app/features/apiserver/types';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { isDashboardV2Resource } from 'app/features/dashboard/api/utils';
import { DashboardSearchItem } from 'app/features/search/types';
import { getGrafanaSearcher } from 'app/features/search/service/searcher';
import { DashboardQueryResult } from 'app/features/search/service/types';
import { DashboardDTO } from 'app/types/dashboard';
interface Props extends Omit<AsyncSelectProps<DashboardPickerDTO>, 'value' | 'onChange' | 'loadOptions' | ''> {
@@ -15,23 +15,26 @@ interface Props extends Omit<AsyncSelectProps<DashboardPickerDTO>, 'value' | 'on
onChange?: (value?: DashboardPickerDTO) => void;
}
export type DashboardPickerDTO = Pick<DashboardDTO['dashboard'], 'uid' | 'title'> &
export type DashboardPickerDTO = Pick<DashboardQueryResult, 'uid' | 'name'> &
Pick<DashboardDTO['meta'], 'folderUid' | 'folderTitle'>;
const formatLabel = (folderTitle = 'Dashboards', dashboardTitle: string) => `${folderTitle}/${dashboardTitle}`;
async function findDashboards(query = '') {
return backendSrv.search({ type: 'dash-db', query, limit: 100 }).then((result: DashboardSearchItem[]) => {
return result.map((item: DashboardSearchItem) => ({
const result = await getGrafanaSearcher().search({ query, kind: ['dashboard'], limit: 100 });
const locationInfo = await getGrafanaSearcher().getLocationInfo();
return result.view.toArray().map((item) => {
const folderTitle = locationInfo[item.location]?.name;
return {
value: {
// dashboards uid here is always defined as this endpoint does not return the default home dashboard
uid: item.uid!,
title: item.title,
folderTitle: item.folderTitle,
folderUid: item.folderUid,
uid: item.uid,
name: item.name,
folderTitle,
folderUid: item.location,
},
label: formatLabel(item?.folderTitle, item.title),
}));
label: formatLabel(folderTitle, item.name),
};
});
}
@@ -58,7 +61,7 @@ export const DashboardPicker = forwardRef<HTMLElement, Props>(
setCurrent({
value: {
uid: dto.metadata.name,
title: dto.spec.title,
name: dto.spec.title,
folderTitle: dto.metadata.annotations?.[AnnoKeyFolderTitle],
folderUid: dto.metadata.annotations?.[AnnoKeyFolder],
},
@@ -69,7 +72,7 @@ export const DashboardPicker = forwardRef<HTMLElement, Props>(
setCurrent({
value: {
uid: dto.dashboard.uid,
title: dto.dashboard.title,
name: dto.dashboard.title,
folderTitle: dto.meta.folderTitle,
folderUid: dto.meta.folderUid,
},
@@ -1,197 +1,136 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { comboboxTestSetup } from 'test/helpers/comboboxTestSetup';
import { getSelectParent, selectOptionInTest } from 'test/helpers/selectOptionInTest';
import { render, screen, userEvent, waitFor, within } from 'test/test-utils';
import { Preferences as UserPreferencesDTO } from '@grafana/schema/src/raw/preferences/x/preferences_types.gen';
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 { captureRequests } from 'app/features/alerting/unified/mocks/server/events';
import SharedPreferences from './SharedPreferences';
const selectComboboxOptionInTest = async (input: HTMLElement, optionOrOptions: string) => {
await userEvent.click(input);
const option = await screen.findByRole('option', { name: optionOrOptions });
await userEvent.click(option);
setBackendSrv(backendSrv);
setupMockServer();
const getPrefsUpdateRequest = async (requests: Request[]) => {
const prefsUpdate = requests.find((r) => r.url.match('/preferences') && r.method === 'PUT');
return prefsUpdate!.clone().json();
};
jest.mock('app/features/dashboard/api/dashboard_api', () => ({
getDashboardAPI: () => ({
getDashboardDTO: jest.fn().mockResolvedValue({
dashboard: {
id: 2,
title: 'My Dashboard',
uid: 'myDash',
templating: {
list: [],
},
panels: [],
},
meta: {},
}),
}),
}));
const [_, { dashbdD, dashbdE }] = getFolderFixtures();
jest.mock('app/core/services/backend_srv', () => {
return {
backendSrv: {
search: jest.fn().mockResolvedValue([
{
id: 2,
title: 'My Dashboard',
tags: [],
type: '',
uid: 'myDash',
uri: '',
url: '',
folderId: 0,
folderTitle: '',
folderUid: '',
folderUrl: '',
isStarred: true,
slug: '',
items: [],
},
{
id: 3,
title: 'Another Dashboard',
tags: [],
type: '',
uid: 'anotherDash',
uri: '',
url: '',
folderId: 0,
folderTitle: '',
folderUid: '',
folderUrl: '',
isStarred: true,
slug: '',
items: [],
},
]),
const selectComboboxOptionInTest = async (input: HTMLElement, optionOrOptions: string | RegExp) => {
const user = userEvent.setup();
await user.click(input);
const option = await screen.findByRole('option', { name: optionOrOptions });
await user.click(option);
};
const setup = async () => {
const view = render(<SharedPreferences resourceUri="user" preferenceType="user" />);
const themeSelect = await screen.findByRole('combobox', { name: 'Interface theme' });
await waitFor(() => expect(themeSelect).not.toBeDisabled());
return view;
};
const original = window.location;
const mockReload = jest.fn();
beforeAll(() => {
Object.defineProperty(window, 'location', {
writable: true,
value: {
...original,
reload: mockReload,
},
};
});
comboboxTestSetup();
});
const mockPreferences: UserPreferencesDTO = {
timezone: 'browser',
weekStart: 'monday',
theme: 'light',
homeDashboardUID: 'myDash',
queryHistory: {
homeTab: '',
},
language: '',
};
const defaultPreferences: UserPreferencesDTO = {
timezone: '',
weekStart: '',
theme: '',
homeDashboardUID: '',
queryHistory: {
homeTab: '',
},
language: '',
};
const mockPrefsPatch = jest.fn().mockResolvedValue(undefined);
const mockPrefsUpdate = jest.fn().mockResolvedValue(undefined);
const mockPrefsLoad = jest.fn().mockResolvedValue(mockPreferences);
jest.mock('app/core/services/PreferencesService', () => ({
PreferencesService: function () {
return {
patch: mockPrefsPatch,
update: mockPrefsUpdate,
load: mockPrefsLoad,
};
},
}));
const props = {
resourceUri: '/fake-api/user/1',
preferenceType: 'user' as const,
};
afterAll(() => {
Object.defineProperty(window, 'location', {
writable: true,
value: original,
});
});
describe('SharedPreferences', () => {
const original = window.location;
const mockReload = jest.fn();
beforeAll(() => {
Object.defineProperty(window, 'location', {
configurable: true,
value: { reload: mockReload },
});
comboboxTestSetup();
});
afterAll(() => {
Object.defineProperty(window, 'location', { configurable: true, value: original });
});
beforeEach(async () => {
render(<SharedPreferences {...props} />);
await waitFor(() => expect(mockPrefsLoad).toHaveBeenCalled());
});
it('renders the theme preference', async () => {
await setup();
const themeSelect = await screen.findByRole('combobox', { name: 'Interface theme' });
expect(themeSelect).toHaveValue('Light');
await waitFor(() => expect(themeSelect).toHaveValue('Light'));
});
it('renders the home dashboard preference', async () => {
await setup();
const dashboardSelect = getSelectParent(screen.getByLabelText('Home Dashboard'));
await waitFor(() => {
expect(dashboardSelect).toHaveTextContent('My Dashboard');
expect(dashboardSelect).toHaveTextContent(dashbdD.item.title);
});
});
it('renders the timezone preference', () => {
it('renders the timezone preference', async () => {
await setup();
const tzSelect = getSelectParent(screen.getByLabelText('Timezone'));
expect(tzSelect).toHaveTextContent('Browser Time');
});
it('renders the week start preference', async () => {
await setup();
const weekSelect = await screen.findByRole('combobox', { name: 'Week start' });
expect(weekSelect).toHaveValue('Monday');
});
it('renders the default language preference', async () => {
await setup();
const langSelect = await screen.findByRole('combobox', { name: /language/i });
expect(langSelect).toHaveValue('Default');
});
it('does not render the pseudo-locale', async () => {
const { user } = await setup();
const langSelect = await screen.findByRole('combobox', { name: /language/i });
// Open the combobox and wait for the options to be rendered
await userEvent.click(langSelect);
await user.click(langSelect);
expect((await screen.findAllByRole('option'))[0]).toBeInTheDocument();
// TODO: The input value should be cleared when clicked, but for some reason it's not?
// checking langSelect.value beforehand indicates that it is cleared, but after using
// userEvent.type the default value comes back?
await userEvent.type(
langSelect,
'{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}Pseudo'
);
await user.clear(langSelect);
await user.type(langSelect, 'Pseudo', {
// Don't click on the element again when typing as this would just re-set the value
skipClick: true,
});
const option = screen.queryByRole('option', { name: 'Pseudo-locale' });
expect(option).not.toBeInTheDocument();
});
it('saves the users new preferences', async () => {
const dashboardToSelect = dashbdE.item;
const capture = captureRequests();
const { user } = await setup();
await selectComboboxOptionInTest(await screen.findByRole('combobox', { name: 'Interface theme' }), 'Dark');
await selectComboboxOptionInTest(
await screen.findByRole('combobox', { name: /home dashboard/i }),
new RegExp(dashboardToSelect.title)
);
await selectOptionInTest(screen.getByLabelText('Timezone'), 'Australia/Sydney');
await selectComboboxOptionInTest(await screen.findByRole('combobox', { name: 'Week start' }), 'Saturday');
await selectComboboxOptionInTest(await screen.findByRole('combobox', { name: /language/i }), 'Français');
await userEvent.click(screen.getByText('Save'));
await user.click(screen.getByText('Save'));
expect(mockPrefsUpdate).toHaveBeenCalledWith({
const requests = await capture;
const newPreferences = await getPrefsUpdateRequest(requests);
expect(newPreferences).toEqual({
timezone: 'Australia/Sydney',
weekStart: 'saturday',
theme: 'dark',
homeDashboardUID: 'myDash',
homeDashboardUID: dashboardToSelect.uid,
queryHistory: {
homeTab: '',
},
@@ -200,12 +139,14 @@ describe('SharedPreferences', () => {
});
it('saves the users default preferences', async () => {
const capture = captureRequests();
const { user } = await setup();
await selectComboboxOptionInTest(await screen.findByRole('combobox', { name: 'Interface theme' }), 'Default');
// there's no default option in this dropdown - there's a clear selection button
// get the parent container, and find the "Clear value" button
const dashboardSelect = screen.getByTestId('User preferences home dashboard drop down');
await userEvent.click(within(dashboardSelect).getByRole('button', { name: 'Clear value' }));
await user.click(within(dashboardSelect).getByRole('button', { name: 'Clear value' }));
await selectOptionInTest(screen.getByLabelText('Timezone'), 'Default');
@@ -213,12 +154,24 @@ describe('SharedPreferences', () => {
await selectComboboxOptionInTest(screen.getByRole('combobox', { name: /language/i }), 'Default');
await userEvent.click(screen.getByText('Save'));
expect(mockPrefsUpdate).toHaveBeenCalledWith(defaultPreferences);
await user.click(screen.getByText('Save'));
const requests = await capture;
const newPreferences = await getPrefsUpdateRequest(requests);
expect(newPreferences).toEqual({
timezone: '',
weekStart: '',
theme: '',
homeDashboardUID: '',
queryHistory: {
homeTab: '',
},
language: '',
});
});
it('refreshes the page after saving preferences', async () => {
await userEvent.click(screen.getByText('Save'));
const { user } = await setup();
await user.click(screen.getByText('Save'));
expect(mockReload).toHaveBeenCalled();
});
});
@@ -1,23 +0,0 @@
import { DashboardDTO } from 'app/types/dashboard';
import { DashboardSearchItem } from '../../../search/types';
import { alertingApi } from './alertingApi';
export const dashboardApi = alertingApi.injectEndpoints({
endpoints: (build) => ({
search: build.query<DashboardSearchItem[], { query?: string }>({
query: ({ query }) => {
const params = new URLSearchParams({ type: 'dash-db', limit: '1000', page: '1', sort: 'name_sort' });
if (query) {
params.set('query', query);
}
return { url: `/api/search?${params.toString()}` };
},
}),
dashboard: build.query<DashboardDTO, { uid: string }>({
query: ({ uid }) => ({ url: `/api/dashboards/uid/${uid}` }),
}),
}),
});
@@ -1,7 +1,7 @@
import { css, cx } from '@emotion/css';
import { noop } from 'lodash';
import { CSSProperties, useCallback, useMemo, useState } from 'react';
import { useDebounce } from 'react-use';
import { useAsync, useDebounce } from 'react-use';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList } from 'react-window';
@@ -18,9 +18,9 @@ import {
clearButtonStyles,
useStyles2,
} from '@grafana/ui';
import { getGrafanaSearcher } from 'app/features/search/service/searcher';
import { DashboardModel } from '../../../../dashboard/state/DashboardModel';
import { dashboardApi } from '../../api/dashboardApi';
import { useDashboardQuery } from './useDashboardQuery';
@@ -31,9 +31,11 @@ export interface PanelDTO {
collapsed?: boolean;
}
const collator = new Intl.Collator();
function panelSort(a: PanelDTO, b: PanelDTO) {
if (a.title && b.title) {
return a.title.localeCompare(b.title);
return collator.compare(a.title, b.title);
}
if (a.title && !b.title) {
return 1;
@@ -52,28 +54,34 @@ interface DashboardPickerProps {
onDismiss: () => void;
}
const useFilteredDashboards = (dashboardFilter: string) => {
return useAsync(async () => {
const results = await getGrafanaSearcher().search({
query: dashboardFilter,
kind: ['dashboard'],
});
const locationInfo = await getGrafanaSearcher().getLocationInfo();
return { dashboards: results.view.toArray(), locationInfo };
}, [dashboardFilter]);
};
export const DashboardPicker = ({ dashboardUid, panelId, isOpen, onChange, onDismiss }: DashboardPickerProps) => {
const styles = useStyles2(getPickerStyles);
const [selectedDashboardUid, setSelectedDashboardUid] = useState(dashboardUid);
const [selectedPanelId, setSelectedPanelId] = useState(panelId);
const [dashboardFilter, setDashboardFilter] = useState('');
const [debouncedDashboardFilter, setDebouncedDashboardFilter] = useState('');
const [panelFilter, setPanelFilter] = useState('');
const { useSearchQuery } = dashboardApi;
const { currentData: filteredDashboards = [], isFetching: isDashSearchFetching } = useSearchQuery({
query: debouncedDashboardFilter,
});
const { value, loading: isDashSearchFetching } = useFilteredDashboards(debouncedDashboardFilter);
const { dashboardModel, isFetching: isDashboardFetching } = useDashboardQuery(selectedDashboardUid);
const handleDashboardChange = useCallback((dashboardUid: string) => {
setSelectedDashboardUid(dashboardUid);
setSelectedPanelId(undefined);
}, []);
const { dashboards: filteredDashboards = [], locationInfo: locationInfo = {} } = value || {};
const allDashboardPanels = getVisualPanels(dashboardModel);
const filteredPanels =
@@ -114,18 +122,19 @@ export const DashboardPicker = ({ dashboardUid, panelId, isOpen, onChange, onDis
const DashboardRow = ({ index, style }: { index: number; style?: CSSProperties }) => {
const dashboard = filteredDashboards[index];
const isSelected = selectedDashboardUid === dashboard.uid;
const folderTitle = locationInfo?.[dashboard.location]?.name ?? 'Dashboards';
return (
<button
type="button"
title={dashboard.title}
title={dashboard.name}
style={style}
className={cx(styles.rowButton, { [styles.rowOdd]: index % 2 === 1, [styles.rowSelected]: isSelected })}
onClick={() => handleDashboardChange(dashboard.uid)}
>
<div className={cx(styles.dashboardTitle, styles.rowButtonTitle)}>{dashboard.title}</div>
<div className={cx(styles.dashboardTitle, styles.rowButtonTitle)}>{dashboard.name}</div>
<div className={styles.dashboardFolder}>
<Icon name="folder" /> {dashboard.folderTitle ?? 'Dashboards'}
<Icon name="folder" /> {folderTitle}
</div>
</button>
);
@@ -1,9 +1,10 @@
import memoizeOne from 'memoize-one';
import { useEffect, useState } from 'react';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { DashboardDTO } from 'app/types/dashboard';
import { DashboardModel } from '../../../../dashboard/state/DashboardModel';
import { dashboardApi } from '../../api/dashboardApi';
const convertToDashboardModel = memoizeOne((dashboardDTO: DashboardDTO) => {
// RTKQuery freezes all returned objects. DashboardModel constructor runs migrations which might change the internal object
@@ -13,16 +14,23 @@ const convertToDashboardModel = memoizeOne((dashboardDTO: DashboardDTO) => {
});
export function useDashboardQuery(dashboardUid?: string) {
const queryData = dashboardApi.endpoints.dashboard.useQuery(
{ uid: dashboardUid ?? '' },
{
skip: !dashboardUid,
selectFromResult: ({ currentData, data, ...rest }) => ({
dashboardModel: currentData ? convertToDashboardModel(currentData) : undefined,
...rest,
}),
const [dashboardModel, setDashboardModel] = useState<DashboardModel>();
const [isFetching, setIsFetching] = useState(false);
useEffect(() => {
if (dashboardUid) {
setIsFetching(true);
getDashboardAPI()
.getDashboardDTO(dashboardUid)
.then((dashboard) => {
if (!('dashboard' in dashboard)) {
console.error('Something went wrong, unexpected dashboard format');
} else {
setDashboardModel(convertToDashboardModel(dashboard));
}
setIsFetching(false);
});
}
);
}, [dashboardUid]);
return queryData;
return { dashboardModel, isFetching };
}
@@ -1,18 +1,21 @@
import { act, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from 'test/test-utils';
import { render, act, screen, waitFor } from 'test/test-utils';
import { selectors } from '@grafana/e2e-selectors';
import { locationService, setEchoSrv } from '@grafana/runtime';
import { defaultDashboard } from '@grafana/schema';
import { locationService, setBackendSrv, setEchoSrv } 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 { contextSrv } from 'app/core/services/context_srv';
import { Echo } from 'app/core/services/echo/Echo';
import store from 'app/core/store';
import { DashboardSearchItemType } from 'app/features/search/types';
import { AddToDashboardForm, Props } from './AddToDashboardForm';
const [_, { dashbdE }] = getFolderFixtures();
setBackendSrv(backendSrv);
setupMockServer();
async function setup(overrides: Partial<Props> = {}) {
const props: Props = {
buildPanel: () => ({ id: 1, type: 'table', options: { showHeader: false } }),
@@ -39,19 +42,17 @@ describe('AddToDashboardButton', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(backendSrv, 'search').mockResolvedValue([]);
mocks.contextSrv.hasPermission.mockImplementation(() => true);
locationService.push('/');
});
describe('navigation', () => {
it('Navigates to dashboard when clicking on "Open"', async () => {
// @ts-expect-error global.open should return a Window, but is not implemented in js-dom.
const openSpy = jest.spyOn(global, 'open').mockReturnValue(true);
const openSpy = jest.spyOn(global, 'open');
await setup();
const { user } = await setup();
await userEvent.click(screen.getByRole('button', { name: /open dashboard$/i }));
await user.click(screen.getByRole('button', { name: /open dashboard$/i }));
expect(screen.queryByRole('dialog', { name: 'Add panel to dashboard' })).not.toBeInTheDocument();
@@ -63,9 +64,9 @@ describe('AddToDashboardButton', () => {
// @ts-expect-error global.open should return a Window, but is not implemented in js-dom.
const openSpy = jest.spyOn(global, 'open').mockReturnValue(true);
await setup();
const { user } = await setup();
await userEvent.click(screen.getByRole('button', { name: /open in new tab/i }));
await user.click(screen.getByRole('button', { name: /open in new tab/i }));
expect(openSpy).toHaveBeenCalledWith(expect.anything(), '_blank');
expect(locationService.getLocation().pathname).toBe('/');
@@ -75,9 +76,9 @@ describe('AddToDashboardButton', () => {
describe('Add to new dashboard', () => {
describe('Navigate to correct dashboard when saving', () => {
it('Navigates to the new dashboard', async () => {
await setup();
const { user } = await setup();
await userEvent.click(screen.getByRole('button', { name: /open dashboard$/i }));
await user.click(screen.getByRole('button', { name: /open dashboard$/i }));
expect(screen.queryByRole('dialog', { name: 'Add panel to dashboard' })).not.toBeInTheDocument();
expect(locationService.getLocation().pathname).toBe('/dashboard/new');
@@ -87,21 +88,21 @@ describe('AddToDashboardButton', () => {
describe('Add to existing dashboard', () => {
it('Renders the dashboard picker when switching to "Existing Dashboard"', async () => {
await setup();
const { user } = await setup();
expect(screen.queryByRole('combobox', { name: /dashboard/ })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole<HTMLInputElement>('radio', { name: /existing dashboard/i }));
await user.click(screen.getByRole<HTMLInputElement>('radio', { name: /existing dashboard/i }));
expect(screen.getByRole('combobox', { name: /dashboard/ })).toBeInTheDocument();
});
it('Does not submit if no dashboard is selected', async () => {
locationService.push = jest.fn();
await setup();
const { user } = await setup();
await userEvent.click(screen.getByRole<HTMLInputElement>('radio', { name: /existing dashboard/i }));
await userEvent.click(screen.getByRole('button', { name: /open dashboard$/i }));
await user.click(screen.getByRole<HTMLInputElement>('radio', { name: /existing dashboard/i }));
await user.click(screen.getByRole('button', { name: /open dashboard$/i }));
locationService.push = jest.fn();
expect(locationService.push).not.toHaveBeenCalled();
@@ -112,72 +113,41 @@ describe('AddToDashboardButton', () => {
// @ts-expect-error global.open should return a Window, but is not implemented in js-dom.
const openSpy = jest.spyOn(global, 'open').mockReturnValue(true);
jest.spyOn(backendSrv, 'getDashboardByUid').mockResolvedValue({
dashboard: { ...defaultDashboard, templating: { list: [] }, title: 'Dashboard Title', uid: 'someUid' },
meta: {},
});
const dashboardToSelect = dashbdE.item;
jest.spyOn(backendSrv, 'search').mockResolvedValue([
{
uid: 'someUid',
isStarred: false,
title: 'Dashboard Title',
tags: [],
type: DashboardSearchItemType.DashDB,
uri: 'someUri',
url: 'someUrl',
},
]);
const { user } = await setup();
await setup();
await user.click(screen.getByRole('radio', { name: /existing dashboard/i }));
await user.click(screen.getByRole('combobox', { name: /dashboard/i }));
await userEvent.click(screen.getByRole('radio', { name: /existing dashboard/i }));
await userEvent.click(screen.getByRole('combobox', { name: /dashboard/i }));
await screen.findAllByTestId(selectors.components.Select.option);
await waitFor(async () => {
await screen.findByTestId(selectors.components.Select.option);
});
await user.click(screen.getByText(new RegExp(dashboardToSelect.title)));
await user.click(screen.getByRole('button', { name: /open in new tab/i }));
await userEvent.click(screen.getByTestId(selectors.components.Select.option));
await userEvent.click(screen.getByRole('button', { name: /open in new tab/i }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
await waitFor(async () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
expect(openSpy).toBeCalledWith('d/someUid', '_blank');
expect(openSpy).toHaveBeenCalledWith(`d/${dashboardToSelect.uid}`, '_blank');
});
it('Navigates to the selected dashboard', async () => {
jest.spyOn(backendSrv, 'search').mockResolvedValue([
{
uid: 'someUid',
isStarred: false,
title: 'Dashboard Title',
tags: [],
type: DashboardSearchItemType.DashDB,
uri: 'someUri',
url: 'someUrl',
},
]);
const dashboardToSelect = dashbdE.item;
await setup();
const { user } = await setup();
await userEvent.click(screen.getByRole('radio', { name: /existing dashboard/i }));
await userEvent.click(screen.getByRole('combobox', { name: /dashboard/i }));
await user.click(screen.getByRole('radio', { name: /existing dashboard/i }));
await user.click(screen.getByRole('combobox', { name: /dashboard/i }));
await waitFor(async () => {
await screen.findByTestId(selectors.components.Select.option);
});
await screen.findAllByTestId(selectors.components.Select.option);
await userEvent.click(screen.getByTestId(selectors.components.Select.option));
await userEvent.click(screen.getByRole('button', { name: /open dashboard$/i }));
await user.click(screen.getByText(new RegExp(dashboardToSelect.title)));
await user.click(screen.getByRole('button', { name: /open dashboard$/i }));
await waitFor(async () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
expect(locationService.getLocation().pathname).toBe('/d/someUid');
expect(locationService.getLocation().pathname).toBe(`/d/${dashboardToSelect.uid}`);
});
});
});
@@ -230,11 +200,11 @@ describe('Error handling', () => {
jest.spyOn(global, 'open').mockReturnValue(null);
const removeDashboardSpy = jest.spyOn(store, 'delete');
await setup();
const { user } = await setup();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /open in new tab/i }));
await user.click(screen.getByRole('button', { name: /open in new tab/i }));
await waitFor(async () => {
expect(await screen.findByRole('alert')).toBeInTheDocument();
@@ -248,11 +218,11 @@ describe('Error handling', () => {
throw 'SOME ERROR';
});
await setup();
const { user } = await setup();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /open in new tab/i }));
await user.click(screen.getByRole('button', { name: /open in new tab/i }));
await waitFor(async () => {
expect(await screen.findByRole('alert')).toBeInTheDocument();