Alerting: Add managed folder validation frontend (#115203)

* hide alerts tab for git synced folders

* add tests for alert tab visibility

* hide managed folders from folder picker

* update UI so managed folders are disabled in dropdown not hidden

* add folder d to folder tree

* include folder d in useFolderQuery hook tests

* update provisioned folders from disabled to hidden in the folder selector

* remove disabled logic from NestedFolderList
This commit is contained in:
Lauren
2025-12-15 21:50:16 +01:00
committed by GitHub
parent 9aa8fb183d
commit 1cb7a00341
10 changed files with 123 additions and 12 deletions
@@ -1,6 +1,6 @@
import { Chance } from 'chance';
import { DashboardsTreeItem, DashboardViewItem, UIDashboardViewItem } from '../types/browse-dashboards';
import { DashboardsTreeItem, DashboardViewItem, ManagerKind, UIDashboardViewItem } from '../types/browse-dashboards';
function wellFormedEmptyFolder(
seed = 1,
@@ -64,13 +64,14 @@ function wellFormedFolder(
}
export function treeViewersCanEdit() {
const [, { folderA, folderC }] = wellFormedTree();
const [, { folderA, folderC, folderD }] = wellFormedTree();
return [
[folderA, folderC],
[folderA, folderC, folderD],
{
folderA,
folderC,
folderD,
},
] as const;
}
@@ -90,6 +91,8 @@ export function wellFormedTree() {
const folderB = wellFormedFolder(seed++);
const folderB_empty = wellFormedEmptyFolder(seed++);
const folderC = wellFormedFolder(seed++);
// folderD is marked as managed by repo (git-synced) for testing disabled folder behavior
const folderD = wellFormedFolder(seed++, {}, { managedBy: ManagerKind.Repo });
const dashbdD = wellFormedDashboard(seed++);
const dashbdE = wellFormedDashboard(seed++);
@@ -107,6 +110,7 @@ export function wellFormedTree() {
folderB,
folderB_empty,
folderC,
folderD,
dashbdD,
dashbdE,
],
@@ -123,6 +127,7 @@ export function wellFormedTree() {
folderB,
folderB_empty,
folderC,
folderD,
dashbdD,
dashbdE,
},
@@ -4,6 +4,7 @@ import { HttpResponse, http } from 'msw';
import { treeViewersCanEdit, wellFormedTree } from '../../../fixtures/folders';
const [mockTree, { folderB }] = wellFormedTree();
// folderD is included in mockTree and will be returned by the handlers with managedBy: 'repo'
const [mockTreeThatViewersCanEdit] = treeViewersCanEdit();
const collator = new Intl.Collator();
@@ -48,6 +49,7 @@ const listFoldersHandler = () =>
id: random.integer({ min: 1, max: 1000 }),
uid: folder.item.uid,
title: folder.item.kind === 'folder' ? folder.item.title : "invalid - this shouldn't happen",
...('managedBy' in folder.item && folder.item.managedBy ? { managedBy: folder.item.managedBy } : {}),
};
})
.sort((a, b) => collator.compare(a.title, b.title)) // API always sorts by title
@@ -76,6 +78,7 @@ const getFolderHandler = () =>
uid: folder?.item.uid,
...additionalProperties,
...(accessControlQueryParam ? { accessControl: mockAccessControl } : {}),
...('managedBy' in folder.item && folder.item.managedBy ? { managedBy: folder.item.managedBy } : {}),
});
});
@@ -5,6 +5,7 @@ import { wellFormedTree } from '../../../../fixtures/folders';
import { getErrorResponse } from '../../../helpers';
const [mockTree, { folderB }] = wellFormedTree();
// folderD is included in mockTree and will be returned by the handlers with managedBy: 'repo'
const baseResponse = {
kind: 'Folder',
@@ -24,7 +25,7 @@ const folderToAppPlatform = (folder: (typeof mockTree)[number]['item'], id?: num
// TODO: Generalise annotations in fixture data
'grafana.app/createdBy': 'user:1',
'grafana.app/updatedBy': 'user:2',
'grafana.app/managedBy': 'user',
'grafana.app/managedBy': 'managedBy' in folder ? folder.managedBy : 'user',
'grafana.app/updatedTimestamp': '2024-01-01T00:00:00Z',
'grafana.app/folder': folder.kind === 'folder' ? folder.parentUID : undefined,
},
@@ -3,7 +3,7 @@
// @grafana/schema?
// New package @grafana/core? @grafana/types?
enum ManagerKind {
export enum ManagerKind {
Repo = 'repo',
Terraform = 'terraform',
Kubectl = 'kubectl',
@@ -12,7 +12,7 @@ import { DashboardViewItem } from '../../../features/search/types';
import { useFoldersQuery } from './useFoldersQuery';
import { getCustomRootFolderItem, getRootFolderItem } from './utils';
const [_, { folderA, folderB, folderC }] = getFolderFixtures();
const [_, { folderA, folderB, folderC, folderD }] = getFolderFixtures();
runtime.setBackendSrv(backendSrv);
setupMockServer();
@@ -44,7 +44,7 @@ describe('useFoldersQuery', () => {
const [_dashboardsContainer, ...items] = await testFn();
const sortedItemTitles = items.map((item) => (item.item as DashboardViewItem).title).sort();
const expectedTitles = [folderA.item.title, folderB.item.title, folderC.item.title].sort();
const expectedTitles = [folderA.item.title, folderB.item.title, folderC.item.title, folderD.item.title].sort();
expect(sortedItemTitles).toEqual(expectedTitles);
});
@@ -21,8 +21,8 @@ import {
Stack,
Text,
} from '@grafana/ui';
import { NestedFolderPicker } from 'app/core/components/NestedFolderPicker/NestedFolderPicker';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
import { ProvisioningAwareFolderPicker } from 'app/features/provisioning/components/Shared/ProvisioningAwareFolderPicker';
import { Folder } from '../../types/rule-form';
import {
@@ -409,9 +409,10 @@ function TargetFolderField() {
name="targetFolder"
render={({ field: { onChange, ref, ...field } }) => (
<Stack width={42}>
<NestedFolderPicker
<ProvisioningAwareFolderPicker
permission="view"
showRootFolder={false}
repositoryName={undefined}
invalid={!!errors.targetFolder?.message}
{...field}
value={field.value?.uid}
@@ -3,7 +3,7 @@ import { Controller, useFormContext } from 'react-hook-form';
import { Trans, t } from '@grafana/i18n';
import { Field, Label, Stack } from '@grafana/ui';
import { NestedFolderPicker } from 'app/core/components/NestedFolderPicker/NestedFolderPicker';
import { ProvisioningAwareFolderPicker } from 'app/features/provisioning/components/Shared/ProvisioningAwareFolderPicker';
import { Folder, RuleFormValues } from '../../types/rule-form';
import { CreateNewFolder } from '../create-folder/CreateNewFolder';
@@ -48,9 +48,10 @@ export function FolderSelector() {
<Controller
render={({ field: { ref, ...field } }) => (
<div style={{ width: 420 }}>
<NestedFolderPicker
<ProvisioningAwareFolderPicker
permission="view"
showRootFolder={false}
repositoryName={undefined}
invalid={!!errors.folder?.message}
{...field}
value={folder?.uid}
@@ -44,6 +44,7 @@ export interface DashboardsTreeItem<T extends DashboardViewItemWithUIItems = Das
level: number;
isOpen: boolean;
parentUID?: string;
disabled?: boolean;
}
interface RendererUserProps {
@@ -0,0 +1,95 @@
import { config } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import { ManagerKind } from 'app/features/apiserver/types';
import { AccessControlAction } from 'app/types/accessControl';
import { FolderDTO } from 'app/types/folders';
import { buildNavModel, getAlertingTabID } from './navModel';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
config: {
unifiedAlertingEnabled: true,
},
}));
jest.mock('app/core/services/context_srv', () => ({
contextSrv: {
hasPermission: jest.fn(),
},
}));
describe('buildNavModel', () => {
const mockFolder: FolderDTO = {
uid: 'test-folder-uid',
title: 'Test Folder',
url: '/dashboards/f/test-folder-uid',
id: 1,
created: '',
createdBy: '',
hasAcl: false,
updated: '',
updatedBy: '',
canSave: true,
canEdit: true,
canAdmin: true,
canDelete: true,
version: 0,
};
beforeEach(() => {
jest.clearAllMocks();
(contextSrv.hasPermission as jest.Mock).mockReturnValue(true);
config.unifiedAlertingEnabled = true;
});
describe('Alerts tab visibility', () => {
it('should show Alerts tab for regular (non-managed) folders when user has permissions', () => {
const navModel = buildNavModel(mockFolder);
const alertingTab = navModel.children?.find((child) => child.id === getAlertingTabID(mockFolder.uid));
expect(alertingTab).toBeDefined();
expect(alertingTab?.text).toContain('Alert rules');
});
it('should hide Alerts tab for Git-synced folders', () => {
const gitSyncedFolder: FolderDTO = {
...mockFolder,
managedBy: ManagerKind.Repo,
};
const navModel = buildNavModel(gitSyncedFolder);
const alertingTab = navModel.children?.find((child) => child.id === getAlertingTabID(mockFolder.uid));
expect(alertingTab).toBeUndefined();
});
it('should hide Alerts tab when user lacks AlertingRuleRead permission', () => {
(contextSrv.hasPermission as jest.Mock).mockReturnValue(false);
const navModel = buildNavModel(mockFolder);
const alertingTab = navModel.children?.find((child) => child.id === getAlertingTabID(mockFolder.uid));
expect(alertingTab).toBeUndefined();
expect(contextSrv.hasPermission).toHaveBeenCalledWith(AccessControlAction.AlertingRuleRead);
});
it('should hide Alerts tab when unified alerting is disabled', () => {
config.unifiedAlertingEnabled = false;
const navModel = buildNavModel(mockFolder);
const alertingTab = navModel.children?.find((child) => child.id === getAlertingTabID(mockFolder.uid));
expect(alertingTab).toBeUndefined();
});
it('should show Alerts tab for regular folders with all conditions met', () => {
const navModel = buildNavModel(mockFolder);
const alertingTab = navModel.children?.find((child) => child.id === getAlertingTabID(mockFolder.uid));
expect(alertingTab).toBeDefined();
expect(alertingTab?.icon).toBe('bell');
expect(alertingTab?.url).toBe(`${mockFolder.url}/alerting`);
});
});
});
@@ -52,7 +52,11 @@ export function buildNavModel(folder: FolderDTO | FolderParent, parentsArg?: Fol
});
}
if (contextSrv.hasPermission(AccessControlAction.AlertingRuleRead) && config.unifiedAlertingEnabled) {
if (
!isProvisioned &&
contextSrv.hasPermission(AccessControlAction.AlertingRuleRead) &&
config.unifiedAlertingEnabled
) {
model.children!.push({
active: false,
icon: 'bell',