mirror of https://github.com/grafana/grafana
Provisioning: Move dashboard from setting page (#107793)
* Provisioning: Move dashboard from the settings page * Cleanup * i18n * Add noMargin * Add test * Use new pr param * Cleanup * Fix tests * i18n * bettererpull/107756/head^2
parent
d7f7a19c56
commit
39904d7b9b
@ -0,0 +1,43 @@ |
||||
import { useProvisionedDashboardData } from '../saving/provisioned/hooks'; |
||||
import { DashboardScene } from '../scene/DashboardScene'; |
||||
|
||||
import { MoveProvisionedDashboardForm } from './MoveProvisionedDashboardForm'; |
||||
|
||||
export interface Props { |
||||
dashboard: DashboardScene; |
||||
targetFolderUID?: string; |
||||
targetFolderTitle?: string; |
||||
onDismiss: () => void; |
||||
onSuccess: (folderUID: string, folderTitle: string) => void; |
||||
} |
||||
|
||||
export function MoveProvisionedDashboardDrawer({ |
||||
dashboard, |
||||
targetFolderUID, |
||||
targetFolderTitle, |
||||
onDismiss, |
||||
onSuccess, |
||||
}: Props) { |
||||
const { defaultValues, loadedFromRef, readOnly, isGitHub, workflowOptions, isNew } = |
||||
useProvisionedDashboardData(dashboard); |
||||
|
||||
if (!defaultValues) { |
||||
return null; |
||||
} |
||||
|
||||
return ( |
||||
<MoveProvisionedDashboardForm |
||||
dashboard={dashboard} |
||||
defaultValues={defaultValues} |
||||
loadedFromRef={loadedFromRef} |
||||
readOnly={readOnly} |
||||
isGitHub={isGitHub} |
||||
isNew={isNew} |
||||
workflowOptions={workflowOptions} |
||||
targetFolderUID={targetFolderUID} |
||||
targetFolderTitle={targetFolderTitle} |
||||
onDismiss={onDismiss} |
||||
onSuccess={onSuccess} |
||||
/> |
||||
); |
||||
} |
@ -0,0 +1,241 @@ |
||||
import { render, screen } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
|
||||
import { getAppEvents } from '@grafana/runtime'; |
||||
import { useGetFolderQuery } from 'app/api/clients/folder/v1beta1'; |
||||
import { |
||||
useCreateRepositoryFilesWithPathMutation, |
||||
useDeleteRepositoryFilesWithPathMutation, |
||||
useGetRepositoryFilesWithPathQuery, |
||||
} from 'app/api/clients/provisioning/v0alpha1'; |
||||
import { AnnoKeySourcePath } from 'app/features/apiserver/types'; |
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene'; |
||||
import { useProvisionedRequestHandler } from '../utils/useProvisionedRequestHandler'; |
||||
|
||||
import { MoveProvisionedDashboardForm, Props } from './MoveProvisionedDashboardForm'; |
||||
|
||||
jest.mock('@grafana/runtime', () => { |
||||
const actual = jest.requireActual('@grafana/runtime'); |
||||
return { |
||||
...actual, |
||||
getAppEvents: jest.fn(), |
||||
}; |
||||
}); |
||||
|
||||
jest.mock('app/api/clients/provisioning/v0alpha1', () => ({ |
||||
useGetRepositoryFilesWithPathQuery: jest.fn(), |
||||
useCreateRepositoryFilesWithPathMutation: jest.fn(), |
||||
useDeleteRepositoryFilesWithPathMutation: jest.fn(), |
||||
})); |
||||
|
||||
jest.mock('app/api/clients/folder/v1beta1', () => ({ |
||||
useGetFolderQuery: jest.fn(), |
||||
})); |
||||
|
||||
jest.mock('../utils/useProvisionedRequestHandler', () => ({ |
||||
useProvisionedRequestHandler: jest.fn(), |
||||
})); |
||||
|
||||
jest.mock('react-router-dom-v5-compat', () => ({ |
||||
useNavigate: () => jest.fn(), |
||||
})); |
||||
|
||||
jest.mock('../components/Provisioned/ResourceEditFormSharedFields', () => ({ |
||||
ResourceEditFormSharedFields: () => <div data-testid="resource-edit-form" />, |
||||
})); |
||||
|
||||
function setup(props: Partial<Props> = {}) { |
||||
const user = userEvent.setup(); |
||||
|
||||
const mockDashboard = { |
||||
useState: jest.fn().mockReturnValue({ |
||||
editPanel: null, |
||||
}), |
||||
state: { |
||||
title: 'Test Dashboard', |
||||
}, |
||||
} as unknown as DashboardScene; |
||||
|
||||
const defaultProps: Props = { |
||||
dashboard: mockDashboard, |
||||
defaultValues: { |
||||
repo: 'test-repo', |
||||
path: 'folder1/dashboard.json', |
||||
ref: 'main', |
||||
workflow: 'write', |
||||
comment: '', |
||||
title: 'Test Dashboard', |
||||
description: '', |
||||
folder: { uid: '', title: '' }, |
||||
}, |
||||
readOnly: false, |
||||
isGitHub: true, |
||||
workflowOptions: [ |
||||
{ label: 'Write', value: 'write' }, |
||||
{ label: 'Branch', value: 'branch' }, |
||||
], |
||||
targetFolderUID: 'target-folder-uid', |
||||
targetFolderTitle: 'Target Folder', |
||||
onDismiss: jest.fn(), |
||||
onSuccess: jest.fn(), |
||||
...props, |
||||
}; |
||||
|
||||
return { |
||||
user, |
||||
...render(<MoveProvisionedDashboardForm {...defaultProps} />), |
||||
props: defaultProps, |
||||
}; |
||||
} |
||||
|
||||
const mockCreateRequest = { |
||||
isSuccess: false, |
||||
isError: false, |
||||
isLoading: false, |
||||
error: null, |
||||
}; |
||||
|
||||
const mockDeleteRequest = { |
||||
isSuccess: false, |
||||
isError: false, |
||||
isLoading: false, |
||||
error: null, |
||||
}; |
||||
|
||||
describe('MoveProvisionedDashboardForm', () => { |
||||
beforeEach(() => { |
||||
jest.clearAllMocks(); |
||||
|
||||
// Setup default mocks
|
||||
const mockAppEvents = { |
||||
publish: jest.fn(), |
||||
}; |
||||
(getAppEvents as jest.Mock).mockReturnValue(mockAppEvents); |
||||
|
||||
// Mock hooks
|
||||
(useGetRepositoryFilesWithPathQuery as jest.Mock).mockReturnValue({ |
||||
data: { |
||||
resource: { |
||||
file: { spec: { title: 'Test Dashboard' } }, |
||||
dryRun: { |
||||
metadata: { |
||||
annotations: { |
||||
[AnnoKeySourcePath]: 'folder1/dashboard.json', |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
isLoading: false, |
||||
}); |
||||
|
||||
(useGetFolderQuery as jest.Mock).mockReturnValue({ |
||||
data: { |
||||
metadata: { |
||||
annotations: { |
||||
[AnnoKeySourcePath]: 'target-folder', |
||||
}, |
||||
}, |
||||
}, |
||||
isLoading: false, |
||||
}); |
||||
|
||||
(useCreateRepositoryFilesWithPathMutation as jest.Mock).mockReturnValue([jest.fn(), mockCreateRequest]); |
||||
|
||||
(useDeleteRepositoryFilesWithPathMutation as jest.Mock).mockReturnValue([jest.fn(), mockDeleteRequest]); |
||||
|
||||
(useProvisionedRequestHandler as jest.Mock).mockReturnValue(undefined); |
||||
}); |
||||
|
||||
it('should render the form with correct title and subtitle', () => { |
||||
setup(); |
||||
|
||||
expect(screen.getByText('Move Provisioned Dashboard')).toBeInTheDocument(); |
||||
expect(screen.getByText('Test Dashboard')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should render form even when currentFileData is not available', () => { |
||||
(useGetRepositoryFilesWithPathQuery as jest.Mock).mockReturnValue({ |
||||
data: null, |
||||
isLoading: false, |
||||
}); |
||||
|
||||
setup(); |
||||
|
||||
// Form should still render, but move button should be disabled
|
||||
expect(screen.getByText('Move Provisioned Dashboard')).toBeInTheDocument(); |
||||
expect(screen.getByRole('button', { name: /move dashboard/i })).toBeDisabled(); |
||||
}); |
||||
|
||||
it('should show loading spinner when file data is loading', () => { |
||||
(useGetRepositoryFilesWithPathQuery as jest.Mock).mockReturnValue({ |
||||
data: null, |
||||
isLoading: true, |
||||
}); |
||||
|
||||
setup(); |
||||
|
||||
expect(screen.getByText('Loading dashboard data')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should show read-only alert when repository is read-only', () => { |
||||
setup({ readOnly: true }); |
||||
|
||||
expect(screen.getByText('This repository is read only')).toBeInTheDocument(); |
||||
expect(screen.getByText(/This dashboard cannot be moved directly from Grafana/)).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should show target path input with calculated path', () => { |
||||
setup(); |
||||
|
||||
expect(screen.getByDisplayValue('target-folder/dashboard.json')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should show error alert when file data has errors', () => { |
||||
(useGetRepositoryFilesWithPathQuery as jest.Mock).mockReturnValue({ |
||||
data: { |
||||
errors: ['File not found', 'Permission denied'], |
||||
resource: null, |
||||
}, |
||||
isLoading: false, |
||||
}); |
||||
|
||||
setup(); |
||||
|
||||
expect(screen.getByText('Error loading dashboard')).toBeInTheDocument(); |
||||
expect(screen.getByText('File not found')).toBeInTheDocument(); |
||||
expect(screen.getByText('Permission denied')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should disable move button when form is submitting', () => { |
||||
(useCreateRepositoryFilesWithPathMutation as jest.Mock).mockReturnValue([ |
||||
jest.fn(), |
||||
{ |
||||
...mockCreateRequest, |
||||
isLoading: true, |
||||
}, |
||||
]); |
||||
|
||||
setup(); |
||||
|
||||
const moveButton = screen.getByRole('button', { name: /moving/i }); |
||||
expect(moveButton).toBeDisabled(); |
||||
expect(moveButton).toHaveTextContent('Moving...'); |
||||
}); |
||||
|
||||
it('should show move dashboard button when not loading', () => { |
||||
setup(); |
||||
|
||||
expect(screen.getByRole('button', { name: /move dashboard/i })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should call onDismiss when cancel button is clicked', async () => { |
||||
const { user, props } = setup(); |
||||
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i }); |
||||
await user.click(cancelButton); |
||||
|
||||
expect(props.onDismiss).toHaveBeenCalled(); |
||||
}); |
||||
}); |
@ -0,0 +1,238 @@ |
||||
import { useEffect, useState } from 'react'; |
||||
import { FormProvider, useForm } from 'react-hook-form'; |
||||
import { useNavigate } from 'react-router-dom-v5-compat'; |
||||
|
||||
import { AppEvents } from '@grafana/data'; |
||||
import { Trans, t } from '@grafana/i18n'; |
||||
import { getAppEvents } from '@grafana/runtime'; |
||||
import { Alert, Button, Drawer, Field, Input, Spinner, Stack } from '@grafana/ui'; |
||||
import { useGetFolderQuery } from 'app/api/clients/folder/v1beta1'; |
||||
import { |
||||
useCreateRepositoryFilesWithPathMutation, |
||||
useDeleteRepositoryFilesWithPathMutation, |
||||
useGetRepositoryFilesWithPathQuery, |
||||
} from 'app/api/clients/provisioning/v0alpha1'; |
||||
import { AnnoKeySourcePath } from 'app/features/apiserver/types'; |
||||
|
||||
import { ResourceEditFormSharedFields } from '../components/Provisioned/ResourceEditFormSharedFields'; |
||||
import { ProvisionedDashboardFormData } from '../saving/shared'; |
||||
import { DashboardScene } from '../scene/DashboardScene'; |
||||
import { useProvisionedRequestHandler } from '../utils/useProvisionedRequestHandler'; |
||||
|
||||
export interface Props { |
||||
dashboard: DashboardScene; |
||||
defaultValues: ProvisionedDashboardFormData; |
||||
readOnly: boolean; |
||||
isGitHub: boolean; |
||||
isNew?: boolean; |
||||
workflowOptions: Array<{ label: string; value: string }>; |
||||
loadedFromRef?: string; |
||||
targetFolderUID?: string; |
||||
targetFolderTitle?: string; |
||||
onDismiss: () => void; |
||||
onSuccess: (folderUID: string, folderTitle: string) => void; |
||||
} |
||||
|
||||
export function MoveProvisionedDashboardForm({ |
||||
dashboard, |
||||
defaultValues, |
||||
loadedFromRef, |
||||
readOnly, |
||||
isGitHub, |
||||
isNew, |
||||
workflowOptions, |
||||
targetFolderUID, |
||||
targetFolderTitle, |
||||
onDismiss, |
||||
onSuccess, |
||||
}: Props) { |
||||
const methods = useForm<ProvisionedDashboardFormData>({ defaultValues }); |
||||
const { editPanel: panelEditor } = dashboard.useState(); |
||||
const { handleSubmit, watch } = methods; |
||||
const appEvents = getAppEvents(); |
||||
|
||||
const [ref, workflow] = watch(['ref', 'workflow']); |
||||
|
||||
const { data: currentFileData, isLoading: isLoadingFileData } = useGetRepositoryFilesWithPathQuery({ |
||||
name: defaultValues.repo, |
||||
path: defaultValues.path, |
||||
}); |
||||
|
||||
const { data: targetFolder } = useGetFolderQuery({ name: targetFolderUID! }, { skip: !targetFolderUID }); |
||||
|
||||
const [createFile, createRequest] = useCreateRepositoryFilesWithPathMutation(); |
||||
const [deleteFile, deleteRequest] = useDeleteRepositoryFilesWithPathMutation(); |
||||
const [targetPath, setTargetPath] = useState<string>(''); |
||||
|
||||
const navigate = useNavigate(); |
||||
|
||||
useEffect(() => { |
||||
const currentSourcePath = currentFileData?.resource?.dryRun?.metadata?.annotations?.[AnnoKeySourcePath]; |
||||
if (!targetFolderUID || !targetFolder || !currentSourcePath) { |
||||
return; |
||||
} |
||||
|
||||
const folderAnnotations = targetFolder.metadata.annotations || {}; |
||||
const targetFolderPath = folderAnnotations[AnnoKeySourcePath] || targetFolderTitle; |
||||
|
||||
const filename = currentSourcePath.split('/').pop(); |
||||
const newPath = `${targetFolderPath}/${filename}`; |
||||
|
||||
setTargetPath(newPath); |
||||
}, [currentFileData, targetFolder, targetFolderUID, targetFolderTitle]); |
||||
|
||||
const handleSubmitForm = async ({ repo, path, comment }: ProvisionedDashboardFormData) => { |
||||
if (!currentFileData?.resource?.file) { |
||||
appEvents.publish({ |
||||
type: AppEvents.alertError.name, |
||||
payload: [ |
||||
t( |
||||
'dashboard-scene.move-provisioned-dashboard-form.current-file-not-found', |
||||
'Current dashboard file could not be found' |
||||
), |
||||
], |
||||
}); |
||||
return; |
||||
} |
||||
|
||||
const branchRef = workflow === 'write' ? loadedFromRef : ref; |
||||
const commitMessage = comment || `Move dashboard: ${dashboard.state.title}`; |
||||
|
||||
try { |
||||
await createFile({ |
||||
name: repo, |
||||
path: targetPath, |
||||
ref: branchRef, |
||||
message: commitMessage, |
||||
body: currentFileData.resource.file, |
||||
}).unwrap(); |
||||
|
||||
await deleteFile({ |
||||
name: repo, |
||||
path: path, |
||||
ref: branchRef, |
||||
message: commitMessage, |
||||
}).unwrap(); |
||||
} catch (error) { |
||||
if (createRequest.isSuccess && !deleteRequest.isSuccess) { |
||||
appEvents.publish({ |
||||
type: AppEvents.alertWarning.name, |
||||
payload: [ |
||||
t( |
||||
'dashboard-scene.move-provisioned-dashboard-form.partial-failure-warning', |
||||
'Dashboard was created at new location but could not be deleted from original location. Please manually remove the old file.' |
||||
), |
||||
], |
||||
}); |
||||
} |
||||
appEvents.publish({ |
||||
type: AppEvents.alertError.name, |
||||
payload: [t('dashboard-scene.move-provisioned-dashboard-form.api-error', 'Failed to move dashboard'), error], |
||||
}); |
||||
} |
||||
}; |
||||
|
||||
const onWriteSuccess = () => { |
||||
panelEditor?.onDiscard(); |
||||
if (targetFolderUID && targetFolderTitle) { |
||||
onSuccess(targetFolderUID, targetFolderTitle); |
||||
} |
||||
navigate('/dashboards'); |
||||
}; |
||||
|
||||
const onBranchSuccess = () => { |
||||
panelEditor?.onDiscard(); |
||||
navigate(`/dashboards?new_pull_request_url=${createRequest.data?.urls?.newPullRequestURL}`); |
||||
}; |
||||
|
||||
useProvisionedRequestHandler({ |
||||
dashboard, |
||||
request: createRequest, |
||||
workflow, |
||||
handlers: { |
||||
onBranchSuccess, |
||||
onWriteSuccess, |
||||
}, |
||||
}); |
||||
|
||||
const isLoading = createRequest.isLoading || deleteRequest.isLoading; |
||||
|
||||
return ( |
||||
<Drawer |
||||
title={t('dashboard-scene.move-provisioned-dashboard-form.drawer-title', 'Move Provisioned Dashboard')} |
||||
subtitle={dashboard.state.title} |
||||
onClose={onDismiss} |
||||
> |
||||
<FormProvider {...methods}> |
||||
<form onSubmit={handleSubmit(handleSubmitForm)}> |
||||
<Stack direction="column" gap={2}> |
||||
{readOnly && ( |
||||
<Alert |
||||
title={t( |
||||
'dashboard-scene.move-provisioned-dashboard-form.title-this-repository-is-read-only', |
||||
'This repository is read only' |
||||
)} |
||||
> |
||||
<Trans i18nKey="dashboard-scene.move-provisioned-dashboard-form.move-read-only-message"> |
||||
This dashboard cannot be moved directly from Grafana because the repository is read-only. To move this |
||||
dashboard, please move the file in your Git repository. |
||||
</Trans> |
||||
</Alert> |
||||
)} |
||||
|
||||
{isLoadingFileData && ( |
||||
<Stack alignItems="center" gap={2}> |
||||
<Spinner /> |
||||
<div> |
||||
{t('dashboard-scene.move-provisioned-dashboard-form.loading-file-data', 'Loading dashboard data')} |
||||
</div> |
||||
</Stack> |
||||
)} |
||||
|
||||
{currentFileData?.errors?.length && currentFileData.errors.length > 0 && ( |
||||
<Alert |
||||
title={t('dashboard-scene.move-provisioned-dashboard-form.file-load-error', 'Error loading dashboard')} |
||||
severity="error" |
||||
> |
||||
{currentFileData.errors.map((error, index) => ( |
||||
<div key={index}>{error}</div> |
||||
))} |
||||
</Alert> |
||||
)} |
||||
|
||||
<Field |
||||
noMargin |
||||
label={t('dashboard-scene.move-provisioned-dashboard-form.target-path-label', 'Target path')} |
||||
> |
||||
<Input readOnly value={targetPath} /> |
||||
</Field> |
||||
|
||||
<ResourceEditFormSharedFields |
||||
resourceType="dashboard" |
||||
isNew={isNew} |
||||
readOnly={readOnly} |
||||
workflow={workflow} |
||||
workflowOptions={workflowOptions} |
||||
isGitHub={isGitHub} |
||||
/> |
||||
|
||||
<Stack gap={2}> |
||||
<Button |
||||
variant="primary" |
||||
type="submit" |
||||
disabled={isLoading || readOnly || !currentFileData || isLoadingFileData} |
||||
> |
||||
{isLoading |
||||
? t('dashboard-scene.move-provisioned-dashboard-form.moving', 'Moving...') |
||||
: t('dashboard-scene.move-provisioned-dashboard-form.move-action', 'Move dashboard')} |
||||
</Button> |
||||
<Button variant="secondary" onClick={onDismiss} fill="outline"> |
||||
<Trans i18nKey="dashboard-scene.move-provisioned-dashboard-form.cancel-action">Cancel</Trans> |
||||
</Button> |
||||
</Stack> |
||||
</Stack> |
||||
</form> |
||||
</FormProvider> |
||||
</Drawer> |
||||
); |
||||
} |
Loading…
Reference in new issue