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:
co-authored by
Alex Khomenko
parent
51629f6d44
commit
d3bd3175af
@@ -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>
|
||||
|
||||
+296
@@ -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,
|
||||
};
|
||||
}
|
||||
+2
-1
@@ -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>
|
||||
);
|
||||
|
||||
+21
-12
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
-51
@@ -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;
|
||||
}
|
||||
+1
-4
@@ -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,
|
||||
});
|
||||
|
||||
+1
@@ -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}
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user