Git Sync: Bulk move resource (#108818)
* Bulk move for provisioning resources
This commit is contained in:
@@ -15,6 +15,7 @@ import { useActionSelectionState } from '../../state/hooks';
|
|||||||
import { setAllSelection } from '../../state/slice';
|
import { setAllSelection } from '../../state/slice';
|
||||||
import { DashboardTreeSelection } from '../../types';
|
import { DashboardTreeSelection } from '../../types';
|
||||||
import { BulkDeleteProvisionedResource } from '../BulkActions/BulkDeleteProvisionedResource';
|
import { BulkDeleteProvisionedResource } from '../BulkActions/BulkDeleteProvisionedResource';
|
||||||
|
import { BulkMoveProvisionedResource } from '../BulkActions/BulkMoveProvisionedResource';
|
||||||
|
|
||||||
import { DeleteModal } from './DeleteModal';
|
import { DeleteModal } from './DeleteModal';
|
||||||
import { MoveModal } from './MoveModal';
|
import { MoveModal } from './MoveModal';
|
||||||
@@ -27,6 +28,7 @@ export interface Props {
|
|||||||
|
|
||||||
export function BrowseActions({ folderDTO }: Props) {
|
export function BrowseActions({ folderDTO }: Props) {
|
||||||
const [showBulkDeleteProvisionedResource, setShowBulkDeleteProvisionedResource] = useState(false);
|
const [showBulkDeleteProvisionedResource, setShowBulkDeleteProvisionedResource] = useState(false);
|
||||||
|
const [showBulkMoveProvisionedResource, setShowBulkMoveProvisionedResource] = useState(false);
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const selectedItems = useActionSelectionState();
|
const selectedItems = useActionSelectionState();
|
||||||
@@ -70,6 +72,24 @@ export function BrowseActions({ folderDTO }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const showMoveModal = () => {
|
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(
|
appEvents.publish(
|
||||||
new ShowModalReactEvent({
|
new ShowModalReactEvent({
|
||||||
component: MoveModal,
|
component: MoveModal,
|
||||||
@@ -128,6 +148,7 @@ export function BrowseActions({ folderDTO }: Props) {
|
|||||||
<Trans i18nKey="browse-dashboards.action.delete-button">Delete</Trans>
|
<Trans i18nKey="browse-dashboards.action.delete-button">Delete</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
{/* bulk delete */}
|
||||||
{showBulkDeleteProvisionedResource && (
|
{showBulkDeleteProvisionedResource && (
|
||||||
<Drawer
|
<Drawer
|
||||||
title={t('browse-dashboards.action.bulk-delete-provisioned-resources', 'Bulk Delete Provisioned Resources')}
|
title={t('browse-dashboards.action.bulk-delete-provisioned-resources', 'Bulk Delete Provisioned Resources')}
|
||||||
@@ -144,6 +165,24 @@ export function BrowseActions({ folderDTO }: Props) {
|
|||||||
/>
|
/>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* bulk move */}
|
||||||
|
{showBulkMoveProvisionedResource && (
|
||||||
|
<Drawer
|
||||||
|
title={t('browse-dashboards.action.bulk-move-provisioned-resources', 'Bulk Move Provisioned Resources')}
|
||||||
|
onClose={() => setShowBulkMoveProvisionedResource(false)}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<BulkMoveProvisionedResource
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
folderUid={folderDTO?.uid}
|
||||||
|
onDismiss={() => {
|
||||||
|
setShowBulkMoveProvisionedResource(false);
|
||||||
|
onActionComplete();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Drawer>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+54
@@ -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 <BulkActionProgress progress={progress} action={action} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successState.allSuccess) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Alert severity="success" title={t('browse-dashboards.bulk-action-resources-form.progress-title', 'Success')}>
|
||||||
|
{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'
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Button onClick={() => handleSuccess()}>
|
||||||
|
<Trans i18nKey="browse-dashboards.bulk-action-resources-form.button-done">Done</Trans>
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failureResults) {
|
||||||
|
return <BulkActionFailureBanner result={failureResults} onDismiss={() => setFailureResults(undefined)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
+9
-2
@@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react';
|
|||||||
|
|
||||||
import { BulkActionProgress, ProgressState } from './BulkActionProgress';
|
import { BulkActionProgress, ProgressState } from './BulkActionProgress';
|
||||||
|
|
||||||
const setup = (progressOverrides: Partial<ProgressState> = {}) => {
|
const setup = (progressOverrides: Partial<ProgressState> = {}, action: 'delete' | 'move' = 'delete') => {
|
||||||
const defaultProgress: ProgressState = {
|
const defaultProgress: ProgressState = {
|
||||||
current: 5,
|
current: 5,
|
||||||
total: 10,
|
total: 10,
|
||||||
@@ -15,7 +15,7 @@ const setup = (progressOverrides: Partial<ProgressState> = {}) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...render(<BulkActionProgress {...props} />),
|
...render(<BulkActionProgress {...props} action={action} />),
|
||||||
props,
|
props,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -59,4 +59,11 @@ describe('BulkActionProgress', () => {
|
|||||||
expect(screen.getByText(/Deleting:/)).toBeInTheDocument();
|
expect(screen.getByText(/Deleting:/)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Complex Dashboard Name/)).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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+16
-8
@@ -2,30 +2,38 @@ import { Trans } from '@grafana/i18n';
|
|||||||
import { Box, Text, Stack, Spinner } from '@grafana/ui';
|
import { Box, Text, Stack, Spinner } from '@grafana/ui';
|
||||||
import ProgressBar from 'app/features/provisioning/Shared/ProgressBar';
|
import ProgressBar from 'app/features/provisioning/Shared/ProgressBar';
|
||||||
|
|
||||||
export interface ProgressState {
|
export type ProgressState = {
|
||||||
current: number;
|
current: number;
|
||||||
total: number;
|
total: number;
|
||||||
item: string;
|
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);
|
const progressPercentage = Math.round((progress.current / progress.total) * 100);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Stack direction="row" alignItems="center">
|
<Stack direction="row" alignItems="center">
|
||||||
<Text>
|
<Text>
|
||||||
<Trans
|
<Trans i18nKey="browse-dashboards.bulk-move-resources-form.progress">
|
||||||
i18nKey="browse-dashboards.bulk-move-resources-form.progress"
|
Progress: {{ current: progress.current }} of {{ total: progress.total }}
|
||||||
defaults="Progress: {{current}} of {{total}}"
|
</Trans>
|
||||||
values={{ current: progress.current, total: progress.total }}
|
|
||||||
/>
|
|
||||||
</Text>
|
</Text>
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
</Stack>
|
</Stack>
|
||||||
<ProgressBar progress={progressPercentage} topBottomSpacing={1} />
|
<ProgressBar progress={progressPercentage} topBottomSpacing={1} />
|
||||||
<Text variant="bodySmall" color="secondary">
|
<Text variant="bodySmall" color="secondary">
|
||||||
<Trans i18nKey="browse-dashboards.bulk-move-resources-form.deleting" defaults="Deleting:" /> {progress.item}
|
{action === 'move' ? (
|
||||||
|
<Trans i18nKey="browse-dashboards.bulk-move-resources-form.moving">Moving</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans i18nKey="browse-dashboards.bulk-move-resources-form.deleting">Deleting</Trans>
|
||||||
|
)}
|
||||||
|
: {progress.item}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
+30
-86
@@ -1,9 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FormProvider, useForm } from 'react-hook-form';
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router-dom-v5-compat';
|
|
||||||
|
|
||||||
import { Trans, t } from '@grafana/i18n';
|
import { Trans, t } from '@grafana/i18n';
|
||||||
import { Alert, Box, Button, Stack } from '@grafana/ui';
|
import { Box, Button, Stack } from '@grafana/ui';
|
||||||
import {
|
import {
|
||||||
DeleteRepositoryFilesWithPathApiArg,
|
DeleteRepositoryFilesWithPathApiArg,
|
||||||
DeleteRepositoryFilesWithPathApiResponse,
|
DeleteRepositoryFilesWithPathApiResponse,
|
||||||
@@ -15,50 +14,32 @@ import { AnnoKeySourcePath } from 'app/features/apiserver/types';
|
|||||||
import { ResourceEditFormSharedFields } from 'app/features/dashboard-scene/components/Provisioned/ResourceEditFormSharedFields';
|
import { ResourceEditFormSharedFields } from 'app/features/dashboard-scene/components/Provisioned/ResourceEditFormSharedFields';
|
||||||
import { getDefaultWorkflow, getWorkflowOptions } from 'app/features/dashboard-scene/saving/provisioned/defaults';
|
import { getDefaultWorkflow, getWorkflowOptions } from 'app/features/dashboard-scene/saving/provisioned/defaults';
|
||||||
import { generateTimestamp } from 'app/features/dashboard-scene/saving/provisioned/utils/timestamp';
|
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 { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView';
|
||||||
import { WorkflowOption } from 'app/features/provisioning/types';
|
|
||||||
import { useSelector } from 'app/types/store';
|
import { useSelector } from 'app/types/store';
|
||||||
|
|
||||||
import { useChildrenByParentUIDState, rootItemsSelector } from '../../state/hooks';
|
import { useChildrenByParentUIDState, rootItemsSelector } from '../../state/hooks';
|
||||||
import { findItem } from '../../state/utils';
|
import { findItem } from '../../state/utils';
|
||||||
import { DashboardTreeSelection } from '../../types';
|
|
||||||
import { DescendantCount } from '../BrowseActions/DescendantCount';
|
import { DescendantCount } from '../BrowseActions/DescendantCount';
|
||||||
import { collectSelectedItems, fetchProvisionedDashboardPath } from '../utils';
|
import { collectSelectedItems, fetchProvisionedDashboardPath } from '../utils';
|
||||||
|
|
||||||
import { BulkActionFailureBanner, MoveResultFailed } from './BulkActionFailureBanner';
|
import { MoveResultFailed } from './BulkActionFailureBanner';
|
||||||
import { BulkActionProgress, ProgressState } from './BulkActionProgress';
|
import { BulkActionPostSubmitStep } from './BulkActionPostSubmitStep';
|
||||||
|
import { ProgressState } from './BulkActionProgress';
|
||||||
|
import { useBulkActionRequest } from './useBulkActionRequest';
|
||||||
|
import {
|
||||||
|
BulkActionFormData,
|
||||||
|
BulkActionProvisionResourceProps,
|
||||||
|
BulkSuccessResponse,
|
||||||
|
MoveResultSuccessState,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
interface BulkDeleteFormData {
|
interface FormProps extends BulkActionProvisionResourceProps {
|
||||||
comment: string;
|
initialValues: BulkActionFormData;
|
||||||
ref: string;
|
|
||||||
workflow?: WorkflowOption;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FormProps extends BulkDeleteProvisionResourceProps {
|
|
||||||
initialValues: BulkDeleteFormData;
|
|
||||||
repository: RepositoryView;
|
repository: RepositoryView;
|
||||||
workflowOptions: Array<{ label: string; value: string }>;
|
workflowOptions: Array<{ label: string; value: string }>;
|
||||||
folderPath?: string;
|
folderPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BulkDeleteProvisionResourceProps {
|
|
||||||
folderUid?: string;
|
|
||||||
selectedItems: Omit<DashboardTreeSelection, 'panel' | '$all'>;
|
|
||||||
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) {
|
function FormContent({ initialValues, selectedItems, repository, workflowOptions, folderPath, onDismiss }: FormProps) {
|
||||||
// States
|
// States
|
||||||
const [progress, setProgress] = useState<ProgressState | null>(null);
|
const [progress, setProgress] = useState<ProgressState | null>(null);
|
||||||
@@ -71,12 +52,12 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions
|
|||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
const [deleteRepoFile, request] = useDeleteRepositoryFilesWithPathMutation();
|
const [deleteRepoFile, request] = useDeleteRepositoryFilesWithPathMutation();
|
||||||
const methods = useForm<BulkDeleteFormData>({ defaultValues: initialValues });
|
const methods = useForm<BulkActionFormData>({ defaultValues: initialValues });
|
||||||
const childrenByParentUID = useChildrenByParentUIDState();
|
const childrenByParentUID = useChildrenByParentUIDState();
|
||||||
const rootItems = useSelector(rootItemsSelector);
|
const rootItems = useSelector(rootItemsSelector);
|
||||||
const { handleSubmit, watch } = methods;
|
const { handleSubmit, watch } = methods;
|
||||||
const workflow = watch('workflow');
|
const workflow = watch('workflow');
|
||||||
const navigate = useNavigate();
|
const { handleSuccess } = useBulkActionRequest({ workflow, repository, successState, onDismiss });
|
||||||
|
|
||||||
const getResourcePath = async (uid: string, isFolder: boolean): Promise<string | undefined> => {
|
const getResourcePath = async (uid: string, isFolder: boolean): Promise<string | undefined> => {
|
||||||
const item = findItem(rootItems?.items || [], childrenByParentUID, uid);
|
const item = findItem(rootItems?.items || [], childrenByParentUID, uid);
|
||||||
@@ -86,27 +67,7 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions
|
|||||||
return isFolder ? `${folderPath}/${item.title}/` : fetchProvisionedDashboardPath(uid);
|
return isFolder ? `${folderPath}/${item.title}/` : fetchProvisionedDashboardPath(uid);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSuccess = () => {
|
const handleSubmitForm = async (data: BulkActionFormData) => {
|
||||||
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) => {
|
|
||||||
setFailureResults(undefined);
|
setFailureResults(undefined);
|
||||||
setHasSubmitted(true);
|
setHasSubmitted(true);
|
||||||
|
|
||||||
@@ -120,7 +81,10 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const successes: BulkSuccessResponse = [];
|
const successes: BulkSuccessResponse<
|
||||||
|
DeleteRepositoryFilesWithPathApiArg,
|
||||||
|
DeleteRepositoryFilesWithPathApiResponse
|
||||||
|
> = [];
|
||||||
const failures: MoveResultFailed[] = [];
|
const failures: MoveResultFailed[] = [];
|
||||||
|
|
||||||
// Iterate through each selected item and delete it
|
// Iterate through each selected item and delete it
|
||||||
@@ -177,40 +141,13 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions
|
|||||||
// handleSuccess(successes);
|
// handleSuccess(successes);
|
||||||
setSuccessState({
|
setSuccessState({
|
||||||
allSuccess: true,
|
allSuccess: true,
|
||||||
repoUrl: successes[0].data.urls?.repositoryURL,
|
repoUrl: successes[0].data.urls?.newPullRequestURL,
|
||||||
});
|
});
|
||||||
} else if (failures.length > 0) {
|
} else if (failures.length > 0) {
|
||||||
setFailureResults(failures);
|
setFailureResults(failures);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPostSubmitContent = () => {
|
|
||||||
if (progress) {
|
|
||||||
return <BulkActionProgress progress={progress} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (successState.allSuccess) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Alert severity="success" title={t('browse-dashboards.bulk-delete-resources-form.progress-title', 'Success')}>
|
|
||||||
<Trans i18nKey="browse-dashboards.bulk-delete-resources-form.success-message">
|
|
||||||
All resources have been deleted successfully.
|
|
||||||
</Trans>
|
|
||||||
</Alert>
|
|
||||||
<Stack gap={2}>
|
|
||||||
<Button onClick={() => handleSuccess()}>
|
|
||||||
<Trans i18nKey="browse-dashboards.bulk-delete-resources-form.button-done">Done</Trans>
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
} else if (failureResults) {
|
|
||||||
return <BulkActionFailureBanner result={failureResults} onDismiss={() => setFailureResults(undefined)} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<form onSubmit={handleSubmit(handleSubmitForm)}>
|
<form onSubmit={handleSubmit(handleSubmitForm)}>
|
||||||
@@ -223,7 +160,14 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{hasSubmitted ? (
|
{hasSubmitted ? (
|
||||||
getPostSubmitContent()
|
<BulkActionPostSubmitStep
|
||||||
|
action="delete"
|
||||||
|
progress={progress}
|
||||||
|
successState={successState}
|
||||||
|
failureResults={failureResults}
|
||||||
|
handleSuccess={handleSuccess}
|
||||||
|
setFailureResults={setFailureResults}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ResourceEditFormSharedFields
|
<ResourceEditFormSharedFields
|
||||||
@@ -257,7 +201,7 @@ export function BulkDeleteProvisionedResource({
|
|||||||
folderUid,
|
folderUid,
|
||||||
selectedItems,
|
selectedItems,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
}: BulkDeleteProvisionResourceProps) {
|
}: BulkActionProvisionResourceProps) {
|
||||||
const { repository, folder } = useGetResourceRepositoryView({ folderName: folderUid });
|
const { repository, folder } = useGetResourceRepositoryView({ folderName: folderUid });
|
||||||
|
|
||||||
const workflowOptions = getWorkflowOptions(repository);
|
const workflowOptions = getWorkflowOptions(repository);
|
||||||
|
|||||||
+306
@@ -0,0 +1,306 @@
|
|||||||
|
import { skipToken } from '@reduxjs/toolkit/query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { Trans, t } from '@grafana/i18n';
|
||||||
|
import { FolderPicker } from '@grafana/runtime';
|
||||||
|
import { Box, Button, Field, Stack } from '@grafana/ui';
|
||||||
|
import { useGetFolderQuery } from 'app/api/clients/folder/v1beta1';
|
||||||
|
import {
|
||||||
|
CreateRepositoryFilesWithPathApiArg,
|
||||||
|
CreateRepositoryFilesWithPathApiResponse,
|
||||||
|
RepositoryView,
|
||||||
|
useCreateRepositoryFilesWithPathMutation,
|
||||||
|
ResourceWrapper,
|
||||||
|
} from 'app/api/clients/provisioning/v0alpha1';
|
||||||
|
import { extractErrorMessage } from 'app/api/utils';
|
||||||
|
import { ScopedResourceClient } from 'app/features/apiserver/client';
|
||||||
|
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 { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView';
|
||||||
|
import { useSelector } from 'app/types/store';
|
||||||
|
|
||||||
|
import { useChildrenByParentUIDState, rootItemsSelector } from '../../state/hooks';
|
||||||
|
import { findItem } from '../../state/utils';
|
||||||
|
import { DescendantCount } from '../BrowseActions/DescendantCount';
|
||||||
|
import { collectSelectedItems, fetchProvisionedDashboardPath } from '../utils';
|
||||||
|
|
||||||
|
import { MoveResultFailed } from './BulkActionFailureBanner';
|
||||||
|
import { BulkActionPostSubmitStep } from './BulkActionPostSubmitStep';
|
||||||
|
import { ProgressState } from './BulkActionProgress';
|
||||||
|
import { useBulkActionRequest } from './useBulkActionRequest';
|
||||||
|
import {
|
||||||
|
BulkActionFormData,
|
||||||
|
BulkActionProvisionResourceProps,
|
||||||
|
BulkSuccessResponse,
|
||||||
|
getTargetFolderPathInRepo,
|
||||||
|
getResourceTargetPath,
|
||||||
|
MoveResultSuccessState,
|
||||||
|
} from './utils';
|
||||||
|
interface FormProps extends BulkActionProvisionResourceProps {
|
||||||
|
initialValues: BulkActionFormData;
|
||||||
|
repository: RepositoryView;
|
||||||
|
workflowOptions: Array<{ label: string; value: string }>;
|
||||||
|
folderPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormContent({ initialValues, selectedItems, repository, workflowOptions, folderPath, onDismiss }: FormProps) {
|
||||||
|
// States
|
||||||
|
const [targetFolderUID, setTargetFolderUID] = useState<string | undefined>(undefined);
|
||||||
|
const [progress, setProgress] = useState<ProgressState | null>(null);
|
||||||
|
const [failureResults, setFailureResults] = useState<MoveResultFailed[] | undefined>();
|
||||||
|
const [successState, setSuccessState] = useState<MoveResultSuccessState>({
|
||||||
|
allSuccess: false,
|
||||||
|
repoUrl: '',
|
||||||
|
});
|
||||||
|
const [hasSubmitted, setHasSubmitted] = useState(false);
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
const [moveFile, moveRequest] = useCreateRepositoryFilesWithPathMutation();
|
||||||
|
const methods = useForm<BulkActionFormData>({ 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<string | undefined> => {
|
||||||
|
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<ResourceWrapper>(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 (
|
||||||
|
<FormProvider {...methods}>
|
||||||
|
<form onSubmit={handleSubmit(handleSubmitForm)}>
|
||||||
|
<Stack direction="column" gap={2}>
|
||||||
|
<Box paddingBottom={2}>
|
||||||
|
<Trans i18nKey="browse-dashboards.bulk-move-resources-form.move-warning">
|
||||||
|
This will move selected folders and their descendants. In total, this will affect:
|
||||||
|
</Trans>
|
||||||
|
<DescendantCount selectedItems={{ ...selectedItems, panel: {}, $all: false }} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{hasSubmitted ? (
|
||||||
|
<BulkActionPostSubmitStep
|
||||||
|
action="move"
|
||||||
|
progress={progress}
|
||||||
|
successState={successState}
|
||||||
|
failureResults={failureResults}
|
||||||
|
handleSuccess={handleSuccess}
|
||||||
|
setFailureResults={setFailureResults}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Target folder selection */}
|
||||||
|
<Field noMargin label={t('browse-dashboards.bulk-move-resources-form.target-folder', 'Target Folder')}>
|
||||||
|
<FolderPicker value={targetFolderUID} onChange={setTargetFolderUID} />
|
||||||
|
</Field>
|
||||||
|
<ResourceEditFormSharedFields
|
||||||
|
resourceType="folder"
|
||||||
|
isNew={false}
|
||||||
|
workflow={workflow}
|
||||||
|
workflowOptions={workflowOptions}
|
||||||
|
repository={repository}
|
||||||
|
hidePath
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Button type="submit" disabled={moveRequest.isLoading || !!failureResults}>
|
||||||
|
{moveRequest.isLoading
|
||||||
|
? t('browse-dashboards.bulk-move-resources-form.button-moving', 'Moving...')
|
||||||
|
: t('browse-dashboards.bulk-move-resources-form.button-move', 'Move')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" fill="outline" onClick={onDismiss} disabled={moveRequest.isLoading}>
|
||||||
|
<Trans i18nKey="browse-dashboards.bulk-move-resources-form.button-cancel">Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<FormContent
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
initialValues={initialValues}
|
||||||
|
repository={repository}
|
||||||
|
workflowOptions={workflowOptions}
|
||||||
|
folderPath={folderPath}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<DashboardTreeSelection, 'panel' | '$all'>;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BulkSuccessResponse<T, K> = 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}`;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
useGetRepositoryFilesWithPathQuery,
|
useGetRepositoryFilesWithPathQuery,
|
||||||
} from 'app/api/clients/provisioning/v0alpha1';
|
} from 'app/api/clients/provisioning/v0alpha1';
|
||||||
import { AnnoKeySourcePath } from 'app/features/apiserver/types';
|
import { AnnoKeySourcePath } from 'app/features/apiserver/types';
|
||||||
|
import { getTargetFolderPathInRepo } from 'app/features/browse-dashboards/components/BulkActions/utils';
|
||||||
|
|
||||||
import { ResourceEditFormSharedFields } from '../components/Provisioned/ResourceEditFormSharedFields';
|
import { ResourceEditFormSharedFields } from '../components/Provisioned/ResourceEditFormSharedFields';
|
||||||
import { ProvisionedDashboardFormData } from '../saving/shared';
|
import { ProvisionedDashboardFormData } from '../saving/shared';
|
||||||
@@ -74,8 +75,7 @@ export function MoveProvisionedDashboardForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderAnnotations = targetFolder.metadata.annotations || {};
|
const targetFolderPath = getTargetFolderPathInRepo({ targetFolder });
|
||||||
const targetFolderPath = folderAnnotations[AnnoKeySourcePath] || targetFolderTitle;
|
|
||||||
|
|
||||||
const filename = currentSourcePath.split('/').pop();
|
const filename = currentSourcePath.split('/').pop();
|
||||||
const newPath = `${targetFolderPath}/${filename}`;
|
const newPath = `${targetFolderPath}/${filename}`;
|
||||||
|
|||||||
@@ -3482,6 +3482,7 @@
|
|||||||
"browse-dashboards": {
|
"browse-dashboards": {
|
||||||
"action": {
|
"action": {
|
||||||
"bulk-delete-provisioned-resources": "Bulk Delete Provisioned Resources",
|
"bulk-delete-provisioned-resources": "Bulk Delete Provisioned Resources",
|
||||||
|
"bulk-move-provisioned-resources": "Bulk Move Provisioned Resources",
|
||||||
"cancel-button": "Cancel",
|
"cancel-button": "Cancel",
|
||||||
"cannot-move-folders": "Folders cannot be moved",
|
"cannot-move-folders": "Folders cannot be moved",
|
||||||
"confirmation-text": "Delete",
|
"confirmation-text": "Delete",
|
||||||
@@ -3515,22 +3516,33 @@
|
|||||||
"this-folder-is-empty": "This folder is empty"
|
"this-folder-is-empty": "This folder is empty"
|
||||||
},
|
},
|
||||||
"bulk-action-resources-form": {
|
"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_one": "{{count}} items failed",
|
||||||
"failed-alert_other": "{{count}} items failed"
|
"failed-alert_other": "{{count}} items failed",
|
||||||
|
"progress-title": "Success"
|
||||||
},
|
},
|
||||||
"bulk-delete-resources-form": {
|
"bulk-delete-resources-form": {
|
||||||
"button-cancel": "Cancel",
|
"button-cancel": "Cancel",
|
||||||
"button-delete": "Delete",
|
"button-delete": "Delete",
|
||||||
"button-deleting": "Deleting...",
|
"button-deleting": "Deleting...",
|
||||||
"button-done": "Done",
|
|
||||||
"delete-warning": "This will delete selected folders and their descendants. In total, this will affect:",
|
"delete-warning": "This will delete selected folders and their descendants. In total, this will affect:",
|
||||||
"error-path-not-found": "Path not found",
|
"error-path-not-found": "Path not found"
|
||||||
"progress-title": "Success",
|
|
||||||
"success-message": "All resources have been deleted successfully."
|
|
||||||
},
|
},
|
||||||
"bulk-move-resources-form": {
|
"bulk-move-resources-form": {
|
||||||
"deleting": "Deleting:",
|
"button-cancel": "Cancel",
|
||||||
"progress": "Progress: {{current}} of {{total}}"
|
"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": {
|
"counts": {
|
||||||
"alertRule_one": "{{count}} alert rule",
|
"alertRule_one": "{{count}} alert rule",
|
||||||
|
|||||||
Reference in New Issue
Block a user