mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Use NestedFolderPicker (#96398)
Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com>
This commit is contained in:
parent
8b7100a9aa
commit
0929feba06
@ -1682,9 +1682,6 @@ exports[`better eslint`] = {
|
||||
"public/app/features/alerting/unified/components/rule-editor/RuleEditorSection.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-editor/RuleFolderPicker.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-editor/RuleInspector.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
|
@ -3,9 +3,7 @@ import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { getWrapper, render, waitFor, waitForElementToBeRemoved, within } from 'test/test-utils';
|
||||
import { byRole, byTestId, byText } from 'testing-library-selector';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors/src';
|
||||
import { setDataSourceSrv } from '@grafana/runtime';
|
||||
import { DashboardSearchItem, DashboardSearchItemType } from 'app/features/search/types';
|
||||
import { RuleWithLocation } from 'app/types/unified-alerting';
|
||||
|
||||
import { AccessControlAction } from '../../../types';
|
||||
@ -18,7 +16,7 @@ import {
|
||||
|
||||
import { cloneRuleDefinition, CloneRuleEditor } from './CloneRuleEditor';
|
||||
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
|
||||
import { mockFeatureDiscoveryApi, mockSearchApi, setupMswServer } from './mockApi';
|
||||
import { mockFeatureDiscoveryApi, setupMswServer } from './mockApi';
|
||||
import {
|
||||
grantUserPermissions,
|
||||
mockDataSource,
|
||||
@ -44,12 +42,6 @@ jest.mock('./components/rule-editor/ExpressionEditor', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// For simplicity of the test we mock the NotificationPreview component
|
||||
// Otherwise we would need to mock a few more HTTP api calls which are not relevant for these tests
|
||||
jest.mock('./components/rule-editor/notificaton-preview/NotificationPreview', () => ({
|
||||
NotificationPreview: () => <div />,
|
||||
}));
|
||||
|
||||
jest.spyOn(AlertingQueryRunner.prototype, 'run').mockImplementation(() => Promise.resolve());
|
||||
|
||||
const server = setupMswServer();
|
||||
@ -58,7 +50,7 @@ const ui = {
|
||||
inputs: {
|
||||
name: byRole('textbox', { name: 'name' }),
|
||||
expr: byTestId('expr'),
|
||||
folderContainer: byTestId(selectors.components.FolderPicker.containerV2),
|
||||
folderContainer: byTestId('folder-picker'),
|
||||
namespace: byTestId('namespace-picker'),
|
||||
group: byTestId('group-picker'),
|
||||
annotationValue: (idx: number) => byTestId(`annotation-value-${idx}`),
|
||||
@ -84,14 +76,6 @@ describe('CloneRuleEditor', function () {
|
||||
it('should populate form values from the existing alert rule', async function () {
|
||||
setDataSourceSrv(new MockDataSourceSrv({}));
|
||||
|
||||
mockSearchApi(server).search([
|
||||
mockDashboardSearchItem({
|
||||
title: 'folder-one',
|
||||
uid: grafanaRulerRule.grafana_alert.namespace_uid,
|
||||
type: DashboardSearchItemType.DashDB,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(
|
||||
<CloneRuleEditor sourceRuleId={{ uid: grafanaRulerRule.grafana_alert.uid, ruleSourceName: 'grafana' }} />,
|
||||
{ wrapper: Wrapper }
|
||||
@ -105,7 +89,7 @@ describe('CloneRuleEditor', function () {
|
||||
await waitFor(() => {
|
||||
expect(ui.inputs.name.get()).toHaveValue(`${grafanaRulerRule.grafana_alert.title} (copy)`);
|
||||
});
|
||||
expect(ui.inputs.folderContainer.get()).toHaveTextContent('folder-one');
|
||||
expect(ui.inputs.folderContainer.get()).toHaveTextContent('Folder A');
|
||||
expect(ui.inputs.group.get()).toHaveTextContent(grafanaRulerRule.grafana_alert.rule_group);
|
||||
expect(
|
||||
byRole('listitem', {
|
||||
@ -147,16 +131,6 @@ describe('CloneRuleEditor', function () {
|
||||
rules: [originRule],
|
||||
});
|
||||
|
||||
mockSearchApi(server).search([
|
||||
mockDashboardSearchItem({
|
||||
title: 'folder-one',
|
||||
uid: '123',
|
||||
type: DashboardSearchItemType.DashDB,
|
||||
folderTitle: 'folder-one',
|
||||
folderUid: '123',
|
||||
}),
|
||||
]);
|
||||
|
||||
render(
|
||||
<CloneRuleEditor
|
||||
sourceRuleId={{
|
||||
@ -313,18 +287,3 @@ describe('CloneRuleEditor', function () {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function mockDashboardSearchItem(searchItem: Partial<DashboardSearchItem>) {
|
||||
return {
|
||||
title: '',
|
||||
uid: '',
|
||||
type: DashboardSearchItemType.DashDB,
|
||||
url: '',
|
||||
uri: '',
|
||||
items: [],
|
||||
tags: [],
|
||||
slug: '',
|
||||
isStarred: false,
|
||||
...searchItem,
|
||||
};
|
||||
}
|
||||
|
@ -1,13 +1,11 @@
|
||||
import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor';
|
||||
import { screen, waitForElementToBeRemoved } from 'test/test-utils';
|
||||
import { screen } from 'test/test-utils';
|
||||
import { byText } from 'testing-library-selector';
|
||||
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { PromApiFeatures, PromApplication } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { searchFolders } from '../../manage-dashboards/state/actions';
|
||||
|
||||
import { discoverFeaturesByUid } from './api/buildInfo';
|
||||
import { fetchRulerRulesGroup } from './api/ruler';
|
||||
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
|
||||
@ -30,7 +28,6 @@ jest.mock('./api/ruler', () => ({
|
||||
fetchRulerRulesGroup: jest.fn(),
|
||||
fetchRulerRulesNamespace: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../../app/features/manage-dashboards/state/actions');
|
||||
|
||||
// there's no angular scope in test and things go terribly wrong when trying to render the query editor row.
|
||||
// lets just skip it
|
||||
@ -116,8 +113,6 @@ const dataSources = {
|
||||
setupDataSources(...Object.values(dataSources));
|
||||
|
||||
const mocks = {
|
||||
// getAllDataSources: jest.mocked(config.getAllDataSources),
|
||||
searchFolders: jest.mocked(searchFolders),
|
||||
api: {
|
||||
discoverFeaturesByUid: jest.mocked(discoverFeaturesByUid),
|
||||
fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup),
|
||||
@ -181,11 +176,8 @@ describe('RuleEditor cloud: checking editable data sources', () => {
|
||||
return null;
|
||||
});
|
||||
|
||||
mocks.searchFolders.mockResolvedValue([]);
|
||||
|
||||
// render rule editor, select mimir/loki managed alerts
|
||||
const { user } = renderRuleEditor();
|
||||
await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner'));
|
||||
|
||||
await ui.inputs.name.find();
|
||||
|
||||
|
@ -1,7 +1,6 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor';
|
||||
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
|
||||
import { screen, waitForElementToBeRemoved } from 'test/test-utils';
|
||||
import { screen } from 'test/test-utils';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
@ -21,8 +20,6 @@ jest.mock('./components/rule-editor/ExpressionEditor', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../app/features/manage-dashboards/state/actions');
|
||||
|
||||
jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
|
||||
AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) => <div>{actions}</div>,
|
||||
}));
|
||||
@ -46,10 +43,7 @@ describe('RuleEditor cloud', () => {
|
||||
});
|
||||
|
||||
it('can create a new cloud alert', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderRuleEditor();
|
||||
await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner'));
|
||||
const { user } = renderRuleEditor();
|
||||
|
||||
const removeExpressionsButtons = screen.getAllByLabelText('Remove expression');
|
||||
expect(removeExpressionsButtons).toHaveLength(2);
|
||||
|
@ -1,51 +1,24 @@
|
||||
import { screen, waitForElementToBeRemoved } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as React from 'react';
|
||||
import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor';
|
||||
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
|
||||
import { screen } from 'test/test-utils';
|
||||
import { byRole } from 'testing-library-selector';
|
||||
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { mockFeatureDiscoveryApi, setupMswServer } from 'app/features/alerting/unified/mockApi';
|
||||
import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { searchFolders } from '../../../../app/features/manage-dashboards/state/actions';
|
||||
|
||||
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
|
||||
import { grantUserPermissions, mockDataSource } from './mocks';
|
||||
import { grafanaRulerGroup, grafanaRulerRule } from './mocks/grafanaRulerApi';
|
||||
import { grafanaRulerGroup } from './mocks/grafanaRulerApi';
|
||||
import { setupDataSources } from './testSetup/datasources';
|
||||
import { buildInfoResponse } from './testSetup/featureDiscovery';
|
||||
import * as config from './utils/config';
|
||||
|
||||
jest.mock('./components/rule-editor/ExpressionEditor', () => ({
|
||||
ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => (
|
||||
<input value={value} data-testid="expr" onChange={(e) => onChange(e.target.value)} />
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../app/features/manage-dashboards/state/actions');
|
||||
|
||||
jest.mock('app/core/components/AppChrome/AppChromeUpdate', () => ({
|
||||
AppChromeUpdate: ({ actions }: { actions: React.ReactNode }) => <div>{actions}</div>,
|
||||
}));
|
||||
|
||||
// there's no angular scope in test and things go terribly wrong when trying to render the query editor row.
|
||||
// lets just skip it
|
||||
jest.mock('app/features/query/components/QueryEditorRow', () => ({
|
||||
QueryEditorRow: () => <p>hi</p>,
|
||||
}));
|
||||
|
||||
jest.spyOn(config, 'getAllDataSources');
|
||||
|
||||
jest.setTimeout(60 * 1000);
|
||||
|
||||
const mocks = {
|
||||
getAllDataSources: jest.mocked(config.getAllDataSources),
|
||||
searchFolders: jest.mocked(searchFolders),
|
||||
};
|
||||
|
||||
const server = setupMswServer();
|
||||
|
||||
describe('RuleEditor grafana managed rules', () => {
|
||||
@ -83,41 +56,17 @@ describe('RuleEditor grafana managed rules', () => {
|
||||
setupDataSources(dataSources.default);
|
||||
mockFeatureDiscoveryApi(server).discoverDsFeatures(dataSources.default, buildInfoResponse.mimir);
|
||||
|
||||
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
|
||||
mocks.searchFolders.mockResolvedValue([
|
||||
{
|
||||
title: 'Folder A',
|
||||
uid: grafanaRulerRule.grafana_alert.namespace_uid,
|
||||
id: 1,
|
||||
type: DashboardSearchItemType.DashDB,
|
||||
},
|
||||
{
|
||||
title: 'Folder B',
|
||||
id: 2,
|
||||
uid: 'b',
|
||||
type: DashboardSearchItemType.DashDB,
|
||||
},
|
||||
{
|
||||
title: 'Folder / with slash',
|
||||
uid: 'c',
|
||||
id: 2,
|
||||
type: DashboardSearchItemType.DashDB,
|
||||
},
|
||||
] as DashboardSearchHit[]);
|
||||
const { user } = renderRuleEditor();
|
||||
|
||||
renderRuleEditor();
|
||||
await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner'));
|
||||
|
||||
await userEvent.type(await ui.inputs.name.find(), 'my great new rule');
|
||||
|
||||
const folderInput = await ui.inputs.folder.find();
|
||||
await clickSelectOption(folderInput, 'Folder A');
|
||||
await user.type(await ui.inputs.name.find(), 'my great new rule');
|
||||
await user.click(await screen.findByRole('button', { name: /select folder/i }));
|
||||
await user.click(await screen.findByLabelText(/folder a/i));
|
||||
const groupInput = await ui.inputs.group.find();
|
||||
await userEvent.click(await byRole('combobox').find(groupInput));
|
||||
await user.click(await byRole('combobox').find(groupInput));
|
||||
await clickSelectOption(groupInput, grafanaRulerGroup.name);
|
||||
await userEvent.type(ui.inputs.annotationValue(1).get(), 'some description');
|
||||
await user.type(ui.inputs.annotationValue(1).get(), 'some description');
|
||||
|
||||
await userEvent.click(ui.buttons.saveAndExit.get());
|
||||
await user.click(ui.buttons.saveAndExit.get());
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('Rule added successfully');
|
||||
});
|
||||
|
@ -14,6 +14,7 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button
|
||||
],
|
||||
"condition": "C",
|
||||
"folder": {
|
||||
"kind": "folder",
|
||||
"title": "super folder",
|
||||
"uid": "abc",
|
||||
},
|
||||
|
@ -17,7 +17,7 @@ import {
|
||||
} from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { ExportFormats } from '../components/export/providers';
|
||||
import { Folder } from '../components/rule-editor/RuleFolderPicker';
|
||||
import { Folder } from '../types/rule-form';
|
||||
import { getDatasourceAPIUid, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from '../utils/datasource';
|
||||
import { arrayKeyValuesToObject } from '../utils/labels';
|
||||
import { isCloudRuleIdentifier, isPrometheusRuleIdentifier } from '../utils/rules';
|
||||
|
@ -4,26 +4,26 @@ import * as React from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Controller, FormProvider, useForm, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { AppEvents, GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { AsyncSelect, Box, Button, Field, Input, Label, Modal, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { NestedFolderPicker } from 'app/core/components/NestedFolderPicker/NestedFolderPicker';
|
||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { createFolder } from 'app/features/manage-dashboards/state/actions';
|
||||
import { useNewFolderMutation } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { alertRuleApi } from '../../api/alertRuleApi';
|
||||
import { GRAFANA_RULER_CONFIG } from '../../api/featureDiscoveryApi';
|
||||
import { RuleFormValues } from '../../types/rule-form';
|
||||
import { Folder, RuleFormValues } from '../../types/rule-form';
|
||||
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form';
|
||||
import { isGrafanaRecordingRuleByType, isGrafanaRulerRule } from '../../utils/rules';
|
||||
import { ProvisioningBadge } from '../Provisioning';
|
||||
import { evaluateEveryValidationOptions } from '../rules/EditRuleGroupModal';
|
||||
|
||||
import { EvaluationGroupQuickPick } from './EvaluationGroupQuickPick';
|
||||
import { containsSlashes, Folder, RuleFolderPicker } from './RuleFolderPicker';
|
||||
|
||||
export const MAX_GROUP_RESULTS = 1000;
|
||||
|
||||
@ -164,13 +164,18 @@ export function FolderAndGroup({
|
||||
<Controller
|
||||
render={({ field: { ref, ...field } }) => (
|
||||
<div style={{ width: 420 }}>
|
||||
<RuleFolderPicker
|
||||
inputId="folder"
|
||||
<NestedFolderPicker
|
||||
showRootFolder={false}
|
||||
invalid={!!errors.folder?.message}
|
||||
{...field}
|
||||
enableReset={true}
|
||||
onChange={({ title, uid }) => {
|
||||
field.onChange({ title, uid });
|
||||
value={folder?.uid}
|
||||
onChange={(uid, title) => {
|
||||
if (uid && title) {
|
||||
setValue('folder', { title, uid });
|
||||
} else {
|
||||
setValue('folder', undefined);
|
||||
}
|
||||
|
||||
resetGroup();
|
||||
}}
|
||||
/>
|
||||
@ -288,31 +293,27 @@ function FolderCreationModal({
|
||||
}): React.ReactElement {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const notifyApp = useAppNotification();
|
||||
const [title, setTitle] = useState('');
|
||||
const [createFolder] = useNewFolderMutation();
|
||||
|
||||
const onSubmit = async () => {
|
||||
const newFolder = await createFolder({ title: title });
|
||||
if (!newFolder.uid) {
|
||||
appEvents.emit(AppEvents.alertError, ['Folder could not be created']);
|
||||
return;
|
||||
const { data, error } = await createFolder({ title });
|
||||
|
||||
if (error) {
|
||||
notifyApp.error('Failed to create folder');
|
||||
} else if (data) {
|
||||
onCreate({ title: data.title, uid: data.uid });
|
||||
notifyApp.success('Folder created');
|
||||
}
|
||||
|
||||
const folder: Folder = { title: newFolder.title, uid: newFolder.uid };
|
||||
onCreate(folder);
|
||||
appEvents.emit(AppEvents.alertSuccess, ['Folder Created', 'OK']);
|
||||
};
|
||||
|
||||
const error = containsSlashes(title);
|
||||
|
||||
return (
|
||||
<Modal className={styles.modal} isOpen={true} title={'New folder'} onDismiss={onClose} onClickBackdrop={onClose}>
|
||||
<div className={styles.modalTitle}>Create a new folder to store your rule</div>
|
||||
|
||||
<form onSubmit={onSubmit}>
|
||||
<Field
|
||||
label={<Label htmlFor="folder">Folder name</Label>}
|
||||
error={"The folder name can't contain slashes"}
|
||||
invalid={error}
|
||||
>
|
||||
<Field label={<Label htmlFor="folder">Folder name</Label>}>
|
||||
<Input
|
||||
data-testid={selectors.components.AlertRules.newFolderNameField}
|
||||
autoFocus={true}
|
||||
@ -330,7 +331,7 @@ function FolderCreationModal({
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!title || error}
|
||||
disabled={!title}
|
||||
data-testid={selectors.components.AlertRules.newFolderNameCreateButton}
|
||||
>
|
||||
Create
|
||||
|
@ -1,76 +0,0 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Icon, Stack, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { Props as FolderPickerProps, OldFolderPicker } from 'app/core/components/Select/OldFolderPicker';
|
||||
import { PermissionLevelString, SearchQueryType } from 'app/types';
|
||||
|
||||
import { CustomAdd, FolderWarning } from '../../../../../core/components/Select/OldFolderPicker';
|
||||
|
||||
export interface Folder {
|
||||
title: string;
|
||||
uid: string;
|
||||
}
|
||||
|
||||
export interface RuleFolderPickerProps extends Omit<FolderPickerProps, 'initialTitle' | 'initialFolderId'> {
|
||||
value?: Folder;
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
const SlashesWarning = () => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const onClick = () => window.open('https://github.com/grafana/grafana/issues/42947', '_blank');
|
||||
return (
|
||||
<Stack gap={0.5}>
|
||||
<div className={styles.slashNotAllowed}>Folders with '/' character are not allowed.</div>
|
||||
<Tooltip placement="top" content={'Link to the Github issue'} theme="info">
|
||||
<Icon name="info-circle" size="xs" className={styles.infoIcon} onClick={onClick} />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const containsSlashes = (str: string): boolean => str.indexOf('/') !== -1;
|
||||
|
||||
export function RuleFolderPicker(props: RuleFolderPickerProps) {
|
||||
const { value } = props;
|
||||
const warningCondition = (folderName: string) => containsSlashes(folderName);
|
||||
|
||||
const folderWarning: FolderWarning = {
|
||||
warningCondition: warningCondition,
|
||||
warningComponent: SlashesWarning,
|
||||
};
|
||||
|
||||
const customAdd: CustomAdd = {
|
||||
disallowValues: true,
|
||||
isAllowedValue: (value) => !containsSlashes(value),
|
||||
};
|
||||
|
||||
return (
|
||||
<OldFolderPicker
|
||||
showRoot={false}
|
||||
rootName=""
|
||||
allowEmpty={true}
|
||||
initialFolderUid={value?.uid}
|
||||
searchQueryType={SearchQueryType.AlertFolder}
|
||||
{...props}
|
||||
permissionLevel={PermissionLevelString.Edit}
|
||||
customAdd={customAdd}
|
||||
folderWarning={folderWarning}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
slashNotAllowed: css({
|
||||
color: theme.colors.warning.main,
|
||||
fontSize: '12px',
|
||||
marginBottom: '2px',
|
||||
}),
|
||||
infoIcon: css({
|
||||
color: theme.colors.warning.main,
|
||||
fontSize: '12px',
|
||||
marginBottom: '2px',
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
});
|
@ -2,7 +2,7 @@ import { ReactNode } from 'react';
|
||||
import { Routes, Route } from 'react-router-dom-v5-compat';
|
||||
import { ui } from 'test/helpers/alertingRuleEditor';
|
||||
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
|
||||
import { render, screen, waitForElementToBeRemoved, userEvent } from 'test/test-utils';
|
||||
import { render, screen, userEvent } from 'test/test-utils';
|
||||
import { byRole } from 'testing-library-selector';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
@ -50,8 +50,8 @@ setupDataSources(dataSources.default, dataSources.am);
|
||||
|
||||
const selectFolderAndGroup = async () => {
|
||||
const user = userEvent.setup();
|
||||
const folderInput = await ui.inputs.folder.find();
|
||||
await clickSelectOption(folderInput, FOLDER_TITLE_HAPPY_PATH);
|
||||
await user.click(await screen.findByRole('button', { name: /select folder/i }));
|
||||
await user.click(await screen.findByLabelText(FOLDER_TITLE_HAPPY_PATH));
|
||||
const groupInput = await ui.inputs.group.find();
|
||||
await user.click(await byRole('combobox').find(groupInput));
|
||||
await clickSelectOption(groupInput, grafanaRulerGroup.name);
|
||||
@ -83,11 +83,9 @@ describe('Can create a new grafana managed alert using simplified routing', () =
|
||||
});
|
||||
|
||||
it('cannot create new grafana managed alert when using simplified routing and not selecting a contact point', async () => {
|
||||
const user = userEvent.setup();
|
||||
const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/'));
|
||||
|
||||
renderSimplifiedRuleEditor();
|
||||
await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner'));
|
||||
const { user } = renderSimplifiedRuleEditor();
|
||||
|
||||
await user.type(await ui.inputs.name.find(), 'my great new rule');
|
||||
|
||||
@ -108,18 +106,15 @@ describe('Can create a new grafana managed alert using simplified routing', () =
|
||||
it('simplified routing is not available when Grafana AM is not enabled', async () => {
|
||||
setAlertmanagerChoices(AlertmanagerChoice.External, 1);
|
||||
renderSimplifiedRuleEditor();
|
||||
await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner'));
|
||||
|
||||
expect(ui.inputs.simplifiedRouting.contactPointRouting.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('can create new grafana managed alert when using simplified routing and selecting a contact point', async () => {
|
||||
const user = userEvent.setup();
|
||||
const contactPointName = 'lotsa-emails';
|
||||
const capture = captureRequests((r) => r.method === 'POST' && r.url.includes('/api/ruler/'));
|
||||
|
||||
renderSimplifiedRuleEditor();
|
||||
await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner'));
|
||||
const { user } = renderSimplifiedRuleEditor();
|
||||
|
||||
await user.type(await ui.inputs.name.find(), 'my great new rule');
|
||||
|
||||
@ -143,9 +138,7 @@ describe('Can create a new grafana managed alert using simplified routing', () =
|
||||
testWithFeatureToggles(['alertingApiServer']);
|
||||
|
||||
it('allows selecting a contact point when using alerting API server', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSimplifiedRuleEditor();
|
||||
await waitForElementToBeRemoved(screen.queryAllByTestId('Spinner'));
|
||||
const { user } = renderSimplifiedRuleEditor();
|
||||
|
||||
await user.click(await ui.inputs.simplifiedRouting.contactPointRouting.find());
|
||||
|
||||
|
@ -8,13 +8,13 @@ import { Labels } from '../../../../../../types/unified-alerting-dto';
|
||||
import { mockApi, setupMswServer } from '../../../mockApi';
|
||||
import { grantUserPermissions, mockAlertQuery } from '../../../mocks';
|
||||
import { mockPreviewApiResponse } from '../../../mocks/grafanaRulerApi';
|
||||
import { Folder } from '../../../types/rule-form';
|
||||
import * as dataSource from '../../../utils/datasource';
|
||||
import {
|
||||
AlertManagerDataSource,
|
||||
GRAFANA_RULES_SOURCE_NAME,
|
||||
useGetAlertManagerDataSourcesByPermissionAndConfig,
|
||||
} from '../../../utils/datasource';
|
||||
import { Folder } from '../RuleFolderPicker';
|
||||
|
||||
import { NotificationPreview } from './NotificationPreview';
|
||||
import NotificationPreviewByAlertManager from './NotificationPreviewByAlertManager';
|
||||
|
@ -8,8 +8,8 @@ import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi';
|
||||
import { Stack } from 'app/plugins/datasource/parca/QueryEditor/Stack';
|
||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { Folder } from '../../../types/rule-form';
|
||||
import { useGetAlertManagerDataSourcesByPermissionAndConfig } from '../../../utils/datasource';
|
||||
import { Folder } from '../RuleFolderPicker';
|
||||
|
||||
const NotificationPreviewByAlertManager = lazy(() => import('./NotificationPreviewByAlertManager'));
|
||||
|
||||
@ -20,7 +20,7 @@ interface NotificationPreviewProps {
|
||||
}>;
|
||||
alertQueries: AlertQuery[];
|
||||
condition: string | null;
|
||||
folder: Folder | null;
|
||||
folder?: Folder;
|
||||
alertName?: string;
|
||||
alertUid?: string;
|
||||
}
|
||||
|
@ -1,9 +1,36 @@
|
||||
import { HttpResponse, http } from 'msw';
|
||||
|
||||
import { mockFolder } from 'app/features/alerting/unified/mocks';
|
||||
import { grafanaRulerRule } from 'app/features/alerting/unified/mocks/grafanaRulerApi';
|
||||
import { FolderDTO } from 'app/types';
|
||||
|
||||
export const getFolderHandler = (response = mockFolder()) =>
|
||||
const DEFAULT_FOLDERS: FolderDTO[] = [
|
||||
mockFolder({
|
||||
id: 1,
|
||||
uid: 'uid',
|
||||
title: 'title',
|
||||
}),
|
||||
mockFolder({
|
||||
id: 2,
|
||||
uid: grafanaRulerRule.grafana_alert.namespace_uid,
|
||||
title: 'Folder A',
|
||||
}),
|
||||
mockFolder({
|
||||
id: 3,
|
||||
uid: 'NAMESPACE_UID',
|
||||
title: 'Some Folder',
|
||||
}),
|
||||
];
|
||||
|
||||
export const getFolderHandler = (responseOverride?: FolderDTO) =>
|
||||
http.get<{ folderUid: string }>(`/api/folders/:folderUid`, ({ request, params }) => {
|
||||
const matchingFolder = DEFAULT_FOLDERS.find((folder) => folder.uid === params.folderUid);
|
||||
const response = responseOverride || matchingFolder;
|
||||
|
||||
if (!response) {
|
||||
return HttpResponse.json({ message: 'folder not found', status: 'not-found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const { accessControl, ...withoutAccessControl } = response;
|
||||
|
||||
// Server only responds with ACL if query param is sent
|
||||
@ -15,6 +42,16 @@ export const getFolderHandler = (response = mockFolder()) =>
|
||||
return HttpResponse.json(response);
|
||||
});
|
||||
|
||||
const handlers = [getFolderHandler()];
|
||||
const listFoldersHandler = (folders = DEFAULT_FOLDERS) =>
|
||||
http.get(`/api/folders`, () => {
|
||||
const strippedFolders = folders.map(({ id, uid, title }) => {
|
||||
return { id, uid, title };
|
||||
});
|
||||
// TODO: Add pagination/permission support here as required by tests
|
||||
// TODO: Add parentUid logic when clicking to expand nested folders
|
||||
return HttpResponse.json(strippedFolders);
|
||||
});
|
||||
|
||||
const handlers = [listFoldersHandler(), getFolderHandler()];
|
||||
|
||||
export default handlers;
|
||||
|
@ -1,7 +1,5 @@
|
||||
import { AlertQuery, GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { Folder } from '../components/rule-editor/RuleFolderPicker';
|
||||
|
||||
export enum RuleFormType {
|
||||
grafana = 'grafana-alerting',
|
||||
grafanaRecording = 'grafana-recording',
|
||||
@ -44,7 +42,7 @@ export interface RuleFormValues {
|
||||
condition: string | null; // refId of the query that gets alerted on
|
||||
noDataState: GrafanaAlertStateDecision;
|
||||
execErrState: GrafanaAlertStateDecision;
|
||||
folder: Folder | null;
|
||||
folder: Folder | undefined;
|
||||
evaluateEvery: string;
|
||||
evaluateFor: string;
|
||||
isPaused?: boolean;
|
||||
@ -61,3 +59,5 @@ export interface RuleFormValues {
|
||||
keepFiringForTimeUnit?: string;
|
||||
expression: string;
|
||||
}
|
||||
|
||||
export type Folder = { title: string; uid: string };
|
||||
|
@ -97,7 +97,7 @@ export const getDefaultFormValues = (): RuleFormValues => {
|
||||
group: '',
|
||||
|
||||
// grafana
|
||||
folder: null,
|
||||
folder: undefined,
|
||||
queries: [],
|
||||
recordingRulesQueries: [],
|
||||
condition: '',
|
||||
@ -756,16 +756,18 @@ export const panelToRuleFormValues = async (
|
||||
}
|
||||
|
||||
const { folderTitle, folderUid } = dashboard.meta;
|
||||
const folder =
|
||||
folderUid && folderTitle
|
||||
? {
|
||||
kind: 'folder',
|
||||
uid: folderUid,
|
||||
title: folderTitle,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const formValues = {
|
||||
type: RuleFormType.grafana,
|
||||
folder:
|
||||
folderUid && folderTitle
|
||||
? {
|
||||
uid: folderUid,
|
||||
title: folderTitle,
|
||||
}
|
||||
: undefined,
|
||||
folder,
|
||||
queries,
|
||||
name: panel.title,
|
||||
condition: queries[queries.length - 1].refId,
|
||||
@ -827,15 +829,18 @@ export const scenesPanelToRuleFormValues = async (vizPanel: VizPanel): Promise<P
|
||||
|
||||
const { folderTitle, folderUid } = dashboard.state.meta;
|
||||
|
||||
const folder =
|
||||
folderUid && folderTitle
|
||||
? {
|
||||
kind: 'folder',
|
||||
uid: folderUid,
|
||||
title: folderTitle,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const formValues = {
|
||||
type: RuleFormType.grafana,
|
||||
folder:
|
||||
folderUid && folderTitle
|
||||
? {
|
||||
uid: folderUid,
|
||||
title: folderTitle,
|
||||
}
|
||||
: undefined,
|
||||
folder,
|
||||
queries: grafanaQueries,
|
||||
name: vizPanel.state.title,
|
||||
condition: grafanaQueries[grafanaQueries.length - 1].refId,
|
||||
@ -851,6 +856,7 @@ export const scenesPanelToRuleFormValues = async (vizPanel: VizPanel): Promise<P
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return formValues;
|
||||
};
|
||||
|
||||
|
@ -4,9 +4,7 @@ import { lastValueFrom } from 'rxjs';
|
||||
import { AppEvents, isTruthy, locationUtil } from '@grafana/data';
|
||||
import { BackendSrvRequest, getBackendSrv, locationService } from '@grafana/runtime';
|
||||
import { Dashboard } from '@grafana/schema';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { createSuccessNotification } from 'app/core/copy/appNotification';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
|
||||
import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types';
|
||||
@ -125,14 +123,12 @@ export const browseDashboardsAPI = createApi({
|
||||
onQueryStarted: ({ parentUid }, { queryFulfilled, dispatch }) => {
|
||||
queryFulfilled.then(async ({ data: folder }) => {
|
||||
await contextSrv.fetchUserPermissions();
|
||||
dispatch(notifyApp(createSuccessNotification('Folder created')));
|
||||
dispatch(
|
||||
refetchChildren({
|
||||
parentUID: parentUid,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
);
|
||||
locationService.push(locationUtil.stripBaseFromUrl(folder.url));
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
@ -1,12 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { locationUtil } from '@grafana/data';
|
||||
import { config, locationService, reportInteraction } from '@grafana/runtime';
|
||||
import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui';
|
||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
import {
|
||||
getImportPhrase,
|
||||
getNewDashboardPhrase,
|
||||
getNewFolderPhrase,
|
||||
getImportPhrase,
|
||||
getNewPhrase,
|
||||
} from 'app/features/search/tempI18nPhrases';
|
||||
import { FolderDTO } from 'app/types';
|
||||
@ -26,18 +28,30 @@ export default function CreateNewButton({ parentFolder, canCreateDashboard, canC
|
||||
const location = useLocation();
|
||||
const [newFolder] = useNewFolderMutation();
|
||||
const [showNewFolderDrawer, setShowNewFolderDrawer] = useState(false);
|
||||
const notifyApp = useAppNotification();
|
||||
|
||||
const onCreateFolder = async (folderName: string) => {
|
||||
try {
|
||||
await newFolder({
|
||||
const folder = await newFolder({
|
||||
title: folderName,
|
||||
parentUid: parentFolder?.uid,
|
||||
});
|
||||
|
||||
const depth = parentFolder?.parents ? parentFolder.parents.length + 1 : 0;
|
||||
reportInteraction('grafana_manage_dashboards_folder_created', {
|
||||
is_subfolder: Boolean(parentFolder?.uid),
|
||||
folder_depth: depth,
|
||||
});
|
||||
|
||||
if (!folder.error) {
|
||||
notifyApp.success('Folder created');
|
||||
} else {
|
||||
notifyApp.error('Failed to create folder');
|
||||
}
|
||||
|
||||
if (folder.data) {
|
||||
locationService.push(locationUtil.stripBaseFromUrl(folder.data.url));
|
||||
}
|
||||
} finally {
|
||||
setShowNewFolderDrawer(false);
|
||||
}
|
||||
|
@ -18,5 +18,4 @@ export enum PermissionLevelString {
|
||||
export enum SearchQueryType {
|
||||
Folder = 'dash-folder',
|
||||
Dashboard = 'dash-db',
|
||||
AlertFolder = 'dash-folder-alerting',
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user