diff --git a/public/app/core/components/AccessControl/Permissions.tsx b/public/app/core/components/AccessControl/Permissions.tsx
index 6106de8d640..1b3b3c1e0e3 100644
--- a/public/app/core/components/AccessControl/Permissions.tsx
+++ b/public/app/core/components/AccessControl/Permissions.tsx
@@ -5,7 +5,7 @@ import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
-import { Text, Box, Button, useStyles2, Space } from '@grafana/ui';
+import { Text, Box, Button, useStyles2 } from '@grafana/ui';
import { SlideDown } from 'app/core/components/Animations/SlideDown';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { DescendantCount } from 'app/features/browse-dashboards/components/BrowseActions/DescendantCount';
@@ -158,7 +158,7 @@ export const Permissions = ({
<>
{canSetPermissions && resource === 'folders' && (
- <>
+
This will change permissions for this folder and all its descendants. In total, this will affect:
@@ -170,8 +170,7 @@ export const Permissions = ({
$all: false,
}}
/>
-
- >
+
)}
{items.length === 0 && (
diff --git a/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.test.tsx b/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.test.tsx
new file mode 100644
index 00000000000..d6f62f10e0f
--- /dev/null
+++ b/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.test.tsx
@@ -0,0 +1,296 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { RepositoryView, useDeleteRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning/v0alpha1';
+import { FolderDTO } from 'app/types';
+
+import { ProvisionedFolderFormDataResult, useProvisionedFolderFormData } from '../hooks/useProvisionedFolderFormData';
+
+import { DeleteProvisionedFolderForm } from './DeleteProvisionedFolderForm';
+
+// Mock dependencies
+jest.mock('@grafana/runtime', () => ({
+ ...jest.requireActual('@grafana/runtime'),
+ getAppEvents: jest.fn(() => ({
+ publish: jest.fn(),
+ })),
+}));
+
+jest.mock('app/api/clients/provisioning/v0alpha1', () => ({
+ useDeleteRepositoryFilesWithPathMutation: jest.fn(),
+ provisioningAPI: {
+ endpoints: {
+ listRepository: {
+ select: jest.fn(() => () => ({ data: { items: [] } })),
+ },
+ },
+ },
+}));
+
+jest.mock('../hooks/useProvisionedFolderFormData');
+
+jest.mock('./BrowseActions/DescendantCount', () => ({
+ DescendantCount: () => 2 folders, 5 dashboards
,
+}));
+
+jest.mock('app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields', () => ({
+ DashboardEditFormSharedFields: () => ,
+}));
+
+const mockUseDeleteRepositoryFilesMutation = useDeleteRepositoryFilesWithPathMutation as jest.MockedFunction<
+ typeof useDeleteRepositoryFilesWithPathMutation
+>;
+const mockUseProvisionedFolderFormData = useProvisionedFolderFormData as jest.MockedFunction<
+ typeof useProvisionedFolderFormData
+>;
+
+const mockDeleteRepoFile = jest.fn();
+
+const mockParentFolder: FolderDTO = {
+ id: 1,
+ uid: 'folder-uid',
+ title: 'Test Folder',
+ url: '/dashboards/f/folder-uid/test-folder',
+ hasAcl: false,
+ canSave: true,
+ canEdit: true,
+ canAdmin: true,
+ canDelete: true,
+ createdBy: '',
+ created: '',
+ updatedBy: '',
+ updated: '',
+ version: 1,
+ parentUid: 'parent-folder-uid',
+};
+
+const mockRepository: RepositoryView = {
+ name: 'test-repo',
+ target: 'folder' as const,
+ title: 'Test Repository',
+ type: 'git' as const,
+ workflows: [],
+};
+
+const mockFolder = {
+ metadata: {
+ name: 'test-folder',
+ annotations: {
+ 'grafana.app/sourcePath': 'folders/test-folder.json',
+ },
+ },
+ spec: {
+ title: 'Test Folder',
+ },
+ status: {},
+};
+
+const mockFormData = {
+ repo: 'test-repo',
+ path: 'folders/test-folder.json',
+ ref: 'main',
+ workflow: 'write' as const,
+ comment: '',
+ title: 'Test Folder',
+};
+
+const defaultHookData: ProvisionedFolderFormDataResult = {
+ workflowOptions: [
+ { label: 'Write directly', value: 'write' },
+ { label: 'Create branch', value: 'branch' },
+ ],
+ isGitHub: true,
+ repository: mockRepository,
+ folder: mockFolder,
+ initialValues: mockFormData,
+};
+
+function setup(
+ props: Partial[0]> = {},
+ hookData = defaultHookData,
+ requestState: { isLoading: boolean; isSuccess: boolean; isError: boolean; error: Error | null } = {
+ isLoading: false,
+ isSuccess: false,
+ isError: false,
+ error: null,
+ }
+) {
+ const mockMutationResult = [mockDeleteRepoFile, requestState] as unknown as ReturnType<
+ typeof useDeleteRepositoryFilesWithPathMutation
+ >;
+ const mockHookResult = hookData as ReturnType;
+
+ mockUseDeleteRepositoryFilesMutation.mockReturnValue(mockMutationResult);
+ mockUseProvisionedFolderFormData.mockReturnValue(mockHookResult);
+
+ const onDismiss = jest.fn();
+ const defaultProps = {
+ parentFolder: mockParentFolder,
+ onDismiss,
+ };
+
+ const renderResult = render();
+
+ const clickDeleteButton = async () => {
+ const deleteButton = screen.getByRole('button', { name: /delete/i });
+ await userEvent.click(deleteButton);
+ };
+
+ return {
+ ...renderResult,
+ onDismiss,
+ mockDeleteRepoFile,
+ clickDeleteButton,
+ };
+}
+
+describe('DeleteProvisionedFolderForm', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ // Mock window.location.href
+ Object.defineProperty(window, 'location', {
+ value: { href: '' },
+ writable: true,
+ });
+ });
+
+ describe('rendering', () => {
+ it('should render component correctly ', () => {
+ setup();
+ // delete warning and descendant count
+ expect(screen.getByText(/This will delete this folder and all its descendants/)).toBeInTheDocument();
+ expect(screen.getByTestId('descendant-count')).toBeInTheDocument();
+
+ // delete and cancel buttons
+ expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
+ });
+
+ it('should not render if initialValues is null', () => {
+ setup({}, { ...defaultHookData, initialValues: undefined });
+ expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
+ });
+ });
+
+ describe('form submission', () => {
+ it('should call deleteRepoFile with correct parameters on form submission', async () => {
+ const { mockDeleteRepoFile, clickDeleteButton } = setup();
+
+ await clickDeleteButton();
+
+ await waitFor(() => {
+ expect(mockDeleteRepoFile).toHaveBeenCalledWith({
+ name: 'test-repo',
+ path: 'folders/test-folder.json/',
+ ref: undefined, // write workflow doesn't set ref
+ message: 'Delete folder: folders/test-folder.json',
+ });
+ });
+ });
+
+ it('should use custom commit message if provided', async () => {
+ const customFormData = {
+ ...mockFormData,
+ comment: 'Custom delete message',
+ };
+ const { mockDeleteRepoFile, clickDeleteButton } = setup(
+ {},
+ { ...defaultHookData, initialValues: customFormData }
+ );
+
+ await clickDeleteButton();
+
+ await waitFor(() => {
+ expect(mockDeleteRepoFile).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Custom delete message',
+ })
+ );
+ });
+ });
+
+ it('should set ref when workflow is branch', async () => {
+ const branchFormData = {
+ ...mockFormData,
+ workflow: 'branch' as const,
+ ref: 'feature-branch',
+ };
+ const { mockDeleteRepoFile, clickDeleteButton } = setup(
+ {},
+ { ...defaultHookData, initialValues: branchFormData }
+ );
+
+ await clickDeleteButton();
+
+ await waitFor(() => {
+ expect(mockDeleteRepoFile).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ref: 'feature-branch',
+ })
+ );
+ });
+ });
+
+ it('should not submit if repository name is missing', async () => {
+ const { mockDeleteRepoFile, clickDeleteButton } = setup({}, { ...defaultHookData, repository: undefined });
+
+ await clickDeleteButton();
+
+ await waitFor(() => {
+ expect(mockDeleteRepoFile).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('loading state', () => {
+ it('should show loading text and disable button when request is loading', () => {
+ setup({}, defaultHookData, { isLoading: true, isSuccess: false, isError: false, error: null });
+
+ const deleteButton = screen.getByRole('button', { name: /deleting/i });
+ expect(deleteButton).toBeDisabled();
+ });
+ });
+
+ describe('success handling', () => {
+ it('should navigate to parent folder on successful write workflow', async () => {
+ const successState = { isLoading: false, isSuccess: true, isError: false, error: null };
+ setup({}, defaultHookData, successState);
+
+ await waitFor(() => {
+ expect(window.location.href).toBe('/dashboards/f/parent-folder-uid/');
+ });
+ });
+
+ it('should navigate to dashboards root when parent folder has no parentUid', async () => {
+ const folderWithoutParent = { ...mockParentFolder, parentUid: undefined };
+ const successState = { isLoading: false, isSuccess: true, isError: false, error: null };
+ setup({ parentFolder: folderWithoutParent }, defaultHookData, successState);
+
+ await waitFor(() => {
+ expect(window.location.href).toBe('/dashboards');
+ });
+ });
+
+ it('should handle branch workflow success without navigation', async () => {
+ const branchFormData = { ...mockFormData, workflow: 'branch' } as unknown as typeof mockFormData;
+ const successState = { isLoading: false, isSuccess: true, isError: false, error: null };
+ setup({}, { ...defaultHookData, initialValues: branchFormData }, successState);
+
+ await waitFor(() => {
+ expect(window.location.href).toBe('');
+ });
+ });
+ });
+
+ describe('error handling', () => {
+ it('should handle request failure', async () => {
+ const error = new Error('API Error');
+ const errorState = { isLoading: false, isSuccess: false, isError: true, error };
+ setup({}, defaultHookData, errorState);
+
+ // Component should handle error gracefully without crashing
+ expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
+ });
+ });
+});
diff --git a/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.tsx b/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.tsx
new file mode 100644
index 00000000000..fac9bbc6864
--- /dev/null
+++ b/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.tsx
@@ -0,0 +1,171 @@
+import { useEffect } from 'react';
+import { FormProvider, useForm } from 'react-hook-form';
+
+import { AppEvents } from '@grafana/data';
+import { Trans, t } from '@grafana/i18n';
+import { getAppEvents } from '@grafana/runtime';
+import { Box, Button, Stack } from '@grafana/ui';
+import { Folder } from 'app/api/clients/folder/v1beta1';
+import { RepositoryView, useDeleteRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning/v0alpha1';
+import { AnnoKeySourcePath } from 'app/features/apiserver/types';
+import { DashboardEditFormSharedFields } from 'app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields';
+import { BaseProvisionedFormData } from 'app/features/dashboard-scene/saving/shared';
+import { FolderDTO } from 'app/types';
+
+import { useProvisionedFolderFormData } from '../hooks/useProvisionedFolderFormData';
+
+import { DescendantCount } from './BrowseActions/DescendantCount';
+import { getFolderURL } from './utils';
+
+interface FormProps extends DeleteProvisionedFolderFormProps {
+ initialValues: BaseProvisionedFormData;
+ repository?: RepositoryView;
+ workflowOptions: Array<{ label: string; value: string }>;
+ folder?: Folder;
+ isGitHub: boolean;
+}
+
+interface DeleteProvisionedFolderFormProps {
+ parentFolder?: FolderDTO;
+ onDismiss?: () => void;
+}
+
+function FormContent({
+ initialValues,
+ parentFolder,
+ repository,
+ workflowOptions,
+ folder,
+ isGitHub,
+ onDismiss,
+}: FormProps) {
+ const resourceId = parentFolder?.uid || '';
+
+ const [deleteRepoFile, request] = useDeleteRepositoryFilesWithPathMutation();
+
+ const methods = useForm({ defaultValues: initialValues });
+ const { handleSubmit, watch } = methods;
+ const workflow = watch('workflow');
+
+ const handleSubmitForm = async ({ repo, path, comment, ref }: BaseProvisionedFormData) => {
+ if (!repository?.name) {
+ return;
+ }
+
+ const commitMessage = comment || `Delete folder: ${folder?.metadata?.annotations?.[AnnoKeySourcePath]}`;
+ const targetRef = workflow === 'write' ? undefined : ref;
+
+ deleteRepoFile({
+ name: repo,
+ path: `${path}/`,
+ ref: targetRef,
+ message: commitMessage,
+ });
+ };
+
+ // TODO: move to a hook if this useEffect shared mostly the same logic as in NewProvisionedFolderForm
+ useEffect(() => {
+ if (request.isSuccess && repository) {
+ if (workflow === 'branch') {
+ // TODO: handle display banner https://github.com/grafana/git-ui-sync-project/issues/300
+ // TODO: implement when BE is ready
+ return;
+ }
+
+ if (workflow === 'write') {
+ getAppEvents().publish({
+ type: AppEvents.alertSuccess.name,
+ payload: [
+ t(
+ 'browse-dashboards.delete-provisioned-folder-form.alert-folder-deleted-successfully',
+ 'Folder deleted successfully'
+ ),
+ ],
+ });
+ // Navigate back to parent folder if it exists, otherwise go to dashboards root
+ if (parentFolder?.parentUid) {
+ window.location.href = getFolderURL(parentFolder.parentUid);
+ } else {
+ window.location.href = '/dashboards';
+ }
+ }
+ }
+
+ if (request.isError) {
+ getAppEvents().publish({
+ type: AppEvents.alertError.name,
+ payload: [
+ t('browse-dashboards.delete-provisioned-folder-form.api-error', 'Failed to delete folder'),
+ request.error,
+ ],
+ });
+ return;
+ }
+ }, [request, repository, workflow, parentFolder]);
+
+ return (
+
+
+
+ );
+}
+
+export function DeleteProvisionedFolderForm({ parentFolder, onDismiss }: DeleteProvisionedFolderFormProps) {
+ const { workflowOptions, isGitHub, repository, folder, initialValues } = useProvisionedFolderFormData({
+ folderUid: parentFolder?.uid,
+ action: 'delete',
+ title: parentFolder?.title,
+ });
+
+ if (!initialValues) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx
index 49472ebc4bb..d5cef0b3aa1 100644
--- a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx
+++ b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx
@@ -5,7 +5,6 @@ import { config, locationService, reportInteraction } from '@grafana/runtime';
import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui';
import { Permissions } from 'app/core/components/AccessControl';
import { appEvents } from 'app/core/core';
-import { ProvisionedResourceDeleteModal } from 'app/features/dashboard-scene/saving/provisioned/ProvisionedResourceDeleteModal';
import { FolderDTO } from 'app/types';
import { ShowModalReactEvent } from 'app/types/events';
@@ -15,6 +14,7 @@ import { getFolderPermissions } from '../permissions';
import { DeleteModal } from './BrowseActions/DeleteModal';
import { MoveModal } from './BrowseActions/MoveModal';
+import { DeleteProvisionedFolderForm } from './DeleteProvisionedFolderForm';
interface Props {
folder: FolderDTO;
@@ -23,6 +23,7 @@ interface Props {
export function FolderActionsButton({ folder }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [showPermissionsDrawer, setShowPermissionsDrawer] = useState(false);
+ const [showDeleteProvisionedFolderDrawer, setShowDeleteProvisionedFolderDrawer] = useState(false);
const [moveFolder] = useMoveFolderMutation();
const [deleteFolder] = useDeleteFolderMutation();
@@ -91,14 +92,7 @@ export function FolderActionsButton({ folder }: Props) {
};
const showDeleteProvisionedModal = () => {
- appEvents.publish(
- new ShowModalReactEvent({
- component: ProvisionedResourceDeleteModal,
- props: {
- resource: folder,
- },
- })
- );
+ setShowDeleteProvisionedFolderDrawer(true);
};
const managePermissionsLabel = t('browse-dashboards.folder-actions-button.manage-permissions', 'Manage permissions');
@@ -109,7 +103,8 @@ export function FolderActionsButton({ folder }: Props) {