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}
>
-
-
+
+ )}
);
}
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}
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ {isLoading
+ ? t('dashboard-scene.move-provisioned-dashboard-form.moving', 'Moving...')
+ : t('dashboard-scene.move-provisioned-dashboard-form.move-action', 'Move dashboard')}
+
- )}
-
- {currentFileData?.errors?.length && currentFileData.errors.length > 0 && (
-
- {currentFileData.errors.map((error, index) => (
- {error}
- ))}
-
- )}
-
-
-
-
-
-
-
-
-
- Cancel
-
-
- {isLoading
- ? t('dashboard-scene.move-provisioned-dashboard-form.moving', 'Moving...')
- : t('dashboard-scene.move-provisioned-dashboard-form.move-action', 'Move dashboard')}
-
-
-
-
+
+
+ )}
);
}
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 */}
-
-
- Cancel
-
-
- {request.isLoading
- ? t('browse-dashboards.delete-provisioned-folder-form.button-deleting', 'Deleting...')
- : t('browse-dashboards.delete-provisioned-folder-form.button-delete', 'Delete')}
-
-
-
-
-
+
+
+ Cancel
+
+
+ {isLoading || request.isLoading
+ ? t('browse-dashboards.delete-provisioned-folder-form.button-deleting', 'Deleting...')
+ : t('browse-dashboards.delete-provisioned-folder-form.button-delete', 'Delete')}
+
+
+
+
+
+ )}
+ >
);
}
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...",