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
This commit is contained in:
Alex Khomenko
2025-09-16 17:41:36 +03:00
committed by GitHub
parent 4c2240dcc3
commit be61b37682
9 changed files with 492 additions and 301 deletions
@@ -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);
@@ -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 (
<div className={styles.container}>
<div className={styles.filler} style={{ width: `${progress}%` }}></div>
<div className={shouldAnimate ? styles.fillerAnimated : styles.filler} style={{ width: `${progress}%` }} />
</div>
);
};
@@ -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',
},
@@ -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<typeof useDeleteRepositoryFilesWithPathMutation>[1],
]);
mockUseCreateRepositoryJobs.mockReturnValue([
mockCreateJob,
createMockRequestState(requestState) as ReturnType<typeof useCreateRepositoryJobsMutation>[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();
});
});
@@ -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<ProvisionedDashboardFormData>({ 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<Job>();
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<string, string>) => {
// Branch success handler for /files API
const onBranchSuccess = (path: string, info: { repoType: string }, urls?: Record<string, string>) => {
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}
>
<FormProvider {...methods}>
<form onSubmit={handleSubmit(handleSubmitForm)}>
<Stack direction="column" gap={2}>
{readOnly && (
<RepoInvalidStateBanner
noRepository={false}
isReadOnlyRepo={true}
readOnlyMessage="To delete this dashboard, please remove the file from your repository."
{hasSubmitted && job ? (
<JobStatus watch={job} jobType="delete" onStatusChange={handleJobStatusChange} />
) : (
<FormProvider {...methods}>
<form onSubmit={handleSubmit(handleSubmitForm)}>
<Stack direction="column" gap={2}>
{readOnly && (
<RepoInvalidStateBanner
noRepository={false}
isReadOnlyRepo={true}
readOnlyMessage="To delete this dashboard, please remove the file from your repository."
/>
)}
<ResourceEditFormSharedFields
resourceType="dashboard"
isNew={isNew}
readOnly={readOnly}
workflow={workflow}
workflowOptions={workflowOptions}
repository={repository}
/>
)}
<ResourceEditFormSharedFields
resourceType="dashboard"
isNew={isNew}
readOnly={readOnly}
workflow={workflow}
workflowOptions={workflowOptions}
repository={repository}
/>
{/* Save / Cancel button */}
<Stack gap={2}>
<Button variant="secondary" onClick={onDismiss} fill="outline">
<Trans i18nKey="dashboard-scene.delete-provisioned-dashboard-form.cancel-action">Cancel</Trans>
</Button>
<Button variant="destructive" type="submit" disabled={request.isLoading || readOnly}>
{request.isLoading
? t('dashboard-scene.delete-provisioned-dashboard-form.deleting', 'Deleting...')
: t('dashboard-scene.delete-provisioned-dashboard-form.delete-action', 'Delete dashboard')}
</Button>
<Stack gap={2}>
<Button variant="secondary" onClick={onDismiss} fill="outline">
<Trans i18nKey="dashboard-scene.delete-provisioned-dashboard-form.cancel-action">Cancel</Trans>
</Button>
<Button variant="destructive" type="submit" disabled={isLoading || request.isLoading || readOnly}>
{isLoading || request.isLoading
? t('dashboard-scene.delete-provisioned-dashboard-form.deleting', 'Deleting...')
: t('dashboard-scene.delete-provisioned-dashboard-form.delete-action', 'Delete dashboard')}
</Button>
</Stack>
</Stack>
</Stack>
</form>
</FormProvider>
</form>
</FormProvider>
)}
</Drawer>
);
}
@@ -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);
});
@@ -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<string>('');
const [job, setJob] = useState<Job>();
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 (
<Drawer
@@ -174,76 +228,86 @@ export function MoveProvisionedDashboardForm({
subtitle={dashboard.state.title}
onClose={onDismiss}
>
<FormProvider {...methods}>
<form onSubmit={handleSubmit(handleSubmitForm)}>
<Stack direction="column" gap={2}>
{readOnly && (
<Alert
title={t(
'dashboard-scene.move-provisioned-dashboard-form.title-this-repository-is-read-only',
'This repository is read only'
)}
>
<Trans i18nKey="dashboard-scene.move-provisioned-dashboard-form.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.
</Trans>
</Alert>
)}
{hasSubmitted && job ? (
<JobStatus watch={job} jobType="move" onStatusChange={handleJobStatusChange} />
) : (
<FormProvider {...methods}>
<form onSubmit={handleSubmit(handleSubmitForm)}>
<Stack direction="column" gap={2}>
{readOnly && (
<Alert
title={t(
'dashboard-scene.move-provisioned-dashboard-form.title-this-repository-is-read-only',
'This repository is read only'
)}
>
<Trans i18nKey="dashboard-scene.move-provisioned-dashboard-form.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.
</Trans>
</Alert>
)}
{isLoadingFileData && (
<Stack alignItems="center" gap={2}>
<Spinner />
<div>
{t('dashboard-scene.move-provisioned-dashboard-form.loading-file-data', 'Loading dashboard data')}
</div>
{isLoadingFileData && (
<Stack alignItems="center" gap={2}>
<Spinner />
<div>
{t(
'dashboard-scene.move-provisioned-dashboard-form.loading-dashboard-data',
'Loading dashboard data'
)}
</div>
</Stack>
)}
{currentFileData?.errors?.length && currentFileData.errors.length > 0 && (
<Alert
title={t(
'dashboard-scene.move-provisioned-dashboard-form.file-load-error',
'Error loading dashboard'
)}
severity="error"
>
{currentFileData.errors.map((error, index) => (
<div key={index}>{error}</div>
))}
</Alert>
)}
<Field
noMargin
label={t('dashboard-scene.move-provisioned-dashboard-form.target-path-label', 'Target path')}
>
<Input readOnly value={targetPath} />
</Field>
<ResourceEditFormSharedFields
resourceType="dashboard"
isNew={isNew}
readOnly={readOnly}
workflow={workflow}
workflowOptions={workflowOptions}
repository={repository}
/>
<Stack gap={2}>
<Button variant="secondary" onClick={onDismiss} fill="outline">
<Trans i18nKey="dashboard-scene.move-provisioned-dashboard-form.cancel-action">Cancel</Trans>
</Button>
<Button
variant="primary"
type="submit"
disabled={isLoading || readOnly || isLoadingFileData || !currentFileData?.resource?.file}
>
{isLoading
? t('dashboard-scene.move-provisioned-dashboard-form.moving', 'Moving...')
: t('dashboard-scene.move-provisioned-dashboard-form.move-action', 'Move dashboard')}
</Button>
</Stack>
)}
{currentFileData?.errors?.length && currentFileData.errors.length > 0 && (
<Alert
title={t('dashboard-scene.move-provisioned-dashboard-form.file-load-error', 'Error loading dashboard')}
severity="error"
>
{currentFileData.errors.map((error, index) => (
<div key={index}>{error}</div>
))}
</Alert>
)}
<Field
noMargin
label={t('dashboard-scene.move-provisioned-dashboard-form.target-path-label', 'Target path')}
>
<Input readOnly value={targetPath} />
</Field>
<ResourceEditFormSharedFields
resourceType="dashboard"
isNew={isNew}
readOnly={readOnly}
workflow={workflow}
workflowOptions={workflowOptions}
repository={repository}
/>
<Stack gap={2}>
<Button variant="secondary" onClick={onDismiss} fill="outline">
<Trans i18nKey="dashboard-scene.move-provisioned-dashboard-form.cancel-action">Cancel</Trans>
</Button>
<Button
variant="primary"
type="submit"
disabled={isLoading || readOnly || !currentFileData || isLoadingFileData}
>
{isLoading
? t('dashboard-scene.move-provisioned-dashboard-form.moving', 'Moving...')
: t('dashboard-scene.move-provisioned-dashboard-form.move-action', 'Move dashboard')}
</Button>
</Stack>
</Stack>
</form>
</FormProvider>
</form>
</FormProvider>
)}
</Drawer>
);
}
@@ -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<typeof useProvisionedFolderFormData>;
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 = {
@@ -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<Job>();
const [hasSubmitted, setHasSubmitted] = useState(false);
const methods = useForm<BaseProvisionedFormData>({ 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<string, string> }, 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 (
<FormProvider {...methods}>
<form onSubmit={handleSubmit(handleSubmitForm)}>
<Stack direction="column" gap={2}>
<Box paddingBottom={2}>
<Trans i18nKey="browse-dashboards.delete-provisioned-folder-form.delete-warning">
This will delete this folder and all its descendants. In total, this will affect:
</Trans>
<DescendantCount
selectedItems={{
folder: { [resourceId]: true },
dashboard: {},
panel: {},
$all: false,
}}
/>
</Box>
<>
{hasSubmitted && job ? (
<JobStatus watch={job} jobType="delete" onStatusChange={handleJobStatusChange} />
) : (
<FormProvider {...methods}>
<form onSubmit={handleSubmit(handleSubmitForm)}>
<Stack direction="column" gap={2}>
<Box paddingBottom={2}>
<Trans i18nKey="browse-dashboards.delete-provisioned-folder-form.delete-warning">
This will delete this folder and all its descendants. In total, this will affect:
</Trans>
<DescendantCount
selectedItems={{
folder: { [resourceId]: true },
dashboard: {},
panel: {},
$all: false,
}}
/>
</Box>
<ResourceEditFormSharedFields
resourceType="folder"
isNew={false}
workflow={workflow}
workflowOptions={workflowOptions}
repository={repository}
/>
<ResourceEditFormSharedFields
resourceType="folder"
isNew={false}
workflow={workflow}
workflowOptions={workflowOptions}
repository={repository}
/>
{/* Delete / Cancel button */}
<Stack gap={2}>
<Button variant="secondary" fill="outline" onClick={onDismiss}>
<Trans i18nKey="browse-dashboards.delete-provisioned-folder-form.button-cancel">Cancel</Trans>
</Button>
<Button type="submit" disabled={request.isLoading} variant="destructive">
{request.isLoading
? t('browse-dashboards.delete-provisioned-folder-form.button-deleting', 'Deleting...')
: t('browse-dashboards.delete-provisioned-folder-form.button-delete', 'Delete')}
</Button>
</Stack>
</Stack>
</form>
</FormProvider>
<Stack gap={2}>
<Button variant="secondary" fill="outline" onClick={onDismiss}>
<Trans i18nKey="browse-dashboards.delete-provisioned-folder-form.button-cancel">Cancel</Trans>
</Button>
<Button type="submit" disabled={isLoading || request.isLoading} variant="destructive">
{isLoading || request.isLoading
? t('browse-dashboards.delete-provisioned-folder-form.button-deleting', 'Deleting...')
: t('browse-dashboards.delete-provisioned-folder-form.button-delete', 'Delete')}
</Button>
</Stack>
</Stack>
</form>
</FormProvider>
)}
</>
);
}
+2 -2
View File
@@ -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...",