ProvisionedFolder: Delete folder drawer (#107089)

* ProvisionedFolder: delete flow set up

---------

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>
This commit is contained in:
Yunwen Zheng
2025-06-25 11:13:42 -04:00
committed by GitHub
co-authored by Alex Khomenko
parent 51629f6d44
commit d3bd3175af
14 changed files with 609 additions and 103 deletions
@@ -5,7 +5,7 @@ import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Text, Box, Button, useStyles2, Space } from '@grafana/ui';
import { Text, Box, Button, useStyles2 } from '@grafana/ui';
import { SlideDown } from 'app/core/components/Animations/SlideDown';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { DescendantCount } from 'app/features/browse-dashboards/components/BrowseActions/DescendantCount';
@@ -158,7 +158,7 @@ export const Permissions = ({
<>
<div>
{canSetPermissions && resource === 'folders' && (
<>
<Box paddingBottom={2}>
<Trans i18nKey="access-control.permissions.permissions-change-warning">
This will change permissions for this folder and all its descendants. In total, this will affect:
</Trans>
@@ -170,8 +170,7 @@ export const Permissions = ({
$all: false,
}}
/>
<Space v={2} />
</>
</Box>
)}
{items.length === 0 && (
<Box>
@@ -0,0 +1,296 @@
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 { FolderDTO } from 'app/types';
import { ProvisionedFolderFormDataResult, useProvisionedFolderFormData } from '../hooks/useProvisionedFolderFormData';
import { DeleteProvisionedFolderForm } from './DeleteProvisionedFolderForm';
// Mock dependencies
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getAppEvents: jest.fn(() => ({
publish: jest.fn(),
})),
}));
jest.mock('app/api/clients/provisioning/v0alpha1', () => ({
useDeleteRepositoryFilesWithPathMutation: jest.fn(),
provisioningAPI: {
endpoints: {
listRepository: {
select: jest.fn(() => () => ({ data: { items: [] } })),
},
},
},
}));
jest.mock('../hooks/useProvisionedFolderFormData');
jest.mock('./BrowseActions/DescendantCount', () => ({
DescendantCount: () => <div data-testid="descendant-count">2 folders, 5 dashboards</div>,
}));
jest.mock('app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields', () => ({
DashboardEditFormSharedFields: () => <div data-testid="shared-fields" />,
}));
const mockUseDeleteRepositoryFilesMutation = useDeleteRepositoryFilesWithPathMutation as jest.MockedFunction<
typeof useDeleteRepositoryFilesWithPathMutation
>;
const mockUseProvisionedFolderFormData = useProvisionedFolderFormData as jest.MockedFunction<
typeof useProvisionedFolderFormData
>;
const mockDeleteRepoFile = jest.fn();
const mockParentFolder: FolderDTO = {
id: 1,
uid: 'folder-uid',
title: 'Test Folder',
url: '/dashboards/f/folder-uid/test-folder',
hasAcl: false,
canSave: true,
canEdit: true,
canAdmin: true,
canDelete: true,
createdBy: '',
created: '',
updatedBy: '',
updated: '',
version: 1,
parentUid: 'parent-folder-uid',
};
const mockRepository: RepositoryView = {
name: 'test-repo',
target: 'folder' as const,
title: 'Test Repository',
type: 'git' as const,
workflows: [],
};
const mockFolder = {
metadata: {
name: 'test-folder',
annotations: {
'grafana.app/sourcePath': 'folders/test-folder.json',
},
},
spec: {
title: 'Test Folder',
},
status: {},
};
const mockFormData = {
repo: 'test-repo',
path: 'folders/test-folder.json',
ref: 'main',
workflow: 'write' as const,
comment: '',
title: 'Test Folder',
};
const defaultHookData: ProvisionedFolderFormDataResult = {
workflowOptions: [
{ label: 'Write directly', value: 'write' },
{ label: 'Create branch', value: 'branch' },
],
isGitHub: true,
repository: mockRepository,
folder: mockFolder,
initialValues: mockFormData,
};
function setup(
props: Partial<Parameters<typeof DeleteProvisionedFolderForm>[0]> = {},
hookData = defaultHookData,
requestState: { isLoading: boolean; isSuccess: boolean; isError: boolean; error: Error | null } = {
isLoading: false,
isSuccess: false,
isError: false,
error: null,
}
) {
const mockMutationResult = [mockDeleteRepoFile, requestState] as unknown as ReturnType<
typeof useDeleteRepositoryFilesWithPathMutation
>;
const mockHookResult = hookData as ReturnType<typeof useProvisionedFolderFormData>;
mockUseDeleteRepositoryFilesMutation.mockReturnValue(mockMutationResult);
mockUseProvisionedFolderFormData.mockReturnValue(mockHookResult);
const onDismiss = jest.fn();
const defaultProps = {
parentFolder: mockParentFolder,
onDismiss,
};
const renderResult = render(<DeleteProvisionedFolderForm {...defaultProps} {...props} />);
const clickDeleteButton = async () => {
const deleteButton = screen.getByRole('button', { name: /delete/i });
await userEvent.click(deleteButton);
};
return {
...renderResult,
onDismiss,
mockDeleteRepoFile,
clickDeleteButton,
};
}
describe('DeleteProvisionedFolderForm', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(console, 'error').mockImplementation(() => {});
// Mock window.location.href
Object.defineProperty(window, 'location', {
value: { href: '' },
writable: true,
});
});
describe('rendering', () => {
it('should render component correctly ', () => {
setup();
// delete warning and descendant count
expect(screen.getByText(/This will delete this folder and all its descendants/)).toBeInTheDocument();
expect(screen.getByTestId('descendant-count')).toBeInTheDocument();
// delete and cancel buttons
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
it('should not render if initialValues is null', () => {
setup({}, { ...defaultHookData, initialValues: undefined });
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
});
});
describe('form submission', () => {
it('should call deleteRepoFile with correct parameters on form submission', async () => {
const { mockDeleteRepoFile, clickDeleteButton } = setup();
await clickDeleteButton();
await waitFor(() => {
expect(mockDeleteRepoFile).toHaveBeenCalledWith({
name: 'test-repo',
path: 'folders/test-folder.json/',
ref: undefined, // write workflow doesn't set ref
message: 'Delete folder: folders/test-folder.json',
});
});
});
it('should use custom commit message if provided', async () => {
const customFormData = {
...mockFormData,
comment: 'Custom delete message',
};
const { mockDeleteRepoFile, clickDeleteButton } = setup(
{},
{ ...defaultHookData, initialValues: customFormData }
);
await clickDeleteButton();
await waitFor(() => {
expect(mockDeleteRepoFile).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Custom delete message',
})
);
});
});
it('should set ref when workflow is branch', async () => {
const branchFormData = {
...mockFormData,
workflow: 'branch' as const,
ref: 'feature-branch',
};
const { mockDeleteRepoFile, clickDeleteButton } = setup(
{},
{ ...defaultHookData, initialValues: branchFormData }
);
await clickDeleteButton();
await waitFor(() => {
expect(mockDeleteRepoFile).toHaveBeenCalledWith(
expect.objectContaining({
ref: 'feature-branch',
})
);
});
});
it('should not submit if repository name is missing', async () => {
const { mockDeleteRepoFile, clickDeleteButton } = setup({}, { ...defaultHookData, repository: undefined });
await clickDeleteButton();
await waitFor(() => {
expect(mockDeleteRepoFile).not.toHaveBeenCalled();
});
});
});
describe('loading state', () => {
it('should show loading text and disable button when request is loading', () => {
setup({}, defaultHookData, { isLoading: true, isSuccess: false, isError: false, error: null });
const deleteButton = screen.getByRole('button', { name: /deleting/i });
expect(deleteButton).toBeDisabled();
});
});
describe('success handling', () => {
it('should navigate to parent folder on successful write workflow', async () => {
const successState = { isLoading: false, isSuccess: true, isError: false, error: null };
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 };
setup({ parentFolder: folderWithoutParent }, defaultHookData, successState);
await waitFor(() => {
expect(window.location.href).toBe('/dashboards');
});
});
it('should handle branch workflow success without navigation', async () => {
const branchFormData = { ...mockFormData, workflow: 'branch' } as unknown as typeof mockFormData;
const successState = { isLoading: false, isSuccess: true, isError: false, error: null };
setup({}, { ...defaultHookData, initialValues: branchFormData }, successState);
await waitFor(() => {
expect(window.location.href).toBe('');
});
});
});
describe('error handling', () => {
it('should handle request failure', async () => {
const error = new Error('API Error');
const errorState = { isLoading: false, isSuccess: false, isError: true, error };
setup({}, defaultHookData, errorState);
// Component should handle error gracefully without crashing
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
});
});
});
@@ -0,0 +1,171 @@
import { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { AppEvents } from '@grafana/data';
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 { DashboardEditFormSharedFields } from 'app/features/dashboard-scene/components/Provisioned/DashboardEditFormSharedFields';
import { BaseProvisionedFormData } from 'app/features/dashboard-scene/saving/shared';
import { FolderDTO } from 'app/types';
import { useProvisionedFolderFormData } from '../hooks/useProvisionedFolderFormData';
import { DescendantCount } from './BrowseActions/DescendantCount';
import { getFolderURL } from './utils';
interface FormProps extends DeleteProvisionedFolderFormProps {
initialValues: BaseProvisionedFormData;
repository?: RepositoryView;
workflowOptions: Array<{ label: string; value: string }>;
folder?: Folder;
isGitHub: boolean;
}
interface DeleteProvisionedFolderFormProps {
parentFolder?: FolderDTO;
onDismiss?: () => void;
}
function FormContent({
initialValues,
parentFolder,
repository,
workflowOptions,
folder,
isGitHub,
onDismiss,
}: FormProps) {
const resourceId = parentFolder?.uid || '';
const [deleteRepoFile, request] = useDeleteRepositoryFilesWithPathMutation();
const methods = useForm<BaseProvisionedFormData>({ defaultValues: initialValues });
const { handleSubmit, watch } = methods;
const workflow = watch('workflow');
const handleSubmitForm = async ({ repo, path, comment, ref }: BaseProvisionedFormData) => {
if (!repository?.name) {
return;
}
const commitMessage = comment || `Delete folder: ${folder?.metadata?.annotations?.[AnnoKeySourcePath]}`;
const targetRef = workflow === 'write' ? undefined : ref;
deleteRepoFile({
name: repo,
path: `${path}/`,
ref: targetRef,
message: commitMessage,
});
};
// TODO: move to a hook if this useEffect shared mostly the same logic as in NewProvisionedFolderForm
useEffect(() => {
if (request.isSuccess && repository) {
if (workflow === 'branch') {
// TODO: handle display banner https://github.com/grafana/git-ui-sync-project/issues/300
// TODO: implement when BE is ready
return;
}
if (workflow === 'write') {
getAppEvents().publish({
type: AppEvents.alertSuccess.name,
payload: [
t(
'browse-dashboards.delete-provisioned-folder-form.alert-folder-deleted-successfully',
'Folder deleted successfully'
),
],
});
// 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';
}
}
}
if (request.isError) {
getAppEvents().publish({
type: AppEvents.alertError.name,
payload: [
t('browse-dashboards.delete-provisioned-folder-form.api-error', 'Failed to delete folder'),
request.error,
],
});
return;
}
}, [request, repository, workflow, parentFolder]);
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>
<DashboardEditFormSharedFields
resourceType="folder"
isNew={false}
workflow={workflow}
workflowOptions={workflowOptions}
isGitHub={isGitHub}
/>
{/* Delete / Cancel button */}
<Stack gap={2}>
<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>
<Button variant="secondary" fill="outline" onClick={onDismiss}>
<Trans i18nKey="browse-dashboards.delete-provisioned-folder-form.button-cancel">Cancel</Trans>
</Button>
</Stack>
</Stack>
</form>
</FormProvider>
);
}
export function DeleteProvisionedFolderForm({ parentFolder, onDismiss }: DeleteProvisionedFolderFormProps) {
const { workflowOptions, isGitHub, repository, folder, initialValues } = useProvisionedFolderFormData({
folderUid: parentFolder?.uid,
action: 'delete',
title: parentFolder?.title,
});
if (!initialValues) {
return null;
}
return (
<FormContent
parentFolder={parentFolder}
onDismiss={onDismiss}
initialValues={initialValues}
repository={repository}
workflowOptions={workflowOptions}
folder={folder}
isGitHub={isGitHub}
/>
);
}
@@ -5,7 +5,6 @@ import { config, locationService, reportInteraction } from '@grafana/runtime';
import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui';
import { Permissions } from 'app/core/components/AccessControl';
import { appEvents } from 'app/core/core';
import { ProvisionedResourceDeleteModal } from 'app/features/dashboard-scene/saving/provisioned/ProvisionedResourceDeleteModal';
import { FolderDTO } from 'app/types';
import { ShowModalReactEvent } from 'app/types/events';
@@ -15,6 +14,7 @@ import { getFolderPermissions } from '../permissions';
import { DeleteModal } from './BrowseActions/DeleteModal';
import { MoveModal } from './BrowseActions/MoveModal';
import { DeleteProvisionedFolderForm } from './DeleteProvisionedFolderForm';
interface Props {
folder: FolderDTO;
@@ -23,6 +23,7 @@ interface Props {
export function FolderActionsButton({ folder }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [showPermissionsDrawer, setShowPermissionsDrawer] = useState(false);
const [showDeleteProvisionedFolderDrawer, setShowDeleteProvisionedFolderDrawer] = useState(false);
const [moveFolder] = useMoveFolderMutation();
const [deleteFolder] = useDeleteFolderMutation();
@@ -91,14 +92,7 @@ export function FolderActionsButton({ folder }: Props) {
};
const showDeleteProvisionedModal = () => {
appEvents.publish(
new ShowModalReactEvent({
component: ProvisionedResourceDeleteModal,
props: {
resource: folder,
},
})
);
setShowDeleteProvisionedFolderDrawer(true);
};
const managePermissionsLabel = t('browse-dashboards.folder-actions-button.manage-permissions', 'Manage permissions');
@@ -109,7 +103,8 @@ export function FolderActionsButton({ folder }: Props) {
<Menu>
{canViewPermissions && <MenuItem onClick={() => setShowPermissionsDrawer(true)} label={managePermissionsLabel} />}
{canMoveFolder && <MenuItem onClick={showMoveModal} label={moveLabel} />}
{canDeleteFolders && (
{/* TODO: remove isProvisionedFolder check once BE folder delete flow is complete */}
{canDeleteFolders && !isProvisionedFolder && (
<MenuItem
destructive
onClick={isProvisionedFolder ? showDeleteProvisionedModal : showDeleteModal}
@@ -141,6 +136,18 @@ export function FolderActionsButton({ folder }: Props) {
<Permissions resource="folders" resourceId={folder.uid} canSetPermissions={canSetPermissions} />
</Drawer>
)}
{showDeleteProvisionedFolderDrawer && (
<Drawer
title={t('browse-dashboards.action.delete-provisioned-folder', 'Delete provisioned folder')}
subtitle={folder.title}
onClose={() => setShowDeleteProvisionedFolderDrawer(false)}
>
<DeleteProvisionedFolderForm
parentFolder={folder}
onDismiss={() => setShowDeleteProvisionedFolderDrawer(false)}
/>
</Drawer>
)}
</>
);
}
@@ -39,6 +39,7 @@ const initialFormValues: Partial<FormData> = {
ref: `folder/${Date.now()}`,
};
// TODO: use useProvisionedFolderFormData hook to manage form data and repository state
export function NewProvisionedFolderForm({ onSubmit, onCancel, parentFolder }: Props) {
const { repository, folder, isLoading } = useGetResourceRepositoryView({ folderName: parentFolder?.uid });
const prURL = usePullRequestParam();
@@ -62,6 +63,7 @@ export function NewProvisionedFolderForm({ onSubmit, onCancel, parentFolder }: P
setValue('workflow', getDefaultWorkflow(repository));
}, [repository, setValue]);
// TODO: replace with useProvisionedRequestHandler hook
useEffect(() => {
const appEvents = getAppEvents();
if (request.isSuccess && repository) {
@@ -194,6 +196,7 @@ export function NewProvisionedFolderForm({ onSubmit, onCancel, parentFolder }: P
/>
</Field>
{/* TODO: use DashboardEditFormSharedFields to replace comment and workflow input*/}
<Field label={t('browse-dashboards.new-provisioned-folder-form.label-comment', 'Comment')}>
<TextArea
{...register('comment')}
@@ -0,0 +1,63 @@
import { useMemo } from 'react';
import { Folder } from 'app/api/clients/folder/v1beta1';
import { RepositoryView } from 'app/api/clients/provisioning/v0alpha1';
import { AnnoKeySourcePath } from 'app/features/apiserver/types';
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 { BaseProvisionedFormData } from '../../dashboard-scene/saving/shared';
export interface UseProvisionedFolderFormDataProps {
folderUid?: string;
action: 'create' | 'delete';
title?: string;
}
export interface ProvisionedFolderFormDataResult {
repository?: RepositoryView;
folder?: Folder;
workflowOptions: Array<{ label: string; value: string }>;
isGitHub: boolean;
initialValues?: BaseProvisionedFormData;
}
/**
* Hook for managing provisioned folder create/delete form data.
*/
export function useProvisionedFolderFormData({
folderUid,
action,
title,
}: UseProvisionedFolderFormDataProps): ProvisionedFolderFormDataResult {
const { repository, folder, isLoading } = useGetResourceRepositoryView({ folderName: folderUid });
const workflowOptions = getWorkflowOptions(repository);
const isGitHub = repository?.type === 'github';
const timestamp = generateTimestamp();
const initialValues = useMemo(() => {
// Only create initial values when we have the data
if (!repository || !folder || isLoading) {
return undefined;
}
return {
title: title || '',
comment: '',
ref: `folder/${timestamp}`,
repo: repository.name || '',
path: folder?.metadata?.annotations?.[AnnoKeySourcePath] || '',
workflow: getDefaultWorkflow(repository),
};
}, [repository, folder, title, isLoading, timestamp]);
return {
repository,
folder,
workflowOptions,
isGitHub,
initialValues,
};
}
@@ -65,7 +65,7 @@ function setup(options: SetupOptions = {}) {
user,
...render(
<FormWrapper>
<DashboardEditFormSharedFields {...componentProps} />
<DashboardEditFormSharedFields {...componentProps} resourceType="dashboard" />
</FormWrapper>
),
};
@@ -192,6 +192,7 @@ describe('DashboardEditFormSharedFields', () => {
{ label: 'Create branch', value: 'branch' },
]}
isNew={true}
resourceType="dashboard"
/>
</FormProvider>
);
@@ -8,6 +8,7 @@ import { WorkflowOption } from 'app/features/provisioning/types';
import { validateBranchName } from 'app/features/provisioning/utils/git';
interface DashboardEditFormSharedFieldsProps {
resourceType: 'dashboard' | 'folder';
workflowOptions: Array<{ label: string; value: string }>;
isNew?: boolean;
readOnly?: boolean;
@@ -16,35 +17,43 @@ interface DashboardEditFormSharedFieldsProps {
}
export const DashboardEditFormSharedFields = memo<DashboardEditFormSharedFieldsProps>(
({ readOnly = false, workflow, workflowOptions, isGitHub, isNew }) => {
({ readOnly = false, workflow, workflowOptions, isGitHub, isNew, resourceType }) => {
const {
control,
register,
formState: { errors },
} = useFormContext();
const pathText =
resourceType === 'dashboard'
? 'File path inside the repository (.json or .yaml)'
: 'Folder path inside the repository';
return (
<>
{/* Path */}
<Field
noMargin
label={t('dashboard-scene.save-or-delete-provisioned-dashboard-form.label-path', 'Path')}
label={t('provisioned-resource-form.save-or-delete-resource-shared-fields.label-path', 'Path')}
description={t(
'dashboard-scene.save-or-delete-provisioned-dashboard-form.description-inside-repository',
'File path inside the repository (.json or .yaml)'
'provisioned-resource-form.save-or-delete-resource-shared-fields.description-inside-repository',
pathText
)}
>
<Input id="dashboard-path" type="text" {...register('path')} readOnly={!isNew} />
</Field>
{/* Comment */}
<Field noMargin label={t('dashboard-scene.save-or-delete-provisioned-dashboard-form.label-comment', 'Comment')}>
<Field
noMargin
label={t('provisioned-resource-form.save-or-delete-resource-shared-fields.label-comment', 'Comment')}
>
<TextArea
id="dashboard-comment"
id="provisioned-resource-form-comment"
{...register('comment')}
disabled={readOnly}
placeholder={t(
'dashboard-scene.save-or-delete-provisioned-dashboard-form.dashboard-comment-placeholder-describe-changes-optional',
'provisioned-resource-form.save-or-delete-resource-shared-fields.comment-placeholder-describe-changes-optional',
'Add a note to describe your changes (optional)'
)}
rows={5}
@@ -56,28 +65,28 @@ export const DashboardEditFormSharedFields = memo<DashboardEditFormSharedFieldsP
<>
<Field
noMargin
label={t('dashboard-scene.save-or-delete-provisioned-dashboard-form.label-workflow', 'Workflow')}
label={t('provisioned-resource-form.save-or-delete-resource-shared-fields.label-workflow', 'Workflow')}
>
<Controller
control={control}
name="workflow"
render={({ field: { ref: _, ...field } }) => (
<RadioButtonGroup id="dashboard-workflow" {...field} options={workflowOptions} />
<RadioButtonGroup id="provisioned-resource-form-workflow" {...field} options={workflowOptions} />
)}
/>
</Field>
{workflow === 'branch' && (
<Field
noMargin
label={t('dashboard-scene.save-or-delete-provisioned-dashboard-form.label-branch', 'Branch')}
label={t('provisioned-resource-form.save-or-delete-resource-shared-fields.label-branch', 'Branch')}
description={t(
'dashboard-scene.save-or-delete-provisioned-dashboard-form.description-branch-name-in-git-hub',
'provisioned-resource-form.save-or-delete-resource-shared-fields.description-branch-name-in-git-hub',
'Branch name in GitHub'
)}
invalid={!!errors.ref}
error={errors.ref && <BranchValidationError />}
>
<Input id="dashboard-branch" {...register('ref', { validate: validateBranchName })} />
<Input id="provisioned-resource-form-branch" {...register('ref', { validate: validateBranchName })} />
</Field>
)}
</>
@@ -1,51 +0,0 @@
import { Trans, t } from '@grafana/i18n';
import { Button, Modal } from '@grafana/ui';
import { FolderDTO, FolderListItemDTO } from '../../../../types';
import { NestedFolderDTO } from '../../../search/service/types';
import { DashboardScene } from '../../scene/DashboardScene';
type FolderDataType = FolderListItemDTO | NestedFolderDTO | FolderDTO;
export interface Props {
onDismiss: () => void;
resource: DashboardScene | FolderDataType;
}
export function ProvisionedResourceDeleteModal({ onDismiss, resource }: Props) {
return (
<Modal
isOpen={true}
title={t(
'dashboard-scene.provisioned-resource-delete-modal.title-cannot-delete-provisioned-resource',
'Cannot delete provisioned resource'
)}
onDismiss={onDismiss}
>
<>
<p>
<Trans i18nKey="dashboard-scene.provisioned-resource-delete-modal.managed-by-version-control">
This resource is managed by version control and cannot be deleted. To remove it, delete it from the
repository and synchronise to apply the changes.
</Trans>
</p>
{isDashboard(resource) && (
<p>
<Trans i18nKey="dashboard-scene.provisioned-resource-delete-modal.file-path">File path:</Trans>{' '}
{resource.getPath()}
</p>
)}
</>
<Modal.ButtonRow>
<Button variant="primary" onClick={onDismiss}>
<Trans i18nKey="dashboard-scene.provisioned-resource-delete-modal.ok">OK</Trans>
</Button>
</Modal.ButtonRow>
</Modal>
);
}
function isDashboard(resource: DashboardScene | FolderDataType): resource is DashboardScene {
return resource instanceof DashboardScene;
}
@@ -278,9 +278,6 @@ describe('SaveProvisionedDashboardForm', () => {
const pathInput = screen.getByRole('textbox', { name: /path/i });
expect(pathInput).toHaveAttribute('readonly'); // can not edit the path value
pathInput.removeAttribute('readonly'); // save won't get called unless we have a value
await user.clear(pathInput);
await user.type(pathInput, 'path/to/file.json');
const commentInput = screen.getByRole('textbox', { name: /comment/i });
await user.clear(commentInput);
@@ -291,7 +288,7 @@ describe('SaveProvisionedDashboardForm', () => {
expect(mockAction).toHaveBeenCalledWith({
ref: undefined,
name: 'test-repo',
path: 'path/to/file.json',
path: 'test-dashboard.json',
message: 'Update dashboard',
body: updatedDashboard,
});
@@ -215,6 +215,7 @@ export function SaveProvisionedDashboardForm({
{!isNew && !readOnly && <SaveDashboardFormCommonOptions drawer={drawer} changeInfo={changeInfo} />}
<DashboardEditFormSharedFields
resourceType="dashboard"
readOnly={readOnly}
workflow={workflow}
workflowOptions={workflowOptions}
@@ -23,13 +23,17 @@ export interface DashboardChangeInfo {
hasFolderChanges?: boolean;
hasMigratedToV2?: boolean;
}
export interface ProvisionedDashboardFormData {
ref?: string; // Branch or tag in the repository
path: string; // Path to the dashboard file in the repository
comment?: string; // Commit message for the change
repo: string; // Repository name
export interface BaseProvisionedFormData {
ref?: string;
path: string;
comment?: string;
repo: string;
workflow?: WorkflowOption;
title: string; // Title of the dashboard
title: string;
}
export interface ProvisionedDashboardFormData extends BaseProvisionedFormData {
description: string;
folder: {
uid?: string;
@@ -122,6 +122,7 @@ export function DeleteProvisionedDashboardForm({
)}
<DashboardEditFormSharedFields
resourceType="dashboard"
isNew={isNew}
readOnly={readOnly}
workflow={workflow}
+20 -15
View File
@@ -3366,6 +3366,7 @@
"delete-modal-restore-dashboards-text": "This action will delete the selected folders immediately but the selected dashboards will be marked for deletion in 30 days. Your organization administrator can restore the dashboards anytime before the 30 days expire. Folders cannot be restored.",
"delete-modal-text": "This action will delete the following content:",
"delete-modal-title": "Delete",
"delete-provisioned-folder": "Delete provisioned folder",
"deleting": "Deleting...",
"manage-permissions-button": "Manage permissions",
"move-button": "Move",
@@ -3406,6 +3407,14 @@
"select-checkbox": "Select",
"tags-column": "Tags"
},
"delete-provisioned-folder-form": {
"alert-folder-deleted-successfully": "Folder deleted successfully",
"api-error": "Failed to delete folder",
"button-cancel": "Cancel",
"button-delete": "Delete",
"button-deleting": "Deleting...",
"delete-warning": "This will delete this folder and all its descendants. In total, this will affect:"
},
"descendant-count": {
"title-unable-to-retrieve-descendant-information": "Unable to retrieve descendant information"
},
@@ -5604,12 +5613,6 @@
"see-docs": "See <2>documentation</2> for more information about provisioning.",
"title-cannot-delete-provisioned-dashboard": "Cannot delete provisioned dashboard"
},
"provisioned-resource-delete-modal": {
"file-path": "File path:",
"managed-by-version-control": "This resource is managed by version control and cannot be deleted. To remove it, delete it from the repository and synchronise to apply the changes.",
"ok": "OK",
"title-cannot-delete-provisioned-resource": "Cannot delete provisioned resource"
},
"query-editor": {
"query": "Query"
},
@@ -5698,15 +5701,6 @@
"placeholder-search-affected-dashboards": "Search affected dashboards",
"update-all": "Update all"
},
"save-or-delete-provisioned-dashboard-form": {
"dashboard-comment-placeholder-describe-changes-optional": "Add a note to describe your changes (optional)",
"description-branch-name-in-git-hub": "Branch name in GitHub",
"description-inside-repository": "File path inside the repository (.json or .yaml)",
"label-branch": "Branch",
"label-comment": "Comment",
"label-path": "Path",
"label-workflow": "Workflow"
},
"save-provisioned-dashboard-form": {
"api-error": "Error saving dashboard",
"cancel": "Cancel",
@@ -9942,6 +9936,17 @@
"text-loading-teams": "Loading teams..."
}
},
"provisioned-resource-form": {
"save-or-delete-resource-shared-fields": {
"comment-placeholder-describe-changes-optional": "Add a note to describe your changes (optional)",
"description-branch-name-in-git-hub": "Branch name in GitHub",
"description-inside-repository": "",
"label-branch": "Branch",
"label-comment": "Comment",
"label-path": "Path",
"label-workflow": "Workflow"
}
},
"provisioning": {
"banner": {
"message": "This feature is currently under active development. For the best experience and latest improvements, we recommend using the <2>nightly build</2> of Grafana."