From 1cb7a003410299c223af8686035aee9e37045c6a Mon Sep 17 00:00:00 2001 From: Lauren Date: Mon, 15 Dec 2025 20:50:16 +0000 Subject: [PATCH] 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 --- .../src/fixtures/folders.ts | 11 ++- .../src/handlers/api/folders/handlers.ts | 3 + .../folder.grafana.app/v1beta1/handlers.ts | 3 +- .../src/types/browse-dashboards.ts | 2 +- .../useFoldersQuery.test.tsx | 4 +- .../import-to-gma/ImportToGMARules.tsx | 5 +- .../components/rule-editor/FolderSelector.tsx | 5 +- .../app/features/browse-dashboards/types.ts | 1 + .../features/folders/state/navModel.test.ts | 95 +++++++++++++++++++ public/app/features/folders/state/navModel.ts | 6 +- 10 files changed, 123 insertions(+), 12 deletions(-) create mode 100644 public/app/features/folders/state/navModel.test.ts diff --git a/packages/grafana-test-utils/src/fixtures/folders.ts b/packages/grafana-test-utils/src/fixtures/folders.ts index 7fc9bed9e20..edbb204b95c 100644 --- a/packages/grafana-test-utils/src/fixtures/folders.ts +++ b/packages/grafana-test-utils/src/fixtures/folders.ts @@ -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, }, diff --git a/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts b/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts index 220beac70b3..6ab46e113cf 100644 --- a/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts +++ b/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts @@ -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 } : {}), }); }); diff --git a/packages/grafana-test-utils/src/handlers/apis/folder.grafana.app/v1beta1/handlers.ts b/packages/grafana-test-utils/src/handlers/apis/folder.grafana.app/v1beta1/handlers.ts index 122497813dc..bba696e8e72 100644 --- a/packages/grafana-test-utils/src/handlers/apis/folder.grafana.app/v1beta1/handlers.ts +++ b/packages/grafana-test-utils/src/handlers/apis/folder.grafana.app/v1beta1/handlers.ts @@ -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, }, diff --git a/packages/grafana-test-utils/src/types/browse-dashboards.ts b/packages/grafana-test-utils/src/types/browse-dashboards.ts index 5757b4a2ec0..80ed1e779ae 100644 --- a/packages/grafana-test-utils/src/types/browse-dashboards.ts +++ b/packages/grafana-test-utils/src/types/browse-dashboards.ts @@ -3,7 +3,7 @@ // @grafana/schema? // New package @grafana/core? @grafana/types? -enum ManagerKind { +export enum ManagerKind { Repo = 'repo', Terraform = 'terraform', Kubectl = 'kubectl', diff --git a/public/app/core/components/NestedFolderPicker/useFoldersQuery.test.tsx b/public/app/core/components/NestedFolderPicker/useFoldersQuery.test.tsx index 534c175d436..9a336b09ba0 100644 --- a/public/app/core/components/NestedFolderPicker/useFoldersQuery.test.tsx +++ b/public/app/core/components/NestedFolderPicker/useFoldersQuery.test.tsx @@ -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); }); diff --git a/public/app/features/alerting/unified/components/import-to-gma/ImportToGMARules.tsx b/public/app/features/alerting/unified/components/import-to-gma/ImportToGMARules.tsx index d88d446642d..98451409af5 100644 --- a/public/app/features/alerting/unified/components/import-to-gma/ImportToGMARules.tsx +++ b/public/app/features/alerting/unified/components/import-to-gma/ImportToGMARules.tsx @@ -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 } }) => ( - (
- ({ + ...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`); + }); + }); +}); diff --git a/public/app/features/folders/state/navModel.ts b/public/app/features/folders/state/navModel.ts index dd53189785e..1a4dffef993 100644 --- a/public/app/features/folders/state/navModel.ts +++ b/public/app/features/folders/state/navModel.ts @@ -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',