mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Add dashboard and panel picker to the rule form (#58304)
Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
parent
245a59548c
commit
cae5d89d0f
22
public/app/features/alerting/unified/api/dashboardApi.ts
Normal file
22
public/app/features/alerting/unified/api/dashboardApi.ts
Normal file
@ -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: {},
|
||||||
|
};
|
||||||
|
}
|
@ -1,21 +1,29 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
|
import produce from 'immer';
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||||
|
import { useToggle } from 'react-use';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { Stack } from '@grafana/experimental';
|
||||||
import { Button, Field, Input, InputControl, Label, TextArea, useStyles2 } from '@grafana/ui';
|
import { Button, Field, Input, InputControl, Label, TextArea, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { RuleFormValues } from '../../types/rule-form';
|
import { RuleFormValues } from '../../types/rule-form';
|
||||||
|
import { Annotation } from '../../utils/constants';
|
||||||
|
|
||||||
import { AnnotationKeyInput } from './AnnotationKeyInput';
|
import { AnnotationKeyInput } from './AnnotationKeyInput';
|
||||||
|
import { DashboardPicker } from './DashboardPicker';
|
||||||
|
|
||||||
const AnnotationsField = () => {
|
const AnnotationsField = () => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
const [showPanelSelector, setShowPanelSelector] = useToggle(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
register,
|
register,
|
||||||
watch,
|
watch,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
|
setValue,
|
||||||
} = useFormContext<RuleFormValues>();
|
} = useFormContext<RuleFormValues>();
|
||||||
const annotations = watch('annotations');
|
const annotations = watch('annotations');
|
||||||
|
|
||||||
@ -26,6 +34,31 @@ const AnnotationsField = () => {
|
|||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({ control, name: 'annotations' });
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Label>Summary and annotations</Label>
|
<Label>Summary and annotations</Label>
|
||||||
@ -81,6 +114,7 @@ const AnnotationsField = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<Stack direction="row" gap={1}>
|
||||||
<Button
|
<Button
|
||||||
className={styles.addAnnotationsButton}
|
className={styles.addAnnotationsButton}
|
||||||
icon="plus-circle"
|
icon="plus-circle"
|
||||||
@ -90,8 +124,21 @@ const AnnotationsField = () => {
|
|||||||
append({ key: '', value: '' });
|
append({ key: '', value: '' });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Add info
|
Add annotation
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button type="button" variant="secondary" icon="dashboard" onClick={() => setShowPanelSelector(true)}>
|
||||||
|
Set dashboard and panel
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
{showPanelSelector && (
|
||||||
|
<DashboardPicker
|
||||||
|
isOpen={true}
|
||||||
|
dashboardUid={selectedDashboardUid}
|
||||||
|
panelId={selectedPanelId}
|
||||||
|
onChange={setSelectedDashboardAndPanelId}
|
||||||
|
onDismiss={() => setShowPanelSelector(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -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;
|
||||||
|
`,
|
||||||
|
});
|
@ -18,7 +18,7 @@ export function DetailsStep() {
|
|||||||
<RuleEditorSection
|
<RuleEditorSection
|
||||||
stepNo={type === RuleFormType.cloudRecording ? 3 : 4}
|
stepNo={type === RuleFormType.cloudRecording ? 3 : 4}
|
||||||
title={
|
title={
|
||||||
type === RuleFormType.cloudRecording ? 'Add details for your recording rule' : 'Add details for your alert'
|
type === RuleFormType.cloudRecording ? 'Add details for your recording rule' : 'Add details for your alert rule'
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
type === RuleFormType.cloudRecording
|
type === RuleFormType.cloudRecording
|
||||||
|
@ -6,7 +6,7 @@ import { Provider } from 'react-redux';
|
|||||||
import 'whatwg-fetch';
|
import 'whatwg-fetch';
|
||||||
|
|
||||||
import { DataSourceJsonData, DataSourceSettings } from '@grafana/data';
|
import { DataSourceJsonData, DataSourceSettings } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
import { config, setBackendSrv } from '@grafana/runtime';
|
||||||
import { backendSrv } from 'app/core/services/backend_srv';
|
import { backendSrv } from 'app/core/services/backend_srv';
|
||||||
import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types';
|
import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types';
|
||||||
|
|
||||||
@ -17,12 +17,8 @@ import { useExternalDataSourceAlertmanagers } from './useExternalAmSelector';
|
|||||||
|
|
||||||
const server = setupServer();
|
const server = setupServer();
|
||||||
|
|
||||||
jest.mock('@grafana/runtime', () => ({
|
|
||||||
...(jest.requireActual('@grafana/runtime') as unknown as object),
|
|
||||||
getBackendSrv: () => backendSrv,
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
|
setBackendSrv(backendSrv);
|
||||||
server.listen({ onUnhandledRequest: 'error' });
|
server.listen({ onUnhandledRequest: 'error' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user