From 546d3ec3133dc76a043b2b8863f34a77173f8737 Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Fri, 24 Oct 2025 14:00:16 +0100 Subject: [PATCH] Dashboard Picker: Update to use correct search + dashboards APIs (#112341) --- eslint-suppressions.json | 5 - .../src/handlers/all-handlers.ts | 4 + .../src/handlers/api/dashboards/handlers.ts | 34 +++ .../src/handlers/api/user/handlers.ts | 29 ++- .../dashboard.grafana.app/v1beta1/handlers.ts | 53 ++++ .../Select/DashboardPicker.test.tsx | 163 ++++-------- .../components/Select/DashboardPicker.tsx | 31 +-- .../SharedPreferences.test.tsx | 233 +++++++----------- .../alerting/unified/api/dashboardApi.ts | 23 -- .../rule-editor/DashboardPicker.tsx | 39 +-- .../rule-editor/useDashboardQuery.ts | 30 ++- .../AddToDashboardForm.test.tsx | 114 ++++----- 12 files changed, 363 insertions(+), 395 deletions(-) create mode 100644 packages/grafana-test-utils/src/handlers/api/dashboards/handlers.ts create mode 100644 packages/grafana-test-utils/src/handlers/apis/dashboard.grafana.app/v1beta1/handlers.ts delete mode 100644 public/app/features/alerting/unified/api/dashboardApi.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index ef1c282b2cb..bac173e1cdf 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/packages/grafana-test-utils/src/handlers/all-handlers.ts b/packages/grafana-test-utils/src/handlers/all-handlers.ts index 58e94fe83ac..dc2bd7f47d0 100644 --- a/packages/grafana-test-utils/src/handlers/all-handlers.ts +++ b/packages/grafana-test-utils/src/handlers/all-handlers.ts @@ -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, diff --git a/packages/grafana-test-utils/src/handlers/api/dashboards/handlers.ts b/packages/grafana-test-utils/src/handlers/api/dashboards/handlers.ts new file mode 100644 index 00000000000..7e244292364 --- /dev/null +++ b/packages/grafana-test-utils/src/handlers/api/dashboards/handlers.ts @@ -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; diff --git a/packages/grafana-test-utils/src/handlers/api/user/handlers.ts b/packages/grafana-test-utils/src/handlers/api/user/handlers.ts index b426f6d0733..df47a24304b 100644 --- a/packages/grafana-test-utils/src/handlers/api/user/handlers.ts +++ b/packages/grafana-test-utils/src/handlers/api/user/handlers.ts @@ -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; diff --git a/packages/grafana-test-utils/src/handlers/apis/dashboard.grafana.app/v1beta1/handlers.ts b/packages/grafana-test-utils/src/handlers/apis/dashboard.grafana.app/v1beta1/handlers.ts new file mode 100644 index 00000000000..0c9c364780d --- /dev/null +++ b/packages/grafana-test-utils/src/handlers/apis/dashboard.grafana.app/v1beta1/handlers.ts @@ -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()]; diff --git a/public/app/core/components/Select/DashboardPicker.test.tsx b/public/app/core/components/Select/DashboardPicker.test.tsx index 8fbe7599830..fbf9e0dd516 100644 --- a/public/app/core/components/Select/DashboardPicker.test.tsx +++ b/public/app/core/components/Select/DashboardPicker.test.tsx @@ -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 = { - 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[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(); + render(); - 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(); + it('should search for dashboards and allow selection', async () => { + const { user } = render(); - 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(); + expect(await screen.findByText('TODO')).toBeInTheDocument(); }); }); }); diff --git a/public/app/core/components/Select/DashboardPicker.tsx b/public/app/core/components/Select/DashboardPicker.tsx index 4975bf8884d..ee91bbe3f38 100644 --- a/public/app/core/components/Select/DashboardPicker.tsx +++ b/public/app/core/components/Select/DashboardPicker.tsx @@ -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, 'value' | 'onChange' | 'loadOptions' | ''> { @@ -15,23 +15,26 @@ interface Props extends Omit, 'value' | 'on onChange?: (value?: DashboardPickerDTO) => void; } -export type DashboardPickerDTO = Pick & +export type DashboardPickerDTO = Pick & Pick; 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( 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( setCurrent({ value: { uid: dto.dashboard.uid, - title: dto.dashboard.title, + name: dto.dashboard.title, folderTitle: dto.meta.folderTitle, folderUid: dto.meta.folderUid, }, diff --git a/public/app/core/components/SharedPreferences/SharedPreferences.test.tsx b/public/app/core/components/SharedPreferences/SharedPreferences.test.tsx index 7039dcd3505..f738dc043df 100644 --- a/public/app/core/components/SharedPreferences/SharedPreferences.test.tsx +++ b/public/app/core/components/SharedPreferences/SharedPreferences.test.tsx @@ -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(); + 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(); - - 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(); }); }); diff --git a/public/app/features/alerting/unified/api/dashboardApi.ts b/public/app/features/alerting/unified/api/dashboardApi.ts deleted file mode 100644 index 104826cfe22..00000000000 --- a/public/app/features/alerting/unified/api/dashboardApi.ts +++ /dev/null @@ -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({ - 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({ - query: ({ uid }) => ({ url: `/api/dashboards/uid/${uid}` }), - }), - }), -}); diff --git a/public/app/features/alerting/unified/components/rule-editor/DashboardPicker.tsx b/public/app/features/alerting/unified/components/rule-editor/DashboardPicker.tsx index f1c0fa0ad05..afd4a4e80f2 100644 --- a/public/app/features/alerting/unified/components/rule-editor/DashboardPicker.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/DashboardPicker.tsx @@ -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 ( ); diff --git a/public/app/features/alerting/unified/components/rule-editor/useDashboardQuery.ts b/public/app/features/alerting/unified/components/rule-editor/useDashboardQuery.ts index bf84e8eb110..3427bd0486b 100644 --- a/public/app/features/alerting/unified/components/rule-editor/useDashboardQuery.ts +++ b/public/app/features/alerting/unified/components/rule-editor/useDashboardQuery.ts @@ -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(); + 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 }; } diff --git a/public/app/features/dashboard-scene/addToDashboard/AddToDashboardForm.test.tsx b/public/app/features/dashboard-scene/addToDashboard/AddToDashboardForm.test.tsx index 8aecfaa7ddf..89d4be7f57d 100644 --- a/public/app/features/dashboard-scene/addToDashboard/AddToDashboardForm.test.tsx +++ b/public/app/features/dashboard-scene/addToDashboard/AddToDashboardForm.test.tsx @@ -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 = {}) { 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('radio', { name: /existing dashboard/i })); + await user.click(screen.getByRole('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('radio', { name: /existing dashboard/i })); - await userEvent.click(screen.getByRole('button', { name: /open dashboard$/i })); + await user.click(screen.getByRole('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();