From be61b37682cbbc6e0936e7b6286424c6e3f77161 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Tue, 16 Sep 2025 17:41:36 +0300 Subject: [PATCH] Provisioning: Use sync job for moving single resources (#111027) * Provisioning: Use sync job for moving single resources * cleanup * Update messages * Update tests * Wait till job is completed * Only animate forward progress * revert * i18n * Fix clearing deleted folders * Cleanup --- .../clients/provisioning/v0alpha1/index.ts | 11 +- .../provisioning/Shared/ProgressBar.tsx | 15 +- .../DeleteProvisionedDashboardForm.test.tsx | 26 +- .../DeleteProvisionedDashboardForm.tsx | 181 +++++++---- .../MoveProvisionedDashboardForm.test.tsx | 3 + .../MoveProvisionedDashboardForm.tsx | 288 +++++++++++------- .../DeleteProvisionedFolderForm.test.tsx | 83 +++-- .../Folders/DeleteProvisionedFolderForm.tsx | 182 +++++++---- public/locales/en-US/grafana.json | 4 +- 9 files changed, 492 insertions(+), 301 deletions(-) diff --git a/public/app/api/clients/provisioning/v0alpha1/index.ts b/public/app/api/clients/provisioning/v0alpha1/index.ts index 5b59f304aa2..9cfa13f2033 100644 --- a/public/app/api/clients/provisioning/v0alpha1/index.ts +++ b/public/app/api/clients/provisioning/v0alpha1/index.ts @@ -222,7 +222,16 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({ // Force a refetch of subfolders if user has opened them, so user see latest data if (job.status?.state === 'success' && (job.spec?.action === 'delete' || job.spec?.action === 'move')) { const state = getState().browseDashboards; - dispatch(clearFolders(Object.keys(state.childrenByParentUID))); + const action = job.spec?.action; + let childrenKeys = Object.keys(state.childrenByParentUID); + + if (action === 'delete') { + // Do not clear deleted resources to avoid 404s when refetching them + const deletedResourceNames = + job.spec?.[action]?.resources?.map((resource) => resource.name).filter(Boolean) || []; + childrenKeys = childrenKeys.filter((key) => !deletedResourceNames.includes(key)); + } + dispatch(clearFolders(childrenKeys)); } } catch (e) { console.error('Error in getRepositoryJobsWithPath:', e); diff --git a/public/app/features/provisioning/Shared/ProgressBar.tsx b/public/app/features/provisioning/Shared/ProgressBar.tsx index a9d9e3c57b0..e3a88412aed 100644 --- a/public/app/features/provisioning/Shared/ProgressBar.tsx +++ b/public/app/features/provisioning/Shared/ProgressBar.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import { useRef, useEffect } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; @@ -9,6 +10,14 @@ interface ProgressBarProps { } const ProgressBar = ({ progress, topBottomSpacing }: ProgressBarProps) => { const styles = useStyles2(getStyles, topBottomSpacing); + const previousProgress = useRef(0); + const shouldAnimate = progress !== undefined && progress > previousProgress.current; + + useEffect(() => { + if (progress !== undefined) { + previousProgress.current = progress; + } + }, [progress]); if (progress === undefined) { return null; @@ -16,7 +25,7 @@ const ProgressBar = ({ progress, topBottomSpacing }: ProgressBarProps) => { return (
-
+
); }; @@ -33,6 +42,10 @@ const getStyles = (theme: GrafanaTheme2, topBottomSpacing = 2) => ({ filler: css({ height: '100%', background: theme.colors.success.text, + }), + fillerAnimated: css({ + height: '100%', + background: theme.colors.success.text, [theme.transitions.handleMotion('no-preference', 'reduce')]: { transition: 'width 0.5s ease-in-out', }, diff --git a/public/app/features/provisioning/components/Dashboards/DeleteProvisionedDashboardForm.test.tsx b/public/app/features/provisioning/components/Dashboards/DeleteProvisionedDashboardForm.test.tsx index 7eb0409ded7..df309808307 100644 --- a/public/app/features/provisioning/components/Dashboards/DeleteProvisionedDashboardForm.test.tsx +++ b/public/app/features/provisioning/components/Dashboards/DeleteProvisionedDashboardForm.test.tsx @@ -3,7 +3,10 @@ import userEvent from '@testing-library/user-event'; import { AppEvents } from '@grafana/data'; import { getAppEvents } from '@grafana/runtime'; -import { useDeleteRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning/v0alpha1'; +import { + useCreateRepositoryJobsMutation, + useDeleteRepositoryFilesWithPathMutation, +} from 'app/api/clients/provisioning/v0alpha1'; import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; import { ProvisionedDashboardData, useProvisionedDashboardData } from '../../hooks/useProvisionedDashboardData'; @@ -16,6 +19,7 @@ jest.mock('../../hooks/useProvisionedDashboardData', () => ({ jest.mock('app/api/clients/provisioning/v0alpha1', () => ({ useDeleteRepositoryFilesWithPathMutation: jest.fn(), + useCreateRepositoryJobsMutation: jest.fn(), provisioningAPIv0alpha1: { endpoints: { listRepository: { @@ -57,10 +61,14 @@ jest.mock('../Shared/ResourceEditFormSharedFields', () => ({ })); const mockDeleteRepoFile = jest.fn(); +const mockCreateJob = jest.fn(); const mockPublish = jest.fn(); const mockUseDeleteRepositoryFiles = useDeleteRepositoryFilesWithPathMutation as jest.MockedFunction< typeof useDeleteRepositoryFilesWithPathMutation >; +const mockUseCreateRepositoryJobs = useCreateRepositoryJobsMutation as jest.MockedFunction< + typeof useCreateRepositoryJobsMutation +>; const mockUseProvisionedDashboardData = useProvisionedDashboardData as jest.MockedFunction< typeof useProvisionedDashboardData >; @@ -143,6 +151,10 @@ function setup(options: SetupOptions = {}) { mockDeleteRepoFile, createMockRequestState(requestState) as ReturnType[1], ]); + mockUseCreateRepositoryJobs.mockReturnValue([ + mockCreateJob, + createMockRequestState(requestState) as ReturnType[1], + ]); return { user, @@ -233,9 +245,8 @@ describe('DeleteProvisionedDashboardDrawer', () => { const deleteButton = screen.getByRole('button', { name: /delete dashboard/i }); await user.click(deleteButton); - expect(consoleSpy).toHaveBeenCalledWith('Missing required fields for deletion:', { + expect(consoleSpy).toHaveBeenCalledWith('Missing required repository for deletion:', { repo: '', - path: 'dashboards/test.json', }); expect(mockDeleteRepoFile).not.toHaveBeenCalled(); consoleSpy.mockRestore(); @@ -257,17 +268,10 @@ describe('DeleteProvisionedDashboardDrawer', () => { }, }); - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); - const deleteButton = screen.getByRole('button', { name: /delete dashboard/i }); await user.click(deleteButton); - expect(consoleSpy).toHaveBeenCalledWith('Missing required fields for deletion:', { - repo: 'test-repo', - path: '', - }); - expect(mockDeleteRepoFile).not.toHaveBeenCalled(); - consoleSpy.mockRestore(); + expect(mockDeleteRepoFile).toHaveBeenCalled(); }); }); diff --git a/public/app/features/provisioning/components/Dashboards/DeleteProvisionedDashboardForm.tsx b/public/app/features/provisioning/components/Dashboards/DeleteProvisionedDashboardForm.tsx index ca542bcfb3c..903052f6ce6 100644 --- a/public/app/features/provisioning/components/Dashboards/DeleteProvisionedDashboardForm.tsx +++ b/public/app/features/provisioning/components/Dashboards/DeleteProvisionedDashboardForm.tsx @@ -1,18 +1,21 @@ -import { useForm, FormProvider } from 'react-hook-form'; +import { useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom-v5-compat'; import { AppEvents } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { getAppEvents } from '@grafana/runtime'; import { Button, Drawer, Stack } from '@grafana/ui'; -import { RepositoryView, useDeleteRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning/v0alpha1'; -import { getFolderURL } from 'app/features/browse-dashboards/components/utils'; +import { Job, RepositoryView, useDeleteRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning/v0alpha1'; import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; +import { JobStatus } from 'app/features/provisioning/Job/JobStatus'; +import { StepStatusInfo } from 'app/features/provisioning/Wizard/types'; import { PROVISIONING_URL } from 'app/features/provisioning/constants'; -import { ProvisionedOperationInfo, useProvisionedRequestHandler } from '../../hooks/useProvisionedRequestHandler'; +import { useProvisionedRequestHandler } from '../../hooks/useProvisionedRequestHandler'; import { ProvisionedDashboardFormData } from '../../types/form'; import { buildResourceBranchRedirectUrl } from '../../utils/redirect'; +import { useBulkActionJob } from '../BulkActions/useBulkActionJob'; import { RepoInvalidStateBanner } from '../Shared/RepoInvalidStateBanner'; import { ResourceEditFormSharedFields } from '../Shared/ResourceEditFormSharedFields'; @@ -44,44 +47,85 @@ export function DeleteProvisionedDashboardForm({ const methods = useForm({ defaultValues }); const { editPanel: panelEditor } = dashboard.useState(); const { handleSubmit, watch } = methods; + const navigate = useNavigate(); const [ref, workflow] = watch(['ref', 'workflow']); + const { createBulkJob, isLoading } = useBulkActionJob(); const [deleteRepoFile, request] = useDeleteRepositoryFilesWithPathMutation(); + const [job, setJob] = useState(); + const [hasSubmitted, setHasSubmitted] = useState(false); + + // Helper function to show error messages + const showError = (error?: unknown) => { + const payload = [ + t('dashboard-scene.delete-provisioned-dashboard-form.api-error', 'Failed to delete dashboard'), + error, + ]; + + getAppEvents().publish({ + type: AppEvents.alertError.name, + payload, + }); + }; const handleSubmitForm = async ({ repo, path, comment }: ProvisionedDashboardFormData) => { - if (!repo || !path) { - console.error('Missing required fields for deletion:', { repo, path }); + if (!repo || !repository) { + console.error('Missing required repository for deletion:', { repo }); return; } - // If writing to the original branch, use the loaded reference; otherwise, use the selected ref. - const branchRef = workflow === 'write' ? loadedFromRef : ref; - const commitMessage = comment || `Delete dashboard: ${dashboard.state.title}`; + // Branch workflow: use /files API for direct file operations + if (workflow === 'branch') { + const branchRef = ref; + const commitMessage = comment || `Delete dashboard: ${dashboard.state.title}`; - deleteRepoFile({ - name: repo, - path: path, - ref: branchRef, - message: commitMessage, - }); + try { + await deleteRepoFile({ + name: repo, + path, + ref: branchRef, + message: commitMessage, + }).unwrap(); + } catch (error) { + showError(error); + } + return; + } + + // Write workflow: use Job API + const effectiveRef = isNew ? undefined : loadedFromRef; + const jobSpec = { + action: 'delete' as const, + delete: { + ref: effectiveRef, + resources: [ + { + name: dashboard.state.meta.uid ?? dashboard.state.meta.k8s?.name ?? '', + group: 'dashboard.grafana.app' as const, + kind: 'Dashboard' as const, + }, + ], + }, + }; + + try { + const result = await createBulkJob(repository, jobSpec); + if (!result.success) { + showError(result.error); + return; + } + + if (result.job) { + setJob(result.job); + setHasSubmitted(true); + } + } catch (error) { + showError(error); + } }; - const navigate = useNavigate(); - - const onError = (error: unknown) => { - getAppEvents().publish({ - type: AppEvents.alertError.name, - payload: [t('dashboard-scene.delete-provisioned-dashboard-form.api-error', 'Failed to delete dashboard'), error], - }); - }; - - const onWriteSuccess = () => { - dashboard.setState({ isDirty: false }); - panelEditor?.onDiscard(); - navigate(getFolderURL(defaultValues.folder.uid || '')); - }; - - const onBranchSuccess = (path: string, info: ProvisionedOperationInfo, urls?: Record) => { + // Branch success handler for /files API + const onBranchSuccess = (path: string, info: { repoType: string }, urls?: Record) => { panelEditor?.onDiscard(); const url = buildResourceBranchRedirectUrl({ baseUrl: `${PROVISIONING_URL}/${defaultValues.repo}/dashboard/preview/${path}`, @@ -92,6 +136,13 @@ export function DeleteProvisionedDashboardForm({ navigate(url); }; + const handleJobStatusChange = (statusInfo: StepStatusInfo) => { + if (statusInfo.status === 'success') { + panelEditor?.onDiscard(); + navigate('/dashboards'); + } + }; + useProvisionedRequestHandler({ request, workflow, @@ -103,8 +154,7 @@ export function DeleteProvisionedDashboardForm({ handlers: { onDismiss, onBranchSuccess: ({ path, urls }, info) => onBranchSuccess(path, info, urls), - onWriteSuccess, - onError, + onError: showError, }, }); @@ -114,40 +164,43 @@ export function DeleteProvisionedDashboardForm({ subtitle={dashboard.state.title} onClose={onDismiss} > - -
- - {readOnly && ( - + ) : ( + + + + {readOnly && ( + + )} + + - )} - - - {/* Save / Cancel button */} - - - + + + + - - - + +
+ )} ); } diff --git a/public/app/features/provisioning/components/Dashboards/MoveProvisionedDashboardForm.test.tsx b/public/app/features/provisioning/components/Dashboards/MoveProvisionedDashboardForm.test.tsx index 7c13ca16f55..016905dc0f9 100644 --- a/public/app/features/provisioning/components/Dashboards/MoveProvisionedDashboardForm.test.tsx +++ b/public/app/features/provisioning/components/Dashboards/MoveProvisionedDashboardForm.test.tsx @@ -5,6 +5,7 @@ import { getAppEvents } from '@grafana/runtime'; import { useGetFolderQuery } from 'app/api/clients/folder/v1beta1'; import { useCreateRepositoryFilesWithPathMutation, + useCreateRepositoryJobsMutation, useGetRepositoryFilesWithPathQuery, } from 'app/api/clients/provisioning/v0alpha1'; import { AnnoKeySourcePath } from 'app/features/apiserver/types'; @@ -25,6 +26,7 @@ jest.mock('@grafana/runtime', () => { jest.mock('app/api/clients/provisioning/v0alpha1', () => ({ useGetRepositoryFilesWithPathQuery: jest.fn(), useCreateRepositoryFilesWithPathMutation: jest.fn(), + useCreateRepositoryJobsMutation: jest.fn(), provisioningAPIv0alpha1: { endpoints: { listRepository: { @@ -146,6 +148,7 @@ describe('MoveProvisionedDashboardForm', () => { }); (useCreateRepositoryFilesWithPathMutation as jest.Mock).mockReturnValue([jest.fn(), mockCreateRequest]); + (useCreateRepositoryJobsMutation as jest.Mock).mockReturnValue([jest.fn(), mockCreateRequest]); (useProvisionedRequestHandler as jest.Mock).mockReturnValue(undefined); }); diff --git a/public/app/features/provisioning/components/Dashboards/MoveProvisionedDashboardForm.tsx b/public/app/features/provisioning/components/Dashboards/MoveProvisionedDashboardForm.tsx index 7c2a6e78643..d38b057e632 100644 --- a/public/app/features/provisioning/components/Dashboards/MoveProvisionedDashboardForm.tsx +++ b/public/app/features/provisioning/components/Dashboards/MoveProvisionedDashboardForm.tsx @@ -9,16 +9,20 @@ import { getAppEvents } from '@grafana/runtime'; import { Alert, Button, Drawer, Field, Input, Spinner, Stack } from '@grafana/ui'; import { useGetFolderQuery } from 'app/api/clients/folder/v1beta1'; import { + Job, RepositoryView, useCreateRepositoryFilesWithPathMutation, useGetRepositoryFilesWithPathQuery, } from 'app/api/clients/provisioning/v0alpha1'; import { AnnoKeySourcePath } from 'app/features/apiserver/types'; import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; +import { JobStatus } from 'app/features/provisioning/Job/JobStatus'; +import { StepStatusInfo } from 'app/features/provisioning/Wizard/types'; import { ProvisionedOperationInfo, useProvisionedRequestHandler } from '../../hooks/useProvisionedRequestHandler'; import { ProvisionedDashboardFormData } from '../../types/form'; import { buildResourceBranchRedirectUrl } from '../../utils/redirect'; +import { useBulkActionJob } from '../BulkActions/useBulkActionJob'; import { getTargetFolderPathInRepo } from '../BulkActions/utils'; import { ResourceEditFormSharedFields } from '../Shared/ResourceEditFormSharedFields'; @@ -64,8 +68,11 @@ export function MoveProvisionedDashboardForm({ const { data: targetFolder } = useGetFolderQuery(targetFolderUID ? { name: targetFolderUID! } : skipToken); + const { createBulkJob, isLoading: isCreatingJob } = useBulkActionJob(); const [moveFile, moveRequest] = useCreateRepositoryFilesWithPathMutation(); const [targetPath, setTargetPath] = useState(''); + const [job, setJob] = useState(); + const [hasSubmitted, setHasSubmitted] = useState(false); const navigate = useNavigate(); @@ -86,47 +93,97 @@ export function MoveProvisionedDashboardForm({ setTargetPath(newPath); }, [currentFileData, targetFolder, targetFolderUID, targetFolderTitle, repository]); + // Helper function to show error messages + const showError = (error?: unknown) => { + const payload = [t('dashboard-scene.move-provisioned-dashboard-form.api-error', 'Failed to move dashboard'), error]; + + appEvents.publish({ + type: AppEvents.alertError.name, + payload, + }); + }; + const handleSubmitForm = async ({ repo, path, comment }: ProvisionedDashboardFormData) => { - if (!currentFileData?.resource?.file) { - appEvents.publish({ - type: AppEvents.alertError.name, - payload: [ - t( - 'dashboard-scene.move-provisioned-dashboard-form.current-file-not-found', - 'Current dashboard file could not be found' - ), - ], - }); + if (!repo || !repository) { + showError(); return; } - const branchRef = workflow === 'write' ? loadedFromRef : ref; - const commitMessage = comment || `Move dashboard: ${dashboard.state.title}`; + const targetFolderPath = getTargetFolderPathInRepo({ + targetFolderUID, + targetFolder, + repoName: repository?.name, + }); + + if (!targetFolderPath) { + showError(); + return; + } + + // Branch workflow: use /files API for direct file operations + if (workflow === 'branch') { + if (!currentFileData?.resource?.file) { + appEvents.publish({ + type: AppEvents.alertError.name, + payload: [ + t( + 'dashboard-scene.move-provisioned-dashboard-form.current-file-not-found', + 'Current dashboard file could not be found' + ), + ], + }); + return; + } + + const branchRef = ref; + const commitMessage = comment || `Move dashboard: ${dashboard.state.title}`; + + try { + await moveFile({ + name: repo, + path: targetPath, + ref: branchRef, + message: commitMessage, + body: currentFileData.resource.file, + originalPath: path, + }).unwrap(); + } catch (error) { + showError(error); + } + return; + } + + // Write workflow: use Job API + const effectiveRef = isNew ? undefined : loadedFromRef; + const jobSpec = { + action: 'move' as const, + move: { + ref: effectiveRef, + targetPath: targetFolderPath, + resources: [ + { + name: dashboard.state.meta.uid ?? dashboard.state.meta.k8s?.name ?? '', + group: 'dashboard.grafana.app' as const, + kind: 'Dashboard' as const, + }, + ], + }, + }; try { - await moveFile({ - name: repo, - path: targetPath, - ref: branchRef, - message: commitMessage, - body: currentFileData.resource.file, - originalPath: path, - }).unwrap(); - } catch (error) { - appEvents.publish({ - type: AppEvents.alertError.name, - payload: [t('dashboard-scene.move-provisioned-dashboard-form.api-error', 'Failed to move dashboard'), error], - }); - } - }; + const result = await createBulkJob(repository, jobSpec); + if (!result.success) { + showError(); + return; + } - const onWriteSuccess = () => { - dashboard.setState({ isDirty: false }); - panelEditor?.onDiscard(); - if (targetFolderUID && targetFolderTitle) { - onSuccess(targetFolderUID, targetFolderTitle); + if (result.job) { + setJob(result.job); + setHasSubmitted(true); + } + } catch (error) { + showError(error); } - navigate('/dashboards'); }; const onBranchSuccess = (info: ProvisionedOperationInfo) => { @@ -140,33 +197,30 @@ export function MoveProvisionedDashboardForm({ navigate(url); }; - const onError = (error: unknown) => { - getAppEvents().publish({ - type: AppEvents.alertError.name, - payload: [ - t('dashboard-scene.move-provisioned-dashboard-form.alert-error-moving-dashboard', 'Error moving dashboard'), - error, - ], - }); + const handleJobStatusChange = (statusInfo: StepStatusInfo) => { + if (statusInfo.status === 'success') { + dashboard.setState({ isDirty: false }); + panelEditor?.onDiscard(); + navigate('/dashboards'); + } }; useProvisionedRequestHandler({ request: moveRequest, workflow, + resourceType: 'dashboard', successMessage: t( 'dashboard-scene.move-provisioned-dashboard-form.success-message', 'Dashboard moved successfully' ), - resourceType: 'dashboard', handlers: { onBranchSuccess: (_, info) => onBranchSuccess(info), - onWriteSuccess, onDismiss, - onError, + onError: showError, }, }); - const isLoading = moveRequest.isLoading; + const isLoading = isCreatingJob || moveRequest.isLoading; return ( - -
- - {readOnly && ( - - - This dashboard cannot be moved directly from Grafana because the repository is read-only. To move this - dashboard, please move the file in your Git repository. - - - )} + {hasSubmitted && job ? ( + + ) : ( + + + + {readOnly && ( + + + This dashboard cannot be moved directly from Grafana because the repository is read-only. To move + this dashboard, please move the file in your Git repository. + + + )} - {isLoadingFileData && ( - - -
- {t('dashboard-scene.move-provisioned-dashboard-form.loading-file-data', 'Loading dashboard data')} -
+ {isLoadingFileData && ( + + +
+ {t( + 'dashboard-scene.move-provisioned-dashboard-form.loading-dashboard-data', + 'Loading dashboard data' + )} +
+
+ )} + + {currentFileData?.errors?.length && currentFileData.errors.length > 0 && ( + + {currentFileData.errors.map((error, index) => ( +
{error}
+ ))} +
+ )} + + + + + + + + + + - )} - - {currentFileData?.errors?.length && currentFileData.errors.length > 0 && ( - - {currentFileData.errors.map((error, index) => ( -
{error}
- ))} -
- )} - - - - - - - - - - -
- -
+ +
+ )}
); } diff --git a/public/app/features/provisioning/components/Folders/DeleteProvisionedFolderForm.test.tsx b/public/app/features/provisioning/components/Folders/DeleteProvisionedFolderForm.test.tsx index 2fc4ffbf460..66520eb4a58 100644 --- a/public/app/features/provisioning/components/Folders/DeleteProvisionedFolderForm.test.tsx +++ b/public/app/features/provisioning/components/Folders/DeleteProvisionedFolderForm.test.tsx @@ -1,7 +1,11 @@ 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 { + RepositoryView, + useCreateRepositoryJobsMutation, + useDeleteRepositoryFilesWithPathMutation, +} from 'app/api/clients/provisioning/v0alpha1'; import { FolderDTO } from 'app/types/folders'; import { @@ -34,6 +38,7 @@ jest.mock('react-redux', () => { jest.mock('app/api/clients/provisioning/v0alpha1', () => ({ useDeleteRepositoryFilesWithPathMutation: jest.fn(), + useCreateRepositoryJobsMutation: jest.fn(), provisioningAPI: { endpoints: { listRepository: { @@ -83,11 +88,15 @@ const MOCK_DATA = { const mockUseDeleteRepositoryFilesMutation = useDeleteRepositoryFilesWithPathMutation as jest.MockedFunction< typeof useDeleteRepositoryFilesWithPathMutation >; +const mockUseCreateRepositoryJobsMutation = useCreateRepositoryJobsMutation as jest.MockedFunction< + typeof useCreateRepositoryJobsMutation +>; const mockUseProvisionedFolderFormData = useProvisionedFolderFormData as jest.MockedFunction< typeof useProvisionedFolderFormData >; const mockDeleteRepoFile = jest.fn(); +const mockCreateJob = jest.fn(); const mockParentFolder: FolderDTO = { id: 1, @@ -161,9 +170,13 @@ function setup( const mockMutationResult = [mockDeleteRepoFile, requestState] as unknown as ReturnType< typeof useDeleteRepositoryFilesWithPathMutation >; + const mockJobMutationResult = [mockCreateJob, requestState] as unknown as ReturnType< + typeof useCreateRepositoryJobsMutation + >; const mockHookResult = hookData as ReturnType; mockUseDeleteRepositoryFilesMutation.mockReturnValue(mockMutationResult); + mockUseCreateRepositoryJobsMutation.mockReturnValue(mockJobMutationResult); mockUseProvisionedFolderFormData.mockReturnValue(mockHookResult); const onDismiss = jest.fn(); @@ -183,6 +196,7 @@ function setup( ...renderResult, onDismiss, mockDeleteRepoFile, + mockCreateJob, mockNavigate, clickDeleteButton, }; @@ -219,24 +233,35 @@ describe('DeleteProvisionedFolderForm', () => { }); describe('form submission', () => { - it('should call deleteRepoFile with correct parameters on form submission', async () => { - const { mockDeleteRepoFile, clickDeleteButton } = setup(); + it('should call createJob with correct parameters on form submission for write workflow', async () => { + const { mockCreateJob, clickDeleteButton } = setup(); await clickDeleteButton(); await waitFor(() => { - expect(mockDeleteRepoFile).toHaveBeenCalledWith({ + expect(mockCreateJob).toHaveBeenCalledWith({ name: 'test-repo', - path: 'folders/test-folder.json/', - ref: undefined, // write workflow doesn't set ref - message: 'Delete folder: folders/test-folder.json', + jobSpec: { + action: 'delete', + delete: { + ref: undefined, // write workflow doesn't set ref + resources: [ + { + name: 'folder-uid', + group: 'folder.grafana.app', + kind: 'Folder', + }, + ], + }, + }, }); }); }); - it('should use custom commit message if provided', async () => { + it('should call deleteRepoFile with custom commit message for branch workflow', async () => { const customFormData = { ...mockFormData, + workflow: 'branch' as const, comment: 'Custom delete message', }; const { mockDeleteRepoFile, clickDeleteButton } = setup( @@ -247,11 +272,12 @@ describe('DeleteProvisionedFolderForm', () => { await clickDeleteButton(); await waitFor(() => { - expect(mockDeleteRepoFile).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Custom delete message', - }) - ); + expect(mockDeleteRepoFile).toHaveBeenCalledWith({ + name: 'test-repo', + path: 'folders/test-folder.json/', + ref: 'main', // branch workflow sets ref + message: 'Custom delete message', + }); }); }); @@ -298,37 +324,6 @@ describe('DeleteProvisionedFolderForm', () => { }); describe('success handling', () => { - it('should navigate to parent folder on successful write workflow', async () => { - const successState = { - isLoading: false, - isSuccess: true, - isError: false, - error: null, - data: MOCK_DATA, - }; - 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, - data: MOCK_DATA, - }; - setup({ parentFolder: folderWithoutParent }, defaultHookData, successState); - - await waitFor(() => { - expect(window.location.href).toBe('/dashboards'); - }); - }); - it('should handle branch workflow success with navigation', async () => { const branchFormData = { ...mockFormData, workflow: 'branch' } as unknown as typeof mockFormData; const successState = { diff --git a/public/app/features/provisioning/components/Folders/DeleteProvisionedFolderForm.tsx b/public/app/features/provisioning/components/Folders/DeleteProvisionedFolderForm.tsx index a81ac76b504..2c76f5c877f 100644 --- a/public/app/features/provisioning/components/Folders/DeleteProvisionedFolderForm.tsx +++ b/public/app/features/provisioning/components/Folders/DeleteProvisionedFolderForm.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom-v5-compat'; @@ -6,16 +7,17 @@ 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 { Job, RepositoryView, useDeleteRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning/v0alpha1'; import { DescendantCount } from 'app/features/browse-dashboards/components/BrowseActions/DescendantCount'; -import { getFolderURL } from 'app/features/browse-dashboards/components/utils'; +import { JobStatus } from 'app/features/provisioning/Job/JobStatus'; +import { StepStatusInfo } from 'app/features/provisioning/Wizard/types'; import { FolderDTO } from 'app/types/folders'; import { useProvisionedFolderFormData } from '../../hooks/useProvisionedFolderFormData'; import { ProvisionedOperationInfo, useProvisionedRequestHandler } from '../../hooks/useProvisionedRequestHandler'; import { BaseProvisionedFormData } from '../../types/form'; import { buildResourceBranchRedirectUrl } from '../../utils/redirect'; +import { useBulkActionJob } from '../BulkActions/useBulkActionJob'; import { RepoInvalidStateBanner } from '../Shared/RepoInvalidStateBanner'; import { ResourceEditFormSharedFields } from '../Shared/ResourceEditFormSharedFields'; @@ -33,28 +35,79 @@ interface DeleteProvisionedFolderFormProps { function FormContent({ initialValues, parentFolder, repository, workflowOptions, folder, onDismiss }: FormProps) { const resourceId = parentFolder?.uid || ''; - + const { createBulkJob, isLoading } = useBulkActionJob(); const [deleteRepoFile, request] = useDeleteRepositoryFilesWithPathMutation(); const navigate = useNavigate(); + const [job, setJob] = useState(); + const [hasSubmitted, setHasSubmitted] = useState(false); const methods = useForm({ defaultValues: initialValues }); const { handleSubmit, watch } = methods; - const workflow = watch('workflow'); + const [ref, workflow] = watch(['ref', 'workflow']); - const handleSubmitForm = async ({ repo, path, comment, ref }: BaseProvisionedFormData) => { - if (!repository?.name) { + // Helper function to show error messages + const showError = (error?: unknown) => { + const payload = [t('browse-dashboards.delete-provisioned-folder-form.api-error', 'Failed to delete folder'), error]; + + getAppEvents().publish({ + type: AppEvents.alertError.name, + payload, + }); + }; + + const handleSubmitForm = async ({ repo, path, comment }: BaseProvisionedFormData) => { + if (!repo || !repository) { + showError(); return; } - const commitMessage = comment || `Delete folder: ${folder?.metadata?.annotations?.[AnnoKeySourcePath]}`; - const targetRef = workflow === 'write' ? undefined : ref; + // Branch workflow: use /files API for direct file operations + if (workflow === 'branch') { + const branchRef = ref; + const commitMessage = comment || t('browse-dashboards.delete-provisioned-folder-form.commit', 'Delete folder'); - deleteRepoFile({ - name: repo, - path: `${path}/`, - ref: targetRef, - message: commitMessage, - }); + try { + await deleteRepoFile({ + name: repo, + path: `${path}/`, + ref: branchRef, + message: commitMessage, + }).unwrap(); + } catch (error) { + showError(error); + } + return; + } + + // Write workflow: use Job API + const jobSpec = { + action: 'delete' as const, + delete: { + ref: undefined, + resources: [ + { + name: resourceId, + group: 'folder.grafana.app' as const, + kind: 'Folder' as const, + }, + ], + }, + }; + + try { + const result = await createBulkJob(repository, jobSpec); + if (!result.success) { + showError(); + return; + } + + if (result.job) { + setJob(result.job); + setHasSubmitted(true); + } + } catch (error) { + showError(error); + } }; const onBranchSuccess = ({ urls }: { urls?: Record }, info: ProvisionedOperationInfo) => { @@ -69,80 +122,77 @@ function FormContent({ initialValues, parentFolder, repository, workflowOptions, } }; - const onWriteSuccess = () => { - // 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'; + const handleJobStatusChange = (statusInfo: StepStatusInfo) => { + if (statusInfo.status === 'success') { + onDismiss?.(); + navigate('/dashboards'); } }; const onError = (error: unknown) => { - getAppEvents().publish({ - type: AppEvents.alertError.name, - payload: [t('browse-dashboards.delete-provisioned-folder-form.api-error', 'Failed to delete folder'), error], - }); + showError(error); }; - // Use the repository-type and resource-type aware provisioned request handler useProvisionedRequestHandler({ request, workflow, + resourceType: 'folder', successMessage: t( 'browse-dashboards.delete-provisioned-folder-form.success-message', 'Folder deleted successfully' ), - resourceType: 'folder', - repository, handlers: { onDismiss, onBranchSuccess, - onWriteSuccess, onError, }, }); return ( - -
- - - - This will delete this folder and all its descendants. In total, this will affect: - - - + <> + {hasSubmitted && job ? ( + + ) : ( + + + + + + This will delete this folder and all its descendants. In total, this will affect: + + + - + - {/* Delete / Cancel button */} - - - - - - - + + + + +
+ +
+ )} + ); } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 5fdbb6d5c75..08ae4cd2d0a 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -3600,6 +3600,7 @@ "button-cancel": "Cancel", "button-delete": "Delete", "button-deleting": "Deleting...", + "commit": "Delete folder", "delete-warning": "This will delete this folder and all its descendants. In total, this will affect:", "success-message": "Folder deleted successfully" }, @@ -5913,13 +5914,12 @@ "usage-count_other": "Used on {{count}} dashboards" }, "move-provisioned-dashboard-form": { - "alert-error-moving-dashboard": "Error moving dashboard", "api-error": "Failed to move dashboard", "cancel-action": "Cancel", "current-file-not-found": "Current dashboard file could not be found", "drawer-title": "Move Provisioned Dashboard", "file-load-error": "Error loading dashboard", - "loading-file-data": "Loading dashboard data", + "loading-dashboard-data": "Loading dashboard data", "move-action": "Move dashboard", "move-read-only-message": "This dashboard cannot be moved directly from Grafana because the repository is read-only. To move this dashboard, please move the file in your Git repository.", "moving": "Moving...",