diff --git a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx index 85b50db08c0..6bc4095ff27 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx @@ -15,6 +15,7 @@ import { useActionSelectionState } from '../../state/hooks'; import { setAllSelection } from '../../state/slice'; import { DashboardTreeSelection } from '../../types'; import { BulkDeleteProvisionedResource } from '../BulkActions/BulkDeleteProvisionedResource'; +import { BulkMoveProvisionedResource } from '../BulkActions/BulkMoveProvisionedResource'; import { DeleteModal } from './DeleteModal'; import { MoveModal } from './MoveModal'; @@ -27,6 +28,7 @@ export interface Props { export function BrowseActions({ folderDTO }: Props) { const [showBulkDeleteProvisionedResource, setShowBulkDeleteProvisionedResource] = useState(false); + const [showBulkMoveProvisionedResource, setShowBulkMoveProvisionedResource] = useState(false); const dispatch = useDispatch(); const selectedItems = useActionSelectionState(); @@ -70,6 +72,24 @@ export function BrowseActions({ folderDTO }: Props) { }; const showMoveModal = () => { + if (provisioningEnabled && hasProvisioned && hasNonProvisioned) { + // Mixed selection + appEvents.publish( + new ShowModalReactEvent({ + component: SelectedMixResourcesMsgModal, + props: {}, + }) + ); + return; + } + + if (provisioningEnabled && hasProvisioned) { + // Only provisioned items + setShowBulkMoveProvisionedResource(true); + return; + } + + // only non-provisioned items appEvents.publish( new ShowModalReactEvent({ component: MoveModal, @@ -128,6 +148,7 @@ export function BrowseActions({ folderDTO }: Props) { Delete + {/* bulk delete */} {showBulkDeleteProvisionedResource && ( )} + + {/* bulk move */} + {showBulkMoveProvisionedResource && ( + setShowBulkMoveProvisionedResource(false)} + size="md" + > + { + setShowBulkMoveProvisionedResource(false); + onActionComplete(); + }} + /> + + )} ); } diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkActionPostSubmitStep.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkActionPostSubmitStep.tsx new file mode 100644 index 00000000000..c3a03aac1c9 --- /dev/null +++ b/public/app/features/browse-dashboards/components/BulkActions/BulkActionPostSubmitStep.tsx @@ -0,0 +1,54 @@ +import { t, Trans } from '@grafana/i18n'; +import { Alert, Button, Stack } from '@grafana/ui'; + +import { BulkActionFailureBanner, MoveResultFailed } from './BulkActionFailureBanner'; +import { BulkActionProgress, ProgressState } from './BulkActionProgress'; +import { MoveResultSuccessState } from './utils'; + +interface Props { + action: 'move' | 'delete'; + progress: ProgressState | null; + successState: MoveResultSuccessState; + failureResults: MoveResultFailed[] | undefined; + handleSuccess: () => void; + setFailureResults: (results: MoveResultFailed[] | undefined) => void; +} + +export function BulkActionPostSubmitStep({ + action, + progress, + successState, + failureResults, + handleSuccess, + setFailureResults, +}: Props) { + if (progress) { + return ; + } + + if (successState.allSuccess) { + return ( + <> + + {action === 'move' + ? t('browse-dashboards.bulk-action-resources-form.all-moved', 'All resources have been moved successfully') + : t( + 'browse-dashboards.bulk-action-resources-form.all-deleted', + 'All resources have been deleted successfully' + )} + + + + + + ); + } + + if (failureResults) { + return setFailureResults(undefined)} />; + } + + return null; +} diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.test.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.test.tsx index e1dcbc41a94..47a6fabfb6a 100644 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.test.tsx +++ b/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'; import { BulkActionProgress, ProgressState } from './BulkActionProgress'; -const setup = (progressOverrides: Partial = {}) => { +const setup = (progressOverrides: Partial = {}, action: 'delete' | 'move' = 'delete') => { const defaultProgress: ProgressState = { current: 5, total: 10, @@ -15,7 +15,7 @@ const setup = (progressOverrides: Partial = {}) => { }; return { - ...render(), + ...render(), props, }; }; @@ -59,4 +59,11 @@ describe('BulkActionProgress', () => { expect(screen.getByText(/Deleting:/)).toBeInTheDocument(); expect(screen.getByText(/Complex Dashboard Name/)).toBeInTheDocument(); }); + + it('should render moving action text when action is move', () => { + setup({ current: 2, total: 4, item: 'Moving Dashboard' }, 'move'); + + expect(screen.getByText(/Moving:/)).toBeInTheDocument(); + expect(screen.getByText(/Moving Dashboard/)).toBeInTheDocument(); + }); }); diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.tsx index fd8ac5196a8..f022554d52a 100644 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.tsx +++ b/public/app/features/browse-dashboards/components/BulkActions/BulkActionProgress.tsx @@ -2,30 +2,38 @@ import { Trans } from '@grafana/i18n'; import { Box, Text, Stack, Spinner } from '@grafana/ui'; import ProgressBar from 'app/features/provisioning/Shared/ProgressBar'; -export interface ProgressState { +export type ProgressState = { current: number; total: number; item: string; +}; + +interface Props { + progress: ProgressState; + action: 'move' | 'delete'; } -export function BulkActionProgress({ progress }: { progress: ProgressState }) { +export function BulkActionProgress({ progress, action }: Props) { const progressPercentage = Math.round((progress.current / progress.total) * 100); return ( - + + Progress: {{ current: progress.current }} of {{ total: progress.total }} + - {progress.item} + {action === 'move' ? ( + Moving + ) : ( + Deleting + )} + : {progress.item} ); diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.tsx index d5fd35ff1bd..14ea787fc81 100644 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.tsx +++ b/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.tsx @@ -1,9 +1,8 @@ import { useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; -import { useNavigate } from 'react-router-dom-v5-compat'; import { Trans, t } from '@grafana/i18n'; -import { Alert, Box, Button, Stack } from '@grafana/ui'; +import { Box, Button, Stack } from '@grafana/ui'; import { DeleteRepositoryFilesWithPathApiArg, DeleteRepositoryFilesWithPathApiResponse, @@ -15,50 +14,32 @@ import { AnnoKeySourcePath } from 'app/features/apiserver/types'; import { ResourceEditFormSharedFields } from 'app/features/dashboard-scene/components/Provisioned/ResourceEditFormSharedFields'; import { getDefaultWorkflow, getWorkflowOptions } from 'app/features/dashboard-scene/saving/provisioned/defaults'; import { generateTimestamp } from 'app/features/dashboard-scene/saving/provisioned/utils/timestamp'; -import { buildResourceBranchRedirectUrl } from 'app/features/dashboard-scene/settings/utils'; import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView'; -import { WorkflowOption } from 'app/features/provisioning/types'; import { useSelector } from 'app/types/store'; import { useChildrenByParentUIDState, rootItemsSelector } from '../../state/hooks'; import { findItem } from '../../state/utils'; -import { DashboardTreeSelection } from '../../types'; import { DescendantCount } from '../BrowseActions/DescendantCount'; import { collectSelectedItems, fetchProvisionedDashboardPath } from '../utils'; -import { BulkActionFailureBanner, MoveResultFailed } from './BulkActionFailureBanner'; -import { BulkActionProgress, ProgressState } from './BulkActionProgress'; +import { MoveResultFailed } from './BulkActionFailureBanner'; +import { BulkActionPostSubmitStep } from './BulkActionPostSubmitStep'; +import { ProgressState } from './BulkActionProgress'; +import { useBulkActionRequest } from './useBulkActionRequest'; +import { + BulkActionFormData, + BulkActionProvisionResourceProps, + BulkSuccessResponse, + MoveResultSuccessState, +} from './utils'; -interface BulkDeleteFormData { - comment: string; - ref: string; - workflow?: WorkflowOption; -} - -interface FormProps extends BulkDeleteProvisionResourceProps { - initialValues: BulkDeleteFormData; +interface FormProps extends BulkActionProvisionResourceProps { + initialValues: BulkActionFormData; repository: RepositoryView; workflowOptions: Array<{ label: string; value: string }>; folderPath?: string; } -interface BulkDeleteProvisionResourceProps { - folderUid?: string; - selectedItems: Omit; - onDismiss?: () => void; -} - -type BulkSuccessResponse = Array<{ - index: number; - item: DeleteRepositoryFilesWithPathApiArg; - data: DeleteRepositoryFilesWithPathApiResponse; -}>; - -type MoveResultSuccessState = { - allSuccess: boolean; - repoUrl?: string; -}; - function FormContent({ initialValues, selectedItems, repository, workflowOptions, folderPath, onDismiss }: FormProps) { // States const [progress, setProgress] = useState(null); @@ -71,12 +52,12 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions // Hooks const [deleteRepoFile, request] = useDeleteRepositoryFilesWithPathMutation(); - const methods = useForm({ defaultValues: initialValues }); + const methods = useForm({ defaultValues: initialValues }); const childrenByParentUID = useChildrenByParentUIDState(); const rootItems = useSelector(rootItemsSelector); const { handleSubmit, watch } = methods; const workflow = watch('workflow'); - const navigate = useNavigate(); + const { handleSuccess } = useBulkActionRequest({ workflow, repository, successState, onDismiss }); const getResourcePath = async (uid: string, isFolder: boolean): Promise => { const item = findItem(rootItems?.items || [], childrenByParentUID, uid); @@ -86,27 +67,7 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions return isFolder ? `${folderPath}/${item.title}/` : fetchProvisionedDashboardPath(uid); }; - const handleSuccess = () => { - if (workflow === 'branch') { - onDismiss?.(); - if (successState.repoUrl) { - const url = buildResourceBranchRedirectUrl({ - paramName: 'repo_url', - paramValue: successState.repoUrl, - repoType: repository.type, - }); - - navigate(url); - return; - } - window.location.reload(); - } else { - onDismiss?.(); - window.location.reload(); - } - }; - - const handleSubmitForm = async (data: BulkDeleteFormData) => { + const handleSubmitForm = async (data: BulkActionFormData) => { setFailureResults(undefined); setHasSubmitted(true); @@ -120,7 +81,10 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions }); } - const successes: BulkSuccessResponse = []; + const successes: BulkSuccessResponse< + DeleteRepositoryFilesWithPathApiArg, + DeleteRepositoryFilesWithPathApiResponse + > = []; const failures: MoveResultFailed[] = []; // Iterate through each selected item and delete it @@ -177,40 +141,13 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions // handleSuccess(successes); setSuccessState({ allSuccess: true, - repoUrl: successes[0].data.urls?.repositoryURL, + repoUrl: successes[0].data.urls?.newPullRequestURL, }); } else if (failures.length > 0) { setFailureResults(failures); } }; - const getPostSubmitContent = () => { - if (progress) { - return ; - } - - if (successState.allSuccess) { - return ( - <> - - - All resources have been deleted successfully. - - - - - - - ); - } else if (failureResults) { - return setFailureResults(undefined)} />; - } - - return null; - }; - return (
@@ -223,7 +160,14 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions {hasSubmitted ? ( - getPostSubmitContent() + ) : ( <> ; + folderPath?: string; +} + +function FormContent({ initialValues, selectedItems, repository, workflowOptions, folderPath, onDismiss }: FormProps) { + // States + const [targetFolderUID, setTargetFolderUID] = useState(undefined); + const [progress, setProgress] = useState(null); + const [failureResults, setFailureResults] = useState(); + const [successState, setSuccessState] = useState({ + allSuccess: false, + repoUrl: '', + }); + const [hasSubmitted, setHasSubmitted] = useState(false); + + // Hooks + const [moveFile, moveRequest] = useCreateRepositoryFilesWithPathMutation(); + const methods = useForm({ defaultValues: initialValues }); + const childrenByParentUID = useChildrenByParentUIDState(); + const rootItems = useSelector(rootItemsSelector); + const { handleSubmit, watch } = methods; + const workflow = watch('workflow'); + const { handleSuccess } = useBulkActionRequest({ workflow, repository, successState, onDismiss }); + + // Get target folder data + const { data: targetFolder } = useGetFolderQuery(targetFolderUID ? { name: targetFolderUID } : skipToken); + + const getResourceCurrentPath = async (uid: string, isFolder: boolean): Promise => { + const item = findItem(rootItems?.items || [], childrenByParentUID, uid); + if (!item) { + return undefined; + } + return isFolder ? `${folderPath}/${item.title}/` : fetchProvisionedDashboardPath(uid); + }; + + const getDashboardBody = async (currentPath: string) => { + const repositoryClient = new ScopedResourceClient({ + group: 'provisioning.grafana.app', + version: 'v0alpha1', + resource: 'repositories', + }); + const fileResponse = await repositoryClient.subresource(repository.name, `files/${currentPath}`); + return fileResponse.resource?.file; + }; + + const setupMoveOperation = () => { + const targetFolderPathInRepo = getTargetFolderPathInRepo({ targetFolder }); + const targets = collectSelectedItems(selectedItems, childrenByParentUID, rootItems?.items || []); + + if (targets.length > 0) { + setProgress({ + current: 0, + total: targets.length, + item: targets[0].displayName || 'Unknown', + }); + } + + return { targetFolderPathInRepo, targets }; + }; + + const createFileBody = async (isFolder: boolean, displayName: string, currentPath: string) => { + if (isFolder) { + return { + title: displayName, + type: 'folder', + }; + } + + const fileBody = await getDashboardBody(currentPath); + if (!fileBody) { + throw new Error( + t('browse-dashboards.bulk-move-resources-form.error-file-content-not-found', 'File content not found') + ); + } + + return fileBody; + }; + + const handleSubmitForm = async (data: BulkActionFormData) => { + setFailureResults(undefined); + setHasSubmitted(true); + + // 1. Validate + if (!targetFolder) { + setFailureResults([ + { + status: 'failed', + title: t('browse-dashboards.bulk-move-resources-form.error-title', 'Target Folder Error'), + }, + ]); + return; + } + + // 2. Setup + const { targetFolderPathInRepo, targets } = setupMoveOperation(); + + // 3. Process items + const successes: BulkSuccessResponse< + CreateRepositoryFilesWithPathApiArg, + CreateRepositoryFilesWithPathApiResponse + > = []; + const failures: MoveResultFailed[] = []; + + // Iterate through each selected item and move it + // We want sequential processing to avoid overwhelming the API + for (let i = 0; i < targets.length; i++) { + const { uid, isFolder, displayName } = targets[i]; + setProgress({ + current: i, + total: targets.length, + item: displayName, + }); + + try { + // 1. Get source path in repository + const currentPath = await getResourceCurrentPath(uid, isFolder); + if (!currentPath) { + failures.push({ + status: 'failed', + title: `${isFolder ? 'Folder' : 'Dashboard'}: ${displayName}`, + errorMessage: t('browse-dashboards.bulk-move-resources-form.error-path-not-found', 'Path not found'), + }); + continue; + } + + if (!targetFolderPathInRepo) { + failures.push({ + status: 'failed', + title: `${isFolder ? 'Folder' : 'Dashboard'}: ${displayName}`, + errorMessage: t( + 'browse-dashboards.bulk-move-resources-form.error-target-folder-path-missing', + 'Target folder path is missing' + ), + }); + continue; + } + + const newPath = getResourceTargetPath(currentPath, targetFolderPathInRepo); + const fileBody = await createFileBody(isFolder, displayName, currentPath); + + // Build move parameters + const moveParams: CreateRepositoryFilesWithPathApiArg = { + name: repository.name, + path: newPath, // NEW target path + ref: workflow === 'write' ? undefined : data.ref, + message: data.comment || `Move resource ${displayName}`, + originalPath: currentPath, // CURRENT path (source) + body: fileBody, // File content + }; + + // Call endpoint to move resource + const response = await moveFile(moveParams).unwrap(); + successes.push({ index: i, item: moveParams, data: response }); + } catch (error: unknown) { + failures.push({ + status: 'failed', + title: `${isFolder ? 'Folder' : 'Dashboard'}: ${displayName}`, + errorMessage: extractErrorMessage(error), + }); + } + + setProgress({ + current: i + 1, + total: targets.length, + item: targets[i + 1]?.displayName, + }); + } + + setProgress(null); + + if (successes.length > 0 && failures.length === 0) { + // handleSuccess(successes); + setSuccessState({ + allSuccess: true, + repoUrl: successes[0].data.urls?.newPullRequestURL, + }); + } else if (failures.length > 0) { + setFailureResults(failures); + } + }; + + return ( + + + + + + This will move selected folders and their descendants. In total, this will affect: + + + + + {hasSubmitted ? ( + + ) : ( + <> + {/* Target folder selection */} + + + + + + + + + + + )} + + + + ); +} + +export function BulkMoveProvisionedResource({ folderUid, selectedItems, onDismiss }: BulkActionProvisionResourceProps) { + const { repository, folder } = useGetResourceRepositoryView({ folderName: folderUid }); + + const workflowOptions = getWorkflowOptions(repository); + const folderPath = folder?.metadata?.annotations?.[AnnoKeySourcePath] || ''; + const timestamp = generateTimestamp(); + + const initialValues = { + comment: '', + ref: `bulk-move/${timestamp}`, + workflow: getDefaultWorkflow(repository), + }; + + if (!repository) { + return null; + } + + return ( + + ); +} diff --git a/public/app/features/browse-dashboards/components/BulkActions/useBulkActionRequest.ts b/public/app/features/browse-dashboards/components/BulkActions/useBulkActionRequest.ts new file mode 100644 index 00000000000..0e0ddfcaf2c --- /dev/null +++ b/public/app/features/browse-dashboards/components/BulkActions/useBulkActionRequest.ts @@ -0,0 +1,39 @@ +import { useNavigate } from 'react-router-dom-v5-compat'; + +import { RepositoryView } from 'app/api/clients/provisioning/v0alpha1'; +import { buildResourceBranchRedirectUrl } from 'app/features/dashboard-scene/settings/utils'; + +import { MoveResultSuccessState } from './utils'; + +interface Props { + workflow?: 'branch' | 'write'; + repository: RepositoryView; + successState: MoveResultSuccessState; + onDismiss?: () => void; +} +export function useBulkActionRequest({ workflow, repository, successState, onDismiss }: Props) { + const navigate = useNavigate(); + + const handleSuccess = () => { + if (workflow === 'branch') { + onDismiss?.(); + if (successState.repoUrl) { + const url = buildResourceBranchRedirectUrl({ + paramName: 'repo_url', + paramValue: successState.repoUrl, + repoType: repository.type, + }); + + navigate(url); + return; + } + window.location.reload(); + } else { + onDismiss?.(); + window.location.reload(); + } + }; + return { + handleSuccess, + }; +} diff --git a/public/app/features/browse-dashboards/components/BulkActions/utils.ts b/public/app/features/browse-dashboards/components/BulkActions/utils.ts new file mode 100644 index 00000000000..48fc4892c73 --- /dev/null +++ b/public/app/features/browse-dashboards/components/BulkActions/utils.ts @@ -0,0 +1,50 @@ +import { Folder } from 'app/api/clients/folder/v1beta1'; +import { AnnoKeySourcePath } from 'app/features/apiserver/types'; +import { WorkflowOption } from 'app/features/provisioning/types'; + +import { DashboardTreeSelection } from '../../types'; + +export type BulkActionFormData = { + comment: string; + ref: string; + workflow?: WorkflowOption; +}; + +export interface BulkActionProvisionResourceProps { + folderUid?: string; + selectedItems: Omit; + onDismiss?: () => void; +} + +export type BulkSuccessResponse = Array<{ + index: number; + item: T; + data: K; +}>; + +export type MoveResultSuccessState = { + allSuccess: boolean; + repoUrl?: string; +}; + +export function getTargetFolderPathInRepo({ targetFolder }: { targetFolder?: Folder }): string | undefined { + if (!targetFolder) { + return undefined; + } + const folderAnnotations = targetFolder.metadata.annotations || {}; + return folderAnnotations[AnnoKeySourcePath] || targetFolder.metadata.name || ''; +} + +export function getResourceTargetPath(currentPath: string, targetFolderPath: string): string { + // Handle folder paths that end with '/' + const cleanCurrentPath = currentPath.replace(/\/$/, ''); // Remove trailing slash + const filename = cleanCurrentPath.split('/').pop(); + + if (!filename) { + throw new Error(`Invalid path: ${currentPath}`); + } + + // For folders, add back the trailing slash + const isFolder = currentPath.endsWith('/'); + return isFolder ? `${targetFolderPath}/${filename}/` : `${targetFolderPath}/${filename}`; +} diff --git a/public/app/features/dashboard-scene/settings/MoveProvisionedDashboardForm.tsx b/public/app/features/dashboard-scene/settings/MoveProvisionedDashboardForm.tsx index 5a2a07cc478..8d431f3f6a1 100644 --- a/public/app/features/dashboard-scene/settings/MoveProvisionedDashboardForm.tsx +++ b/public/app/features/dashboard-scene/settings/MoveProvisionedDashboardForm.tsx @@ -13,6 +13,7 @@ import { useGetRepositoryFilesWithPathQuery, } from 'app/api/clients/provisioning/v0alpha1'; import { AnnoKeySourcePath } from 'app/features/apiserver/types'; +import { getTargetFolderPathInRepo } from 'app/features/browse-dashboards/components/BulkActions/utils'; import { ResourceEditFormSharedFields } from '../components/Provisioned/ResourceEditFormSharedFields'; import { ProvisionedDashboardFormData } from '../saving/shared'; @@ -74,8 +75,7 @@ export function MoveProvisionedDashboardForm({ return; } - const folderAnnotations = targetFolder.metadata.annotations || {}; - const targetFolderPath = folderAnnotations[AnnoKeySourcePath] || targetFolderTitle; + const targetFolderPath = getTargetFolderPathInRepo({ targetFolder }); const filename = currentSourcePath.split('/').pop(); const newPath = `${targetFolderPath}/${filename}`; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index c3763c9a59a..a36e09b69c4 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -3482,6 +3482,7 @@ "browse-dashboards": { "action": { "bulk-delete-provisioned-resources": "Bulk Delete Provisioned Resources", + "bulk-move-provisioned-resources": "Bulk Move Provisioned Resources", "cancel-button": "Cancel", "cannot-move-folders": "Folders cannot be moved", "confirmation-text": "Delete", @@ -3515,22 +3516,33 @@ "this-folder-is-empty": "This folder is empty" }, "bulk-action-resources-form": { + "all-deleted": "All resources have been deleted successfully", + "all-moved": "All resources have been moved successfully", + "button-done": "Done", "failed-alert_one": "{{count}} items failed", - "failed-alert_other": "{{count}} items failed" + "failed-alert_other": "{{count}} items failed", + "progress-title": "Success" }, "bulk-delete-resources-form": { "button-cancel": "Cancel", "button-delete": "Delete", "button-deleting": "Deleting...", - "button-done": "Done", "delete-warning": "This will delete selected folders and their descendants. In total, this will affect:", - "error-path-not-found": "Path not found", - "progress-title": "Success", - "success-message": "All resources have been deleted successfully." + "error-path-not-found": "Path not found" }, "bulk-move-resources-form": { - "deleting": "Deleting:", - "progress": "Progress: {{current}} of {{total}}" + "button-cancel": "Cancel", + "button-move": "Move", + "button-moving": "Moving...", + "deleting": "Deleting", + "error-file-content-not-found": "File content not found", + "error-path-not-found": "Path not found", + "error-target-folder-path-missing": "Target folder path is missing", + "error-title": "Target Folder Error", + "move-warning": "This will move selected folders and their descendants. In total, this will affect:", + "moving": "Moving", + "progress": "Progress: {{current}} of {{total}}", + "target-folder": "Target Folder" }, "counts": { "alertRule_one": "{{count}} alert rule",