From 3c915d0502a03ebcd32e7fca1632848619d3b4da Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Tue, 31 Oct 2023 08:38:25 +0100 Subject: [PATCH] Alerting: Fix Alert rule form editing when associated panel has no id (#77209) --- .../alerting/unified/CloneRuleEditor.test.tsx | 6 +- .../export/GrafanaModifyExport.test.tsx | 9 +- ...ield.test.tsx => AnnotationsStep.test.tsx} | 159 +++++++++--------- .../rule-editor/AnnotationsStep.tsx | 33 ++-- .../rule-editor/DashboardPicker.test.tsx | 52 ++++++ .../rule-editor/DashboardPicker.tsx | 58 ++++--- .../rule-editor/useDashboardQuery.ts | 27 +++ .../app/features/alerting/unified/mockApi.ts | 21 ++- public/app/features/alerting/unified/mocks.ts | 35 +++- .../alerting/unified/mocks/grafanaApi.ts | 8 - 10 files changed, 259 insertions(+), 149 deletions(-) rename public/app/features/alerting/unified/components/rule-editor/{AnnotationsField.test.tsx => AnnotationsStep.test.tsx} (77%) create mode 100644 public/app/features/alerting/unified/components/rule-editor/DashboardPicker.test.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/useDashboardQuery.ts delete mode 100644 public/app/features/alerting/unified/mocks/grafanaApi.ts diff --git a/public/app/features/alerting/unified/CloneRuleEditor.test.tsx b/public/app/features/alerting/unified/CloneRuleEditor.test.tsx index 9978d9b31f2..ded8fa455c1 100644 --- a/public/app/features/alerting/unified/CloneRuleEditor.test.tsx +++ b/public/app/features/alerting/unified/CloneRuleEditor.test.tsx @@ -21,6 +21,7 @@ import { import { cloneRuleDefinition, CloneRuleEditor } from './CloneRuleEditor'; import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; +import { mockSearchApi } from './mockApi'; import { mockDataSource, MockDataSourceSrv, @@ -30,7 +31,6 @@ import { mockStore, } from './mocks'; import { mockAlertmanagerConfigResponse } from './mocks/alertmanagerApi'; -import { mockSearchApiResponse } from './mocks/grafanaApi'; import { mockRulerRulesApiResponse, mockRulerRulesGroupApiResponse } from './mocks/rulerApi'; import { AlertingQueryRunner } from './state/AlertingQueryRunner'; import { RuleFormValues } from './types/rule-form'; @@ -156,7 +156,7 @@ describe('CloneRuleEditor', function () { 'folder-one': [{ name: 'group1', interval: '20s', rules: [originRule] }], }); - mockSearchApiResponse(server, []); + mockSearchApi(server).search([]); mockAlertmanagerConfigResponse(server, GRAFANA_RULES_SOURCE_NAME, amConfig); render(, { @@ -209,7 +209,7 @@ describe('CloneRuleEditor', function () { rules: [originRule], }); - mockSearchApiResponse(server, []); + mockSearchApi(server).search([]); mockAlertmanagerConfigResponse(server, GRAFANA_RULES_SOURCE_NAME, amConfig); render( diff --git a/public/app/features/alerting/unified/components/export/GrafanaModifyExport.test.tsx b/public/app/features/alerting/unified/components/export/GrafanaModifyExport.test.tsx index 0f44e23c14f..0d01ea8868d 100644 --- a/public/app/features/alerting/unified/components/export/GrafanaModifyExport.test.tsx +++ b/public/app/features/alerting/unified/components/export/GrafanaModifyExport.test.tsx @@ -12,7 +12,7 @@ import { TestProvider } from '../../../../../../test/helpers/TestProvider'; import { AlertmanagerChoice } from '../../../../../plugins/datasource/alertmanager/types'; import { DashboardSearchItemType } from '../../../../search/types'; import { mockAlertRuleApi, mockApi, mockExportApi, mockSearchApi, setupMswServer } from '../../mockApi'; -import { getGrafanaRule, mockDataSource } from '../../mocks'; +import { getGrafanaRule, mockDashboardSearchItem, mockDataSource } from '../../mocks'; import { mockAlertmanagerChoiceResponse } from '../../mocks/alertmanagerApi'; import { setupDataSources } from '../../testSetup/datasources'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; @@ -96,14 +96,11 @@ describe('GrafanaModifyExport', () => { it('Should render edit form for the specified rule', async () => { mockApi(server).eval({ results: { A: { frames: [] } } }); mockSearchApi(server).search([ - { + mockDashboardSearchItem({ title: grafanaRule.namespace.name, uid: 'folder-test-uid', - id: 1, - url: '', - tags: [], type: DashboardSearchItemType.DashFolder, - }, + }), ]); mockAlertRuleApi(server).rulerRules(GRAFANA_RULES_SOURCE_NAME, { [grafanaRule.namespace.name]: [{ name: grafanaRule.group.name, interval: '1m', rules: [grafanaRule.rulerRule!] }], diff --git a/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.test.tsx b/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.test.tsx similarity index 77% rename from public/app/features/alerting/unified/components/rule-editor/AnnotationsField.test.tsx rename to public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.test.tsx index c9b25fe59b5..0d8e4dcfa72 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.test.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.test.tsx @@ -1,20 +1,18 @@ import { findByRole, findByText, findByTitle, getByTestId, queryByText, render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { rest } from 'msw'; import { setupServer } from 'msw/node'; import React from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { TestProvider } from 'test/helpers/TestProvider'; import { byRole, byTestId } from 'testing-library-selector'; +import 'core-js/stable/structured-clone'; import { setBackendSrv } from '@grafana/runtime'; -import { defaultDashboard } from '@grafana/schema'; import { backendSrv } from 'app/core/services/backend_srv'; -import { DashboardDTO } from '../../../../../types'; -import { DashboardSearchItem, DashboardSearchItemType } from '../../../../search/types'; -import { mockStore } from '../../mocks'; -import { mockSearchApiResponse } from '../../mocks/grafanaApi'; +import { DashboardSearchItemType } from '../../../../search/types'; +import { mockDashboardApi } from '../../mockApi'; +import { mockDashboardDto, mockDashboardSearchItem, mockStore } from '../../mocks'; import { RuleFormValues } from '../../types/rule-form'; import { Annotation } from '../../utils/constants'; import { getDefaultFormValues } from '../../utils/rule-form'; @@ -36,6 +34,8 @@ const ui = { setDashboardButton: byRole('button', { name: 'Link dashboard and panel' }), annotationKeys: byTestId('annotation-key-', { exact: false }), annotationValues: byTestId('annotation-value-', { exact: false }), + dashboardAnnotation: byTestId('dashboard-annotation'), + panelAnnotation: byTestId('panel-annotation'), dashboardPicker: { dialog: byRole('dialog'), heading: byRole('heading', { name: 'Select dashboard and panel' }), @@ -85,7 +85,7 @@ describe('AnnotationsField', function () { describe('Dashboard and panel picker', function () { it('should display dashboard and panel selector when select button clicked', async function () { - mockSearchApiResponse(server, []); + mockDashboardApi(server).search([]); const user = userEvent.setup(); @@ -98,11 +98,11 @@ describe('AnnotationsField', function () { }); it('should enable Confirm button only when dashboard and panel selected', async function () { - mockSearchApiResponse(server, [ + mockDashboardApi(server).search([ mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }), ]); - mockGetDashboardResponse( + mockDashboardApi(server).dashboard( mockDashboardDto({ title: 'My dashboard', uid: 'dash-test-uid', @@ -128,11 +128,11 @@ describe('AnnotationsField', function () { }); it('should add selected dashboard and panel as annotations', async function () { - mockSearchApiResponse(server, [ + mockDashboardApi(server).search([ mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }), ]); - mockGetDashboardResponse( + mockDashboardApi(server).dashboard( mockDashboardDto({ title: 'My dashboard', uid: 'dash-test-uid', @@ -164,11 +164,11 @@ describe('AnnotationsField', function () { }); it('should not show rows as panels', async function () { - mockSearchApiResponse(server, [ + mockDashboardApi(server).search([ mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }), ]); - mockGetDashboardResponse( + mockDashboardApi(server).dashboard( mockDashboardDto({ title: 'My dashboard', uid: 'dash-test-uid', @@ -193,11 +193,11 @@ describe('AnnotationsField', function () { }); it('should show panels within collapsed rows', async function () { - mockSearchApiResponse(server, [ + mockDashboardApi(server).search([ mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }), ]); - mockGetDashboardResponse( + mockDashboardApi(server).dashboard( mockDashboardDto({ title: 'My dashboard', uid: 'dash-test-uid', @@ -231,7 +231,7 @@ describe('AnnotationsField', function () { // this test _should_ work in theory but something is stopping the 'onClick' function on the dashboard item // to trigger "handleDashboardChange" – skipping it for now but has been manually tested. it.skip('should update existing dashboard and panel identifies', async function () { - mockSearchApiResponse(server, [ + mockDashboardApi(server).search([ mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }), mockDashboardSearchItem({ title: 'My other dashboard', @@ -240,7 +240,7 @@ describe('AnnotationsField', function () { }), ]); - mockGetDashboardResponse( + mockDashboardApi(server).dashboard( mockDashboardDto({ title: 'My dashboard', uid: 'dash-test-uid', @@ -250,7 +250,7 @@ describe('AnnotationsField', function () { ], }) ); - mockGetDashboardResponse( + mockDashboardApi(server).dashboard( mockDashboardDto({ title: 'My other dashboard', uid: 'dash-other-uid', @@ -297,67 +297,64 @@ describe('AnnotationsField', function () { expect(annotationValueElements[1]).toHaveTextContent('3'); }); }); + + it('should render warning icon for panels of type other than graph and timeseries', async function () { + mockDashboardApi(server).search([ + mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }), + ]); + + mockDashboardApi(server).dashboard( + mockDashboardDto({ + title: 'My dashboard', + uid: 'dash-test-uid', + panels: [ + { id: 1, title: 'First panel', type: 'bar' }, + { id: 2, title: 'Second panel', type: 'graph' }, + { type: 'timeseries' }, // Panels might NOT have id and title fields + ], + }) + ); + + const user = userEvent.setup(); + + render(); + + const { dialog } = ui.dashboardPicker; + + await user.click(ui.setDashboardButton.get()); + await user.click(await findByTitle(dialog.get(), 'My dashboard')); + + const warnedPanel = await findByRole(dialog.get(), 'button', { name: /First panel/ }); + + expect(getByTestId(warnedPanel, 'warning-icon')).toBeInTheDocument(); + }); + + it('should render when panels do not contain certain fields', async () => { + mockDashboardApi(server).search([ + mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }), + ]); + + mockDashboardApi(server).dashboard( + mockDashboardDto({ + title: 'My dashboard', + uid: 'dash-test-uid', + panels: [{ type: 'row' }, { type: 'timeseries' }, { id: 4, type: 'graph' }, { title: 'Graph', type: 'graph' }], + }) + ); + + render( + + ); + + expect(await ui.dashboardAnnotation.find()).toBeInTheDocument(); + expect(ui.dashboardAnnotation.get()).toHaveTextContent('My dashboard'); + expect(ui.panelAnnotation.query()).not.toBeInTheDocument(); + }); }); - -it('should render warning icon for panels of type other than graph and timeseries', async function () { - mockSearchApiResponse(server, [ - mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }), - ]); - - mockGetDashboardResponse( - mockDashboardDto({ - title: 'My dashboard', - uid: 'dash-test-uid', - panels: [ - { id: 1, title: 'First panel', type: 'bar' }, - { id: 2, title: 'Second panel', type: 'graph' }, - ], - }) - ); - - const user = userEvent.setup(); - - render(); - - const { dialog } = ui.dashboardPicker; - - await user.click(ui.setDashboardButton.get()); - await user.click(await findByTitle(dialog.get(), 'My dashboard')); - - const warnedPanel = await findByRole(dialog.get(), 'button', { name: /First panel/ }); - - expect(getByTestId(warnedPanel, 'warning-icon')).toBeInTheDocument(); -}); - -function mockGetDashboardResponse(dashboard: DashboardDTO) { - server.use( - rest.get(`/api/dashboards/uid/${dashboard.dashboard.uid}`, (req, res, ctx) => - res(ctx.json(dashboard)) - ) - ); -} - -function mockDashboardSearchItem(searchItem: Partial) { - return { - title: '', - uid: '', - type: DashboardSearchItemType.DashDB, - url: '', - uri: '', - items: [], - tags: [], - slug: '', - isStarred: false, - ...searchItem, - }; -} - -function mockDashboardDto(dashboard: Partial): DashboardDTO { - return { - dashboard: { - ...defaultDashboard, - ...dashboard, - } as DashboardDTO['dashboard'], - meta: {}, - }; -} diff --git a/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.tsx index 7617ff84438..8481c811450 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AnnotationsStep.tsx @@ -7,17 +7,17 @@ import { useToggle } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; import { Stack } from '@grafana/experimental'; import { Button, Field, Input, Text, TextArea, useStyles2 } from '@grafana/ui'; -import { DashboardDataDTO } from 'app/types'; -import { dashboardApi } from '../../api/dashboardApi'; +import { DashboardModel } from '../../../../dashboard/state'; import { RuleFormValues } from '../../types/rule-form'; import { Annotation, annotationLabels } from '../../utils/constants'; import AnnotationHeaderField from './AnnotationHeaderField'; import DashboardAnnotationField from './DashboardAnnotationField'; -import { DashboardPicker, mergePanels, PanelDTO } from './DashboardPicker'; +import { DashboardPicker, getVisualPanels, PanelDTO } from './DashboardPicker'; import { NeedHelpInfo } from './NeedHelpInfo'; import { RuleEditorSection } from './RuleEditorSection'; +import { useDashboardQuery } from './useDashboardQuery'; const AnnotationsStep = () => { const styles = useStyles2(getStyles); @@ -35,31 +35,26 @@ const AnnotationsStep = () => { const { fields, append, remove } = useFieldArray({ control, name: 'annotations' }); const selectedDashboardUid = annotations.find((annotation) => annotation.key === Annotation.dashboardUID)?.value; - const selectedPanelId = annotations.find((annotation) => annotation.key === Annotation.panelID)?.value; + const selectedPanelId = Number(annotations.find((annotation) => annotation.key === Annotation.panelID)?.value); - const [selectedDashboard, setSelectedDashboard] = useState(undefined); + const [selectedDashboard, setSelectedDashboard] = useState(undefined); const [selectedPanel, setSelectedPanel] = useState(undefined); - const { useDashboardQuery } = dashboardApi; - - const { currentData: dashboardResult, isFetching: isDashboardFetching } = useDashboardQuery( - { uid: selectedDashboardUid ?? '' }, - { skip: !selectedDashboardUid } - ); + const { dashboardModel, isFetching: isDashboardFetching } = useDashboardQuery(selectedDashboardUid); useEffect(() => { - if (isDashboardFetching) { + if (isDashboardFetching || !dashboardModel) { return; } - setSelectedDashboard(dashboardResult?.dashboard); + setSelectedDashboard(dashboardModel); - const allPanels = mergePanels(dashboardResult); - const currentPanel = allPanels.find((panel) => panel.id.toString() === selectedPanelId); + const allPanels = getVisualPanels(dashboardModel); + const currentPanel = allPanels.find((panel) => panel.id === selectedPanelId); setSelectedPanel(currentPanel); - }, [selectedPanelId, dashboardResult, isDashboardFetching]); + }, [selectedPanelId, dashboardModel, isDashboardFetching]); - const setSelectedDashboardAndPanelId = (dashboardUid: string, panelId: string) => { + const setSelectedDashboardAndPanelId = (dashboardUid: string, panelId: number) => { const updatedAnnotations = produce(annotations, (draft) => { const dashboardAnnotation = draft.find((a) => a.key === Annotation.dashboardUID); const panelAnnotation = draft.find((a) => a.key === Annotation.panelID); @@ -71,9 +66,9 @@ const AnnotationsStep = () => { } if (panelAnnotation) { - panelAnnotation.value = panelId; + panelAnnotation.value = panelId.toString(); } else { - draft.push({ key: Annotation.panelID, value: panelId }); + draft.push({ key: Annotation.panelID, value: panelId.toString() }); } }); diff --git a/public/app/features/alerting/unified/components/rule-editor/DashboardPicker.test.tsx b/public/app/features/alerting/unified/components/rule-editor/DashboardPicker.test.tsx new file mode 100644 index 00000000000..54ae7d967f2 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/DashboardPicker.test.tsx @@ -0,0 +1,52 @@ +import { render } from '@testing-library/react'; +import { noop } from 'lodash'; +import React from 'react'; +import { AutoSizerProps } from 'react-virtualized-auto-sizer'; +import { byRole } from 'testing-library-selector'; + +import 'core-js/stable/structured-clone'; + +import { TestProvider } from '../../../../../../test/helpers/TestProvider'; +import { DashboardSearchItemType } from '../../../../search/types'; +import { mockDashboardApi, setupMswServer } from '../../mockApi'; +import { mockDashboardDto, mockDashboardSearchItem } from '../../mocks'; + +import { DashboardPicker } from './DashboardPicker'; + +jest.mock('react-virtualized-auto-sizer', () => { + return ({ children }: AutoSizerProps) => children({ height: 600, width: 1 }); +}); + +const server = setupMswServer(); + +mockDashboardApi(server).search([ + mockDashboardSearchItem({ uid: 'dash-1', type: DashboardSearchItemType.DashDB, title: 'Dashboard 1' }), + mockDashboardSearchItem({ uid: 'dash-2', type: DashboardSearchItemType.DashDB, title: 'Dashboard 2' }), + mockDashboardSearchItem({ uid: 'dash-3', type: DashboardSearchItemType.DashDB, title: 'Dashboard 3' }), +]); + +mockDashboardApi(server).dashboard( + mockDashboardDto({ + uid: 'dash-2', + title: 'Dashboard 2', + panels: [{ type: 'graph' }, { type: 'timeseries' }], + }) +); + +const ui = { + dashboardButton: (name: RegExp) => byRole('button', { name }), +}; + +describe('DashboardPicker', () => { + it('Renders panels without ids', async () => { + render(, { + wrapper: TestProvider, + }); + + expect(await ui.dashboardButton(/Dashboard 1/).find()).toBeInTheDocument(); + expect(await ui.dashboardButton(/Dashboard 2/).find()).toBeInTheDocument(); + expect(await ui.dashboardButton(/Dashboard 3/).find()).toBeInTheDocument(); + + expect(await ui.dashboardButton(//).findAll()).toHaveLength(2); + }); +}); 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 444325c2535..ccf98cca736 100644 --- a/public/app/features/alerting/unified/components/rule-editor/DashboardPicker.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/DashboardPicker.tsx @@ -17,14 +17,17 @@ import { Tooltip, useStyles2, } from '@grafana/ui'; -import { DashboardDTO } from 'app/types'; +import { DashboardModel } from '../../../../dashboard/state'; import { dashboardApi } from '../../api/dashboardApi'; +import { useDashboardQuery } from './useDashboardQuery'; + export interface PanelDTO { id?: number; title?: string; type: string; + collapsed?: boolean; } function panelSort(a: PanelDTO, b: PanelDTO) { @@ -42,24 +45,12 @@ function panelSort(a: PanelDTO, b: PanelDTO) { interface DashboardPickerProps { isOpen: boolean; - dashboardUid?: string | undefined; - panelId?: string | undefined; - onChange: (dashboardUid: string, panelId: string) => void; + dashboardUid?: string; + panelId?: number; + onChange: (dashboardUid: string, panelId: number) => void; onDismiss: () => void; } -export function mergePanels(dashboardResult: DashboardDTO | undefined) { - const panels = dashboardResult?.dashboard?.panels?.filter((panel) => panel.type !== 'row') || []; - const nestedPanels = - dashboardResult?.dashboard?.panels - ?.filter((row: { collapsed: boolean }) => row.collapsed) - .map((collapsedRow: { panels: PanelDTO[] }) => collapsedRow.panels) || []; - - const allDashboardPanels = [...panels, ...nestedPanels.flat()]; - - return allDashboardPanels; -} - export const DashboardPicker = ({ dashboardUid, panelId, isOpen, onChange, onDismiss }: DashboardPickerProps) => { const styles = useStyles2(getPickerStyles); @@ -70,26 +61,23 @@ export const DashboardPicker = ({ dashboardUid, panelId, isOpen, onChange, onDis const [debouncedDashboardFilter, setDebouncedDashboardFilter] = useState(''); const [panelFilter, setPanelFilter] = useState(''); - const { useSearchQuery, useDashboardQuery } = dashboardApi; + const { useSearchQuery } = dashboardApi; const { currentData: filteredDashboards = [], isFetching: isDashSearchFetching } = useSearchQuery({ query: debouncedDashboardFilter, }); - const { currentData: dashboardResult, isFetching: isDashboardFetching } = useDashboardQuery( - { uid: selectedDashboardUid ?? '' }, - { skip: !selectedDashboardUid } - ); + const { dashboardModel, isFetching: isDashboardFetching } = useDashboardQuery(selectedDashboardUid); const handleDashboardChange = useCallback((dashboardUid: string) => { setSelectedDashboardUid(dashboardUid); setSelectedPanelId(undefined); }, []); - const allDashboardPanels = mergePanels(dashboardResult); + const allDashboardPanels = getVisualPanels(dashboardModel); const filteredPanels = allDashboardPanels - ?.filter((panel) => panel.title?.toLowerCase().includes(panelFilter.toLowerCase())) + .filter((panel) => panel.title?.toLowerCase().includes(panelFilter.toLowerCase())) .sort(panelSort) ?? []; const currentPanel: PanelDTO | undefined = allDashboardPanels.find( @@ -145,7 +133,7 @@ export const DashboardPicker = ({ dashboardUid, panelId, isOpen, onChange, onDis const PanelRow = ({ index, style }: { index: number; style: CSSProperties }) => { const panel = filteredPanels[index]; const panelTitle = panel.title || ''; - const isSelected = panel.id && selectedPanelId === panel.id?.toString(); + const isSelected = Boolean(panel.id) && selectedPanelId === panel.id; const isAlertingCompatible = panel.type === 'graph' || panel.type === 'timeseries'; const disabled = !isValidPanelIdentifier(panel); @@ -158,7 +146,7 @@ export const DashboardPicker = ({ dashboardUid, panelId, isOpen, onChange, onDis [styles.rowOdd]: index % 2 === 1, [styles.rowSelected]: isSelected, })} - onClick={() => (disabled ? noop : setSelectedPanelId(panel.id?.toString()))} + onClick={() => (disabled ? noop : setSelectedPanelId(panel.id))} >
{panelTitle} @@ -187,11 +175,11 @@ export const DashboardPicker = ({ dashboardUid, panelId, isOpen, onChange, onDis contentClassName={styles.modalContent} > {/* This alert shows if the selected dashboard is not found in the first page of dashboards */} - {!selectedDashboardIsInPageResult && dashboardUid && ( + {!selectedDashboardIsInPageResult && dashboardUid && dashboardModel && (
- Dashboard: {dashboardResult?.dashboard.title} ({dashboardResult?.dashboard.uid}) in folder{' '} - {dashboardResult?.meta.folderTitle ?? 'General'} + Dashboard: {dashboardModel.title} ({dashboardModel.uid}) in folder{' '} + {dashboardModel.meta?.folderTitle ?? 'General'}
{currentPanel && (
@@ -274,6 +262,20 @@ export const DashboardPicker = ({ dashboardUid, panelId, isOpen, onChange, onDis ); }; +export function getVisualPanels(dashboardModel: DashboardModel | undefined) { + if (!dashboardModel) { + return []; + } + + const panelsWithoutRows = dashboardModel.panels.filter((panel) => panel.type !== 'row'); + const panelsNestedInRows = dashboardModel.panels + .filter((rowPanel) => rowPanel.collapsed) + .flatMap((collapsedRow) => collapsedRow.panels ?? []); + + const allDashboardPanels = [...panelsWithoutRows, ...panelsNestedInRows]; + return allDashboardPanels; +} + const isValidPanelIdentifier = (panel: PanelDTO): boolean => { return typeof panel.id === 'number' && typeof panel.type === 'string'; }; diff --git a/public/app/features/alerting/unified/components/rule-editor/useDashboardQuery.ts b/public/app/features/alerting/unified/components/rule-editor/useDashboardQuery.ts new file mode 100644 index 00000000000..56db0ce526f --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/useDashboardQuery.ts @@ -0,0 +1,27 @@ +import memoizeOne from 'memoize-one'; + +import { DashboardDTO } from '../../../../../types'; +import { DashboardModel } from '../../../../dashboard/state'; +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 + // Hence we need to add structuredClone to make a deep copy of the API response object + const { dashboard, meta } = structuredClone(dashboardDTO); + return new DashboardModel(dashboard, meta); +}); + +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, + }), + } + ); + + return queryData; +} diff --git a/public/app/features/alerting/unified/mockApi.ts b/public/app/features/alerting/unified/mockApi.ts index 87814055a13..63cca9d20a9 100644 --- a/public/app/features/alerting/unified/mockApi.ts +++ b/public/app/features/alerting/unified/mockApi.ts @@ -22,8 +22,8 @@ import { MatcherOperator, Route, } from '../../../plugins/datasource/alertmanager/types'; -import { FolderDTO, NotifierDTO } from '../../../types'; -import { DashboardSearchHit } from '../../search/types'; +import { DashboardDTO, FolderDTO, NotifierDTO } from '../../../types'; +import { DashboardSearchItem } from '../../search/types'; import { CreateIntegrationDTO, NewOnCallIntegrationDTO, OnCallIntegrationDTO } from './api/onCallApi'; import { AlertingQueryResponse } from './state/AlertingQueryRunner'; @@ -397,12 +397,27 @@ export function mockFolderApi(server: SetupServer) { export function mockSearchApi(server: SetupServer) { return { - search: (results: DashboardSearchHit[]) => { + search: (results: DashboardSearchItem[]) => { server.use(rest.get(`/api/search`, (_, res, ctx) => res(ctx.status(200), ctx.json(results)))); }, }; } +export function mockDashboardApi(server: SetupServer) { + return { + search: (results: DashboardSearchItem[]) => { + server.use(rest.get(`/api/search`, (_, res, ctx) => res(ctx.status(200), ctx.json(results)))); + }, + dashboard: (response: DashboardDTO) => { + server.use( + rest.get(`/api/dashboards/uid/${response.dashboard.uid}`, (_, res, ctx) => + res(ctx.status(200), ctx.json(response)) + ) + ); + }, + }; +} + // Creates a MSW server and sets up beforeAll, afterAll and beforeEach handlers for it export function setupMswServer() { const server = setupServer(); diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index 132aba7d0a7..2e0ea529721 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -16,6 +16,7 @@ import { TestDataSourceResponse, } from '@grafana/data'; import { config, DataSourceSrv, GetDataSourceListFilters } from '@grafana/runtime'; +import { defaultDashboard } from '@grafana/schema'; import { contextSrv } from 'app/core/services/context_srv'; import { DatasourceSrv } from 'app/features/plugins/datasource_srv'; import { @@ -30,7 +31,7 @@ import { SilenceState, } from 'app/plugins/datasource/alertmanager/types'; import { configureStore } from 'app/store/configureStore'; -import { AccessControlAction, FolderDTO, NotifiersState, ReceiversState, StoreState } from 'app/types'; +import { AccessControlAction, DashboardDTO, FolderDTO, NotifiersState, ReceiversState, StoreState } from 'app/types'; import { Alert, AlertingRule, @@ -55,6 +56,8 @@ import { RulerRulesConfigDTO, } from 'app/types/unified-alerting-dto'; +import { DashboardSearchItem, DashboardSearchItemType } from '../../search/types'; + let nextDataSourceId = 1; export function mockDataSource( @@ -684,6 +687,36 @@ export function mockAlertWithState(state: GrafanaAlertState, labels?: {}): Alert return { activeAt: '', annotations: {}, labels: labels || {}, state: state, value: '' }; } +export function mockDashboardSearchItem(searchItem: Partial) { + return { + title: '', + uid: '', + type: DashboardSearchItemType.DashDB, + url: '', + uri: '', + items: [], + tags: [], + slug: '', + isStarred: false, + ...searchItem, + }; +} + +export function mockDashboardDto( + dashboard: Partial, + meta?: Partial +): DashboardDTO { + return { + dashboard: { + uid: 'dashboard-test', + title: 'Dashboard test', + schemaVersion: defaultDashboard.schemaVersion, + ...dashboard, + }, + meta: { ...meta }, + }; +} + export const onCallPluginMetaMock: PluginMeta = { name: 'Grafana OnCall', id: 'grafana-oncall-app', diff --git a/public/app/features/alerting/unified/mocks/grafanaApi.ts b/public/app/features/alerting/unified/mocks/grafanaApi.ts deleted file mode 100644 index 1062de6749f..00000000000 --- a/public/app/features/alerting/unified/mocks/grafanaApi.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { rest } from 'msw'; -import { SetupServer } from 'msw/node'; - -import { DashboardSearchItem } from '../../../search/types'; - -export function mockSearchApiResponse(server: SetupServer, searchResult: DashboardSearchItem[]) { - server.use(rest.get('/api/search', (req, res, ctx) => res(ctx.json(searchResult)))); -}