mirror of https://github.com/grafana/grafana
Alerting: Add dashboard and panel picker to the rule form (#58304)
Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>pull/59145/head
parent
245a59548c
commit
cae5d89d0f
@ -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<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}` }), |
||||
}), |
||||
}), |
||||
}); |
||||
@ -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<RuleFormValues> }) { |
||||
const store = mockStore(() => null); |
||||
const formApi = useForm<RuleFormValues>({ defaultValues: { ...getDefaultFormValues(), ...formValues } }); |
||||
|
||||
return ( |
||||
<Provider store={store}> |
||||
<FormProvider {...formApi}> |
||||
<AnnotationsField /> |
||||
</FormProvider> |
||||
</Provider> |
||||
); |
||||
} |
||||
|
||||
describe('AnnotationsField', function () { |
||||
it('should display default list of annotations', function () { |
||||
render(<FormWrapper />); |
||||
|
||||
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(<FormWrapper />); |
||||
|
||||
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(<FormWrapper />); |
||||
|
||||
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(<FormWrapper formValues={{ annotations: [] }} />); |
||||
|
||||
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( |
||||
<FormWrapper |
||||
formValues={{ |
||||
annotations: [ |
||||
{ key: Annotation.dashboardUID, value: 'dash-test-uid' }, |
||||
{ key: Annotation.panelID, value: '1' }, |
||||
], |
||||
}} |
||||
/> |
||||
); |
||||
|
||||
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<DashboardSearchItem[]>(searchResult)))); |
||||
} |
||||
|
||||
function mockGetDashboardResponse(dashboard: DashboardDTO) { |
||||
server.use( |
||||
rest.get(`/api/dashboards/uid/${dashboard.dashboard.uid}`, (req, res, ctx) => |
||||
res(ctx.json<DashboardDTO>(dashboard)) |
||||
) |
||||
); |
||||
} |
||||
|
||||
function mockDashboardSearchItem(searchItem: Partial<DashboardSearchItem>) { |
||||
return { |
||||
title: '', |
||||
uid: '', |
||||
type: DashboardSearchItemType.DashDB, |
||||
url: '', |
||||
uri: '', |
||||
items: [], |
||||
tags: [], |
||||
isStarred: false, |
||||
...searchItem, |
||||
}; |
||||
} |
||||
|
||||
function mockDashboardDto(dashboard: Partial<DashboardDTO['dashboard']>) { |
||||
return { |
||||
dashboard: { |
||||
title: '', |
||||
uid: '', |
||||
templating: { list: [] }, |
||||
panels: [], |
||||
...dashboard, |
||||
}, |
||||
meta: {}, |
||||
}; |
||||
} |
||||
@ -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 ( |
||||
<div |
||||
title={dashboard.title} |
||||
style={style} |
||||
className={cx(styles.row, { [styles.rowOdd]: index % 2 === 1, [styles.rowSelected]: isSelected })} |
||||
onClick={() => handleDashboardChange(dashboard.uid)} |
||||
> |
||||
<div className={styles.dashboardTitle}>{dashboard.title}</div> |
||||
<div className={styles.dashboardFolder}> |
||||
<Icon name="folder" /> {dashboard.folderTitle ?? 'General'} |
||||
</div> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
const PanelRow = ({ index, style }: { index: number; style: CSSProperties }) => { |
||||
const panel = filteredPanels[index]; |
||||
const isSelected = selectedPanelId === panel.id.toString(); |
||||
|
||||
return ( |
||||
<div |
||||
style={style} |
||||
className={cx(styles.row, { [styles.rowOdd]: index % 2 === 1, [styles.rowSelected]: isSelected })} |
||||
onClick={() => setSelectedPanelId(panel.id.toString())} |
||||
> |
||||
{panel.title || '<No title>'} |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
return ( |
||||
<Modal |
||||
title="Select dashboard and panel" |
||||
closeOnEscape |
||||
isOpen={isOpen} |
||||
onDismiss={onDismiss} |
||||
className={styles.modal} |
||||
contentClassName={styles.modalContent} |
||||
> |
||||
{/* This alert shows if the selected dashboard is not found in the first page of dashboards */} |
||||
{!selectedDashboardIsInPageResult && dashboardUid && ( |
||||
<Alert title="Current selection" severity="info" topSpacing={0} bottomSpacing={1} className={styles.modalAlert}> |
||||
<div> |
||||
Dashboard: {dashboardResult?.dashboard.title} ({dashboardResult?.dashboard.uid}) in folder{' '} |
||||
{dashboardResult?.meta.folderTitle ?? 'General'} |
||||
</div> |
||||
{Boolean(currentPanel) && ( |
||||
<div> |
||||
Panel: {currentPanel.title} ({currentPanel.id}) |
||||
</div> |
||||
)} |
||||
</Alert> |
||||
)} |
||||
<div className={styles.container}> |
||||
<FilterInput |
||||
value={dashboardFilter} |
||||
onChange={setDashboardFilter} |
||||
title="Search dashboard" |
||||
placeholder="Search dashboard" |
||||
autoFocus |
||||
/> |
||||
<FilterInput value={panelFilter} onChange={setPanelFilter} title="Search panel" placeholder="Search panel" /> |
||||
|
||||
<div className={styles.column}> |
||||
{isDashSearchFetching && ( |
||||
<LoadingPlaceholder text="Loading dashboards..." className={styles.loadingPlaceholder} /> |
||||
)} |
||||
|
||||
{!isDashSearchFetching && ( |
||||
<AutoSizer> |
||||
{({ height, width }) => ( |
||||
<FixedSizeList |
||||
ref={scrollToItem} |
||||
itemSize={50} |
||||
height={height} |
||||
width={width} |
||||
itemCount={filteredDashboards.length} |
||||
> |
||||
{DashboardRow} |
||||
</FixedSizeList> |
||||
)} |
||||
</AutoSizer> |
||||
)} |
||||
</div> |
||||
|
||||
<div className={styles.column}> |
||||
{!dashboardUid && !isDashboardFetching && <div>Select a dashboard to get a list of available panels</div>} |
||||
{isDashboardFetching && ( |
||||
<LoadingPlaceholder text="Loading dashboard..." className={styles.loadingPlaceholder} /> |
||||
)} |
||||
|
||||
{!isDashboardFetching && ( |
||||
<AutoSizer> |
||||
{({ width, height }) => ( |
||||
<FixedSizeList itemSize={32} height={height} width={width} itemCount={filteredPanels.length}> |
||||
{PanelRow} |
||||
</FixedSizeList> |
||||
)} |
||||
</AutoSizer> |
||||
)} |
||||
</div> |
||||
</div> |
||||
<Modal.ButtonRow> |
||||
<Button type="button" variant="secondary" onClick={onDismiss}> |
||||
Cancel |
||||
</Button> |
||||
<Button |
||||
type="button" |
||||
variant="primary" |
||||
disabled={!(selectedDashboardUid && selectedPanelId)} |
||||
onClick={() => { |
||||
if (selectedDashboardUid && selectedPanelId) { |
||||
onChange(selectedDashboardUid, selectedPanelId); |
||||
} |
||||
}} |
||||
> |
||||
Confirm |
||||
</Button> |
||||
</Modal.ButtonRow> |
||||
</Modal> |
||||
); |
||||
}; |
||||
|
||||
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; |
||||
`,
|
||||
}); |
||||
Loading…
Reference in new issue