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