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 ( + +
+ + + + This will delete this folder and all its descendants. In total, this will affect: + + + + + + + {/* Delete / Cancel button */} + + + + + +
+
+ ); +} + +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) { {canViewPermissions && setShowPermissionsDrawer(true)} label={managePermissionsLabel} />} {canMoveFolder && } - {canDeleteFolders && ( + {/* TODO: remove isProvisionedFolder check once BE folder delete flow is complete */} + {canDeleteFolders && !isProvisionedFolder && ( )} + {showDeleteProvisionedFolderDrawer && ( + setShowDeleteProvisionedFolderDrawer(false)} + > + setShowDeleteProvisionedFolderDrawer(false)} + /> + + )} ); } diff --git a/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx index 002b24a7f51..617d96d5087 100644 --- a/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx +++ b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx @@ -39,6 +39,7 @@ const initialFormValues: Partial = { ref: `folder/${Date.now()}`, }; +// TODO: use useProvisionedFolderFormData hook to manage form data and repository state export function NewProvisionedFolderForm({ onSubmit, onCancel, parentFolder }: Props) { const { repository, folder, isLoading } = useGetResourceRepositoryView({ folderName: parentFolder?.uid }); const prURL = usePullRequestParam(); @@ -62,6 +63,7 @@ export function NewProvisionedFolderForm({ onSubmit, onCancel, parentFolder }: P setValue('workflow', getDefaultWorkflow(repository)); }, [repository, setValue]); + // TODO: replace with useProvisionedRequestHandler hook useEffect(() => { const appEvents = getAppEvents(); if (request.isSuccess && repository) { @@ -194,6 +196,7 @@ export function NewProvisionedFolderForm({ onSubmit, onCancel, parentFolder }: P /> + {/* TODO: use DashboardEditFormSharedFields to replace comment and workflow input*/}