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

* betterer
pull/107756/head^2
Alex Khomenko 2 weeks ago committed by GitHub
parent d7f7a19c56
commit 39904d7b9b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 9
      .betterer.results
  2. 4
      public/app/features/browse-dashboards/components/ProvisionedFolderPreviewBanner.tsx
  3. 8
      public/app/features/dashboard-scene/saving/provisioned/PreviewBannerViewPR.test.tsx
  4. 31
      public/app/features/dashboard-scene/saving/provisioned/PreviewBannerViewPR.tsx
  5. 121
      public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx
  6. 43
      public/app/features/dashboard-scene/settings/MoveProvisionedDashboardDrawer.tsx
  7. 241
      public/app/features/dashboard-scene/settings/MoveProvisionedDashboardForm.test.tsx
  8. 238
      public/app/features/dashboard-scene/settings/MoveProvisionedDashboardForm.tsx
  9. 20
      public/locales/en-US/grafana.json

@ -1687,15 +1687,6 @@ exports[`better eslint`] = {
"public/app/features/dashboard-scene/serialization/transformToV1TypesUtils.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"],
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"],
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "2"],
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "3"],
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "4"],
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "5"],
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "6"]
],
"public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsEdit.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"],
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"],

@ -12,11 +12,11 @@ export function ProvisionedFolderPreviewBanner({ queryParams }: CommonBannerProp
}
if (prURL) {
return <PreviewBannerViewPR prParam={prURL} isFolder />;
return <PreviewBannerViewPR prParam={prURL} />;
}
if (newPrURL) {
return <PreviewBannerViewPR prParam={newPrURL} isFolder isNewPr />;
return <PreviewBannerViewPR prParam={newPrURL} isNewPr />;
}
return null;

@ -60,14 +60,14 @@ describe('PreviewBannerViewPR', () => {
setup({ prParam: 'test-url', isFolder: false, isNewPr: true });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.getByText('This dashboard is loaded from a branch in GitHub.')).toBeInTheDocument();
expect(screen.getByText('A new resource has been created in a branch in GitHub.')).toBeInTheDocument();
});
it('should render correct text for existing PR dashboard', () => {
setup({ prParam: 'test-url', isFolder: false, isNewPr: false });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.getByText('This dashboard is loaded from a pull request in GitHub.')).toBeInTheDocument();
expect(screen.getByText('This resource is loaded from a pull request in GitHub.')).toBeInTheDocument();
});
it('should render correct button text for new PR dashboard', () => {
@ -88,14 +88,14 @@ describe('PreviewBannerViewPR', () => {
setup({ prParam: 'test-url', isFolder: true, isNewPr: true });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.getByText('A new folder has been created in a branch in GitHub.')).toBeInTheDocument();
expect(screen.getByText('A new resource has been created in a branch in GitHub.')).toBeInTheDocument();
});
it('should render correct text for existing PR folder', () => {
setup({ prParam: 'test-url', isFolder: true, isNewPr: false });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.getByText('A new folder has been created in a pull request in GitHub.')).toBeInTheDocument();
expect(screen.getByText('This resource is loaded from a pull request in GitHub.')).toBeInTheDocument();
});
it('should render correct button text for new PR folder', () => {

@ -8,33 +8,22 @@ import { commonAlertProps } from './DashboardPreviewBanner';
interface Props {
prParam: string;
isFolder?: boolean;
isNewPr?: boolean;
}
/**
* @description This component is used to display a banner when a provisioned dashboard/folder is created or loaded from a new branch in Github.
*/
export function PreviewBannerViewPR({ prParam, isFolder = false, isNewPr }: Props) {
const titleText = isFolder
? isNewPr
? t(
'provisioned-resource-preview-banner.title-folder-created-branch-git-hub',
'A new folder has been created in a branch in GitHub.'
)
: t(
'provisioned-resource-preview-banner.title-folder-created-pull-request-git-hub',
'A new folder has been created in a pull request in GitHub.'
)
: isNewPr
? t(
'provisioned-resource-preview-banner.title-dashboard-loaded-branch-git-hub',
'This dashboard is loaded from a branch in GitHub.'
)
: t(
'provisioned-resource-preview-banner.title-dashboard-loaded-pull-request-git-hub',
'This dashboard is loaded from a pull request in GitHub.'
);
export function PreviewBannerViewPR({ prParam, isNewPr }: Props) {
const titleText = isNewPr
? t(
'provisioned-resource-preview-banner.title-created-branch-git-hub',
'A new resource has been created in a branch in GitHub.'
)
: t(
'provisioned-resource-preview-banner.title-loaded-pull-request-git-hub',
'This resource is loaded from a pull request in GitHub.'
);
return (
<Alert

@ -31,9 +31,16 @@ import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { getDashboardSceneFor } from '../utils/utils';
import { DeleteDashboardButton } from './DeleteDashboardButton';
import { MoveProvisionedDashboardDrawer } from './MoveProvisionedDashboardDrawer';
import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils';
export interface GeneralSettingsEditViewState extends DashboardEditViewState {}
export interface GeneralSettingsEditViewState extends DashboardEditViewState {
showMoveModal?: boolean;
moveModalProps?: {
targetFolderUID?: string;
targetFolderTitle?: string;
};
}
export class GeneralSettingsEditView
extends SceneObjectBase<GeneralSettingsEditViewState>
@ -156,10 +163,40 @@ export class GeneralSettingsEditView
public onDeleteDashboard = () => {};
public onProvisionedFolderChange = async (newUID?: string, newTitle?: string) => {
if (newUID !== this._dashboard.state.meta.folderUid) {
this.setState({
showMoveModal: true,
moveModalProps: {
targetFolderUID: newUID,
targetFolderTitle: newTitle,
},
});
}
};
public onMoveModalDismiss = () => {
this.setState({
showMoveModal: false,
moveModalProps: undefined,
});
};
private onMoveSuccess = (folderUID: string, folderTitle: string) => {
const newMeta = {
...this._dashboard.state.meta,
folderUid: folderUID,
folderTitle: folderTitle,
};
this._dashboard.setState({ meta: newMeta });
this.onMoveModalDismiss();
};
static Component = ({ model }: SceneComponentProps<GeneralSettingsEditView>) => {
const dashboard = model.getDashboard();
const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey());
const { title, description, tags, meta, editable } = dashboard.useState();
const { showMoveModal, moveModalProps } = model.useState();
const { sync: graphTooltip } = model.getCursorSync()?.useState() || {};
const { timeZone, weekStart, UNSAFE_nowDelay: nowDelay } = model.getTimeRange().useState();
const { intervals } = model.getRefreshPicker().useState();
@ -201,8 +238,9 @@ export class GeneralSettingsEditView
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}>
<NavToolbarActions dashboard={dashboard} />
<div style={{ maxWidth: '600px' }}>
<Box marginBottom={5}>
<Box display="flex" direction="column" gap={2} marginBottom={5}>
<Field
noMargin
label={
<Stack justifyContent="space-between">
<Label htmlFor="title-input">
@ -222,6 +260,7 @@ export class GeneralSettingsEditView
/>
</Field>
<Field
noMargin
label={
<Stack justifyContent="space-between">
<Label htmlFor="description-input">
@ -240,18 +279,18 @@ export class GeneralSettingsEditView
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => model.onDescriptionChange(e.target.value)}
/>
</Field>
<Field label={t('dashboard-settings.general.tags-label', 'Tags')}>
<Field noMargin label={t('dashboard-settings.general.tags-label', 'Tags')}>
<TagsInput id="tags-input" tags={tags} onChange={model.onTagsChange} width={40} />
</Field>
<Field label={t('dashboard-settings.general.folder-label', 'Folder')}>
{dashboard.isManagedRepository() ? (
<Input readOnly value={meta.folderTitle} />
) : (
<FolderPicker value={meta.folderUid} onChange={model.onFolderChange} />
)}
<Field noMargin label={t('dashboard-settings.general.folder-label', 'Folder')}>
<FolderPicker
value={meta.folderUid}
onChange={dashboard.isManagedRepository() ? model.onProvisionedFolderChange : model.onFolderChange}
/>
</Field>
<Field
noMargin
label={t('dashboard-settings.general.editable-label', 'Editable')}
description={t(
'dashboard-settings.general.editable-description',
@ -282,33 +321,51 @@ export class GeneralSettingsEditView
label={t('dashboard-settings.general.panel-options-label', 'Panel options')}
isOpen={true}
>
<Field
label={t('dashboard-settings.general.panel-options-graph-tooltip-label', 'Graph tooltip')}
description={t(
'dashboard-settings.general.panel-options-graph-tooltip-description',
'Controls tooltip and hover highlight behavior across different panels. Reload the dashboard for changes to take effect'
)}
>
<RadioButtonGroup onChange={model.onTooltipChange} options={GRAPH_TOOLTIP_OPTIONS} value={graphTooltip} />
</Field>
<Field
label={t('dashboard-settings.general.panels-preload-label', 'Preload panels')}
description={t(
'dashboard-settings.general.panels-preload-description',
'When enabled all panels will start loading as soon as the dashboard has been loaded.'
)}
>
<Switch
id="preload-panels-dashboards-toggle"
value={dashboard.state.preload}
onChange={(e) => model.onPreloadChange(e.currentTarget.checked)}
/>
</Field>
<Stack direction="column" gap={2}>
<Field
noMargin
label={t('dashboard-settings.general.panel-options-graph-tooltip-label', 'Graph tooltip')}
description={t(
'dashboard-settings.general.panel-options-graph-tooltip-description',
'Controls tooltip and hover highlight behavior across different panels. Reload the dashboard for changes to take effect'
)}
>
<RadioButtonGroup
onChange={model.onTooltipChange}
options={GRAPH_TOOLTIP_OPTIONS}
value={graphTooltip}
/>
</Field>
<Field
noMargin
label={t('dashboard-settings.general.panels-preload-label', 'Preload panels')}
description={t(
'dashboard-settings.general.panels-preload-description',
'When enabled all panels will start loading as soon as the dashboard has been loaded.'
)}
>
<Switch
id="preload-panels-dashboards-toggle"
value={dashboard.state.preload}
onChange={(e) => model.onPreloadChange(e.currentTarget.checked)}
/>
</Field>
</Stack>
</CollapsableSection>
<Box marginTop={3}>{meta.canDelete && <DeleteDashboardButton dashboard={dashboard} />}</Box>
</div>
{showMoveModal && moveModalProps && (
<MoveProvisionedDashboardDrawer
dashboard={dashboard}
targetFolderUID={moveModalProps.targetFolderUID}
targetFolderTitle={moveModalProps.targetFolderTitle}
onDismiss={model.onMoveModalDismiss}
onSuccess={model.onMoveSuccess}
/>
)}
</Page>
);
};

@ -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>
);
}

@ -5656,6 +5656,20 @@
"usage-count_one": "Used on {{count}} dashboards",
"usage-count_other": "Used on {{count}} dashboards"
},
"move-provisioned-dashboard-form": {
"api-error": "Failed to move dashboard",
"cancel-action": "Cancel",
"current-file-not-found": "Current dashboard file could not be found",
"drawer-title": "Move Provisioned Dashboard",
"file-load-error": "Error loading dashboard",
"loading-file-data": "Loading dashboard data",
"move-action": "Move dashboard",
"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.",
"moving": "Moving...",
"partial-failure-warning": "Dashboard was created at new location but could not be deleted from original location. Please manually remove the old file.",
"target-path-label": "Target path",
"title-this-repository-is-read-only": "This repository is read only"
},
"name-already-exists-error": {
"body-name-already-exists": "A dashboard with the same name in selected folder already exists. Would you still like to save this dashboard?",
"title-name-already-exists": "Name already exists"
@ -10313,10 +10327,8 @@
"open-pull-request-in-git-hub": "Open pull request in GitHub",
"view-pull-request-in-git-hub": "View pull request in GitHub"
},
"title-dashboard-loaded-branch-git-hub": "This dashboard is loaded from a branch in GitHub.",
"title-dashboard-loaded-pull-request-git-hub": "This dashboard is loaded from a pull request in GitHub.",
"title-folder-created-branch-git-hub": "A new folder has been created in a branch in GitHub.",
"title-folder-created-pull-request-git-hub": "A new folder has been created in a pull request in GitHub."
"title-created-branch-git-hub": "A new resource has been created in a branch in GitHub.",
"title-loaded-pull-request-git-hub": "This resource is loaded from a pull request in GitHub."
},
"provisioning": {
"banner": {

Loading…
Cancel
Save