Alerting: Use NestedFolderPicker (#96398)

Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com>
This commit is contained in:
Gilles De Mey 2024-11-19 11:41:31 +01:00 committed by GitHub
parent 8b7100a9aa
commit 0929feba06
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 133 additions and 271 deletions

View File

@ -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"],

View File

@ -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,
};
}

View File

@ -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();

View File

@ -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);

View File

@ -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');
});

View File

@ -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",
},

View File

@ -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';

View File

@ -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

View File

@ -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 &apos;/&apos; 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',
}),
});

View File

@ -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());

View File

@ -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';

View File

@ -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;
}

View File

@ -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;

View File

@ -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 };

View File

@ -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;
};

View File

@ -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));
});
},
}),

View File

@ -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);
}

View File

@ -18,5 +18,4 @@ export enum PermissionLevelString {
export enum SearchQueryType {
Folder = 'dash-folder',
Dashboard = 'dash-db',
AlertFolder = 'dash-folder-alerting',
}