diff --git a/public/app/features/alerting/unified/api/dashboardApi.ts b/public/app/features/alerting/unified/api/dashboardApi.ts new file mode 100644 index 00000000000..15c71cb7508 --- /dev/null +++ b/public/app/features/alerting/unified/api/dashboardApi.ts @@ -0,0 +1,22 @@ +import { DashboardDTO } from '../../../../types'; +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/AnnotationsField.test.tsx b/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.test.tsx new file mode 100644 index 00000000000..0b40d78d138 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.test.tsx @@ -0,0 +1,276 @@ +import { findByText, findByTitle, 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 { Provider } from 'react-redux'; +import { byRole, byTestId } from 'testing-library-selector'; + +import { setBackendSrv } from '@grafana/runtime'; +import { backendSrv } from 'app/core/services/backend_srv'; + +import { DashboardDTO } from '../../../../../types'; +import { DashboardSearchItem, DashboardSearchItemType } from '../../../../search/types'; +import { mockStore } from '../../mocks'; +import { RuleFormValues } from '../../types/rule-form'; +import { Annotation } from '../../utils/constants'; +import { getDefaultFormValues } from '../../utils/rule-form'; + +import 'whatwg-fetch'; + +import AnnotationsField from './AnnotationsField'; + +// To get anything displayed inside the Autosize component we need to mock it +// Ref https://github.com/bvaughn/react-window/issues/454#issuecomment-646031139 +jest.mock( + 'react-virtualized-auto-sizer', + () => + ({ children }: { children: ({ height, width }: { height: number; width: number }) => JSX.Element }) => + children({ height: 500, width: 330 }) +); + +const ui = { + setDashboardButton: byRole('button', { name: 'Set dashboard and panel' }), + annotationKeys: byTestId('annotation-key-', { exact: false }), + annotationValues: byTestId('annotation-value-', { exact: false }), + dashboardPicker: { + dialog: byRole('dialog'), + heading: byRole('heading', { name: 'Select dashboard and panel' }), + confirmButton: byRole('button', { name: 'Confirm' }), + }, +} as const; + +const server = setupServer(); + +beforeAll(() => { + setBackendSrv(backendSrv); + server.listen({ onUnhandledRequest: 'error' }); +}); + +beforeEach(() => { + server.resetHandlers(); +}); + +afterAll(() => { + server.close(); +}); + +function FormWrapper({ formValues }: { formValues?: Partial }) { + const store = mockStore(() => null); + const formApi = useForm({ defaultValues: { ...getDefaultFormValues(), ...formValues } }); + + return ( + + + + + + ); +} + +describe('AnnotationsField', function () { + it('should display default list of annotations', function () { + render(); + + const annotationElements = ui.annotationKeys.getAll(); + + expect(annotationElements).toHaveLength(3); + expect(annotationElements[0]).toHaveTextContent('Summary'); + expect(annotationElements[1]).toHaveTextContent('Description'); + expect(annotationElements[2]).toHaveTextContent('Runbook URL'); + }); + + describe('Dashboard and panel picker', function () { + it('should display dashboard and panel selector when select button clicked', async function () { + mockSearchResponse([]); + + const user = userEvent.setup(); + + render(); + + await user.click(ui.setDashboardButton.get()); + + expect(ui.dashboardPicker.dialog.get()).toBeInTheDocument(); + expect(ui.dashboardPicker.heading.get()).toBeInTheDocument(); + }); + + it('should enable Confirm button only when dashboard and panel selected', async function () { + mockSearchResponse([ + 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' }, + { id: 2, title: 'Second panel' }, + ], + }) + ); + + const user = userEvent.setup(); + + render(); + + await user.click(ui.setDashboardButton.get()); + expect(ui.dashboardPicker.confirmButton.get()).toBeDisabled(); + + await user.click(await findByTitle(ui.dashboardPicker.dialog.get(), 'My dashboard')); + expect(ui.dashboardPicker.confirmButton.get()).toBeDisabled(); + + await user.click(await findByText(ui.dashboardPicker.dialog.get(), 'First panel')); + expect(ui.dashboardPicker.confirmButton.get()).toBeEnabled(); + }); + + it('should add selected dashboard and panel as annotations', async function () { + mockSearchResponse([ + 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' }, + { id: 2, title: 'Second panel' }, + ], + }) + ); + + const user = userEvent.setup(); + + render(); + + await user.click(ui.setDashboardButton.get()); + await user.click(await findByTitle(ui.dashboardPicker.dialog.get(), 'My dashboard')); + + await user.click(await findByText(ui.dashboardPicker.dialog.get(), 'Second panel')); + + await user.click(ui.dashboardPicker.confirmButton.get()); + + const annotationKeyElements = ui.annotationKeys.getAll(); + const annotationValueElements = ui.annotationValues.getAll(); + + expect(ui.dashboardPicker.dialog.query()).not.toBeInTheDocument(); + + expect(annotationKeyElements).toHaveLength(2); + expect(annotationValueElements).toHaveLength(2); + + expect(annotationKeyElements[0]).toHaveTextContent('Dashboard UID'); + expect(annotationValueElements[0]).toHaveTextContent('dash-test-uid'); + + expect(annotationKeyElements[1]).toHaveTextContent('Panel ID'); + expect(annotationValueElements[1]).toHaveTextContent('2'); + }); + + // 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 () { + mockSearchResponse([ + mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }), + mockDashboardSearchItem({ + title: 'My other dashboard', + uid: 'dash-other-uid', + type: DashboardSearchItemType.DashDB, + }), + ]); + + mockGetDashboardResponse( + mockDashboardDto({ + title: 'My dashboard', + uid: 'dash-test-uid', + panels: [ + { id: 1, title: 'First panel' }, + { id: 2, title: 'Second panel' }, + ], + }) + ); + mockGetDashboardResponse( + mockDashboardDto({ + title: 'My other dashboard', + uid: 'dash-other-uid', + panels: [{ id: 3, title: 'Third panel' }], + }) + ); + + const user = userEvent.setup(); + + render( + + ); + + let annotationValueElements = ui.annotationValues.getAll(); + expect(annotationValueElements[0]).toHaveTextContent('dash-test-uid'); + expect(annotationValueElements[1]).toHaveTextContent('1'); + + await user.click(ui.setDashboardButton.get()); + await user.click(await findByTitle(ui.dashboardPicker.dialog.get(), 'My other dashboard')); + await user.click(await findByText(ui.dashboardPicker.dialog.get(), 'Third panel')); + await user.click(ui.dashboardPicker.confirmButton.get()); + + expect(ui.dashboardPicker.dialog.query()).not.toBeInTheDocument(); + + const annotationKeyElements = ui.annotationKeys.getAll(); + annotationValueElements = ui.annotationValues.getAll(); + + expect(annotationKeyElements).toHaveLength(2); + expect(annotationValueElements).toHaveLength(2); + + expect(annotationKeyElements[0]).toHaveTextContent('Dashboard UID'); + expect(annotationValueElements[0]).toHaveTextContent('dash-other-uid'); + + expect(annotationKeyElements[1]).toHaveTextContent('Panel ID'); + expect(annotationValueElements[1]).toHaveTextContent('3'); + }); + }); +}); + +function mockSearchResponse(searchResult: DashboardSearchItem[]) { + server.use(rest.get('/api/search', (req, res, ctx) => res(ctx.json(searchResult)))); +} + +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: [], + isStarred: false, + ...searchItem, + }; +} + +function mockDashboardDto(dashboard: Partial) { + return { + dashboard: { + title: '', + uid: '', + templating: { list: [] }, + panels: [], + ...dashboard, + }, + meta: {}, + }; +} diff --git a/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx b/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx index be34a7d3e4c..58df2ededd9 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx @@ -1,21 +1,29 @@ import { css, cx } from '@emotion/css'; +import produce from 'immer'; import React, { useCallback } from 'react'; import { useFieldArray, useFormContext } from 'react-hook-form'; +import { useToggle } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; +import { Stack } from '@grafana/experimental'; import { Button, Field, Input, InputControl, Label, TextArea, useStyles2 } from '@grafana/ui'; import { RuleFormValues } from '../../types/rule-form'; +import { Annotation } from '../../utils/constants'; import { AnnotationKeyInput } from './AnnotationKeyInput'; +import { DashboardPicker } from './DashboardPicker'; const AnnotationsField = () => { const styles = useStyles2(getStyles); + const [showPanelSelector, setShowPanelSelector] = useToggle(false); + const { control, register, watch, formState: { errors }, + setValue, } = useFormContext(); const annotations = watch('annotations'); @@ -26,6 +34,31 @@ const AnnotationsField = () => { 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 setSelectedDashboardAndPanelId = (dashboardUid: string, panelId: string) => { + const updatedAnnotations = produce(annotations, (draft) => { + const dashboardAnnotation = draft.find((a) => a.key === Annotation.dashboardUID); + const panelAnnotation = draft.find((a) => a.key === Annotation.panelID); + + if (dashboardAnnotation) { + dashboardAnnotation.value = dashboardUid; + } else { + draft.push({ key: Annotation.dashboardUID, value: dashboardUid }); + } + + if (panelAnnotation) { + panelAnnotation.value = panelId; + } else { + draft.push({ key: Annotation.panelID, value: panelId }); + } + }); + + setValue('annotations', updatedAnnotations); + setShowPanelSelector(false); + }; + return ( <> @@ -81,17 +114,31 @@ const AnnotationsField = () => { ); })} - + + + + + {showPanelSelector && ( + setShowPanelSelector(false)} + /> + )} ); diff --git a/public/app/features/alerting/unified/components/rule-editor/DashboardPicker.tsx b/public/app/features/alerting/unified/components/rule-editor/DashboardPicker.tsx new file mode 100644 index 00000000000..ab0fd01aa52 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/DashboardPicker.tsx @@ -0,0 +1,280 @@ +import { css, cx } from '@emotion/css'; +import React, { CSSProperties, useCallback, useMemo, useState } from 'react'; +import { useDebounce } from 'react-use'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { FixedSizeList } from 'react-window'; + +import { GrafanaTheme2 } from '@grafana/data/src'; +import { FilterInput, LoadingPlaceholder, useStyles2, Icon, Modal, Button, Alert } from '@grafana/ui'; + +import { dashboardApi } from '../../api/dashboardApi'; + +export interface PanelDTO { + id: number; + title?: string; +} + +function panelSort(a: PanelDTO, b: PanelDTO) { + if (a.title && b.title) { + return a.title.localeCompare(b.title); + } + if (a.title && !b.title) { + return 1; + } else if (!a.title && b.title) { + return -1; + } + + return 0; +} + +interface DashboardPickerProps { + isOpen: boolean; + dashboardUid?: string | undefined; + panelId?: string | undefined; + onChange: (dashboardUid: string, panelId: string) => void; + onDismiss: () => void; +} + +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, useDashboardQuery } = dashboardApi; + + const { currentData: filteredDashboards = [], isFetching: isDashSearchFetching } = useSearchQuery({ + query: debouncedDashboardFilter, + }); + const { currentData: dashboardResult, isFetching: isDashboardFetching } = useDashboardQuery( + { uid: selectedDashboardUid ?? '' }, + { skip: !selectedDashboardUid } + ); + + const handleDashboardChange = useCallback((dashboardUid: string) => { + setSelectedDashboardUid(dashboardUid); + setSelectedPanelId(undefined); + }, []); + + const filteredPanels = + dashboardResult?.dashboard?.panels + ?.filter((panel): panel is PanelDTO => typeof panel.id === 'number') + ?.filter((panel) => panel.title?.toLowerCase().includes(panelFilter.toLowerCase())) + .sort(panelSort) ?? []; + + const currentPanel = dashboardResult?.dashboard?.panels?.find((panel) => panel.id.toString() === selectedPanelId); + + const selectedDashboardIndex = useMemo(() => { + return filteredDashboards.map((dashboard) => dashboard.uid).indexOf(selectedDashboardUid ?? ''); + }, [filteredDashboards, selectedDashboardUid]); + + const isDefaultSelection = dashboardUid && dashboardUid === selectedDashboardUid; + const selectedDashboardIsInPageResult = selectedDashboardIndex >= 0; + + const scrollToItem = useCallback( + (node) => { + const canScroll = selectedDashboardIndex >= 0; + + if (isDefaultSelection && canScroll) { + node?.scrollToItem(selectedDashboardIndex, 'smart'); + } + }, + [isDefaultSelection, selectedDashboardIndex] + ); + + useDebounce( + () => { + setDebouncedDashboardFilter(dashboardFilter); + }, + 500, + [dashboardFilter] + ); + + const DashboardRow = ({ index, style }: { index: number; style?: CSSProperties }) => { + const dashboard = filteredDashboards[index]; + const isSelected = selectedDashboardUid === dashboard.uid; + + return ( +
handleDashboardChange(dashboard.uid)} + > +
{dashboard.title}
+
+ {dashboard.folderTitle ?? 'General'} +
+
+ ); + }; + + const PanelRow = ({ index, style }: { index: number; style: CSSProperties }) => { + const panel = filteredPanels[index]; + const isSelected = selectedPanelId === panel.id.toString(); + + return ( +
setSelectedPanelId(panel.id.toString())} + > + {panel.title || ''} +
+ ); + }; + + return ( + + {/* This alert shows if the selected dashboard is not found in the first page of dashboards */} + {!selectedDashboardIsInPageResult && dashboardUid && ( + +
+ Dashboard: {dashboardResult?.dashboard.title} ({dashboardResult?.dashboard.uid}) in folder{' '} + {dashboardResult?.meta.folderTitle ?? 'General'} +
+ {Boolean(currentPanel) && ( +
+ Panel: {currentPanel.title} ({currentPanel.id}) +
+ )} +
+ )} +
+ + + +
+ {isDashSearchFetching && ( + + )} + + {!isDashSearchFetching && ( + + {({ height, width }) => ( + + {DashboardRow} + + )} + + )} +
+ +
+ {!dashboardUid && !isDashboardFetching &&
Select a dashboard to get a list of available panels
} + {isDashboardFetching && ( + + )} + + {!isDashboardFetching && ( + + {({ width, height }) => ( + + {PanelRow} + + )} + + )} +
+
+ + + + +
+ ); +}; + +const getPickerStyles = (theme: GrafanaTheme2) => ({ + container: css` + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: min-content auto; + gap: ${theme.spacing(2)}; + flex: 1; + `, + column: css` + flex: 1 1 auto; + `, + dashboardTitle: css` + height: 22px; + font-weight: ${theme.typography.fontWeightBold}; + `, + dashboardFolder: css` + height: 20px; + font-size: ${theme.typography.bodySmall.fontSize}; + color: ${theme.colors.text.secondary}; + display: flex; + flex-direction: row; + justify-content: flex-start; + column-gap: ${theme.spacing(1)}; + align-items: center; + `, + row: css` + padding: ${theme.spacing(0.5)}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; + border: 2px solid transparent; + `, + rowSelected: css` + border-color: ${theme.colors.primary.border}; + `, + rowOdd: css` + background-color: ${theme.colors.background.secondary}; + `, + loadingPlaceholder: css` + height: 100%; + display: flex; + justify-content: center; + align-items: center; + `, + modal: css` + height: 100%; + `, + modalContent: css` + flex: 1; + display: flex; + flex-direction: column; + `, + modalAlert: css` + flex-grow: 0; + `, +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/DetailsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/DetailsStep.tsx index c40b8fa405f..4c6bcf2adae 100644 --- a/public/app/features/alerting/unified/components/rule-editor/DetailsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/DetailsStep.tsx @@ -18,7 +18,7 @@ export function DetailsStep() { ({ - ...(jest.requireActual('@grafana/runtime') as unknown as object), - getBackendSrv: () => backendSrv, -})); - beforeAll(() => { + setBackendSrv(backendSrv); server.listen({ onUnhandledRequest: 'error' }); });