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:
@@ -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',
|
||||
},
|
||||
|
||||
+15
-11
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+117
-64
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+3
@@ -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);
|
||||
});
|
||||
|
||||
+176
-112
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+39
-44
@@ -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 = {
|
||||
|
||||
+116
-66
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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...",
|
||||
|
||||
Reference in New Issue
Block a user