From 71cee10cb6625fb1bf3335a01c52a75a8a44388c Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Mon, 17 Mar 2025 16:15:41 +0200 Subject: [PATCH] Provisioning: Add dashboard saving functionality (#102269) * Move dashboard-scene, provisioned dashboard features, and dashboard services/types from grafana-git-ui-sync branch * Merge * Update props order * Fix imports * Fix imports * Update dashboard page * Update imports * Update test * Tweaks * Remove extra mocks * Split out utils * Translate * Revert * Add translations * Add comment * Prettier * Add comment * Use AnnoKeyManagerIdentity * Add manager kind --- .../pages/DashboardScenePage.tsx | 6 +- .../saving/SaveDashboardAsForm.tsx | 14 +- .../saving/SaveDashboardDrawer.tsx | 17 +- .../ProvisionedResourceDeleteModal.tsx | 59 +++ .../provisioned/SaveProvisionedDashboard.tsx | 41 ++ .../SaveProvisionedDashboardForm.test.tsx | 381 ++++++++++++++++++ .../SaveProvisionedDashboardForm.tsx | 347 ++++++++++++++++ .../saving/provisioned/defaults.ts | 25 ++ .../saving/provisioned/hooks.ts | 91 +++++ .../saving/provisioned/utils/path.test.ts | 71 ++++ .../saving/provisioned/utils/path.ts | 34 ++ .../provisioned/utils/timestamp.test.ts | 26 ++ .../saving/provisioned/utils/timestamp.ts | 11 + .../scene/NavToolbarActions.tsx | 7 +- .../settings/DeleteDashboardButton.tsx | 5 + .../features/dashboard/containers/types.ts | 1 + .../hooks/useGetResourceRepository.ts | 4 +- public/app/types/dashboard.ts | 3 +- public/locales/en-US/grafana.json | 37 ++ 19 files changed, 1169 insertions(+), 11 deletions(-) create mode 100644 public/app/features/dashboard-scene/saving/provisioned/ProvisionedResourceDeleteModal.tsx create mode 100644 public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboard.tsx create mode 100644 public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.test.tsx create mode 100644 public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx create mode 100644 public/app/features/dashboard-scene/saving/provisioned/defaults.ts create mode 100644 public/app/features/dashboard-scene/saving/provisioned/hooks.ts create mode 100644 public/app/features/dashboard-scene/saving/provisioned/utils/path.test.ts create mode 100644 public/app/features/dashboard-scene/saving/provisioned/utils/path.ts create mode 100644 public/app/features/dashboard-scene/saving/provisioned/utils/timestamp.test.ts create mode 100644 public/app/features/dashboard-scene/saving/provisioned/utils/timestamp.ts diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx index 7aad80d73e1..83e72d3b1ca 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx +++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx @@ -23,6 +23,8 @@ export interface Props export function DashboardScenePage({ route, queryParams, location }: Props) { const params = useParams(); const { type, slug, uid } = params; + // User by /admin/provisioning/:slug/dashboard/preview/* to load dashboards based on their file path in a remote repository + const path = params['*']; const prevMatch = usePrevious({ params }); const stateManager = getDashboardScenePageStateManager(); const { dashboard, isLoading, loadError } = stateManager.useState(); @@ -34,9 +36,9 @@ export function DashboardScenePage({ route, queryParams, location }: Props) { stateManager.loadSnapshot(slug!); } else { stateManager.loadDashboard({ + uid: (route.routeName === DashboardRoutes.Provisioning ? path : uid) ?? '', type, slug, - uid: uid ?? '', route: route.routeName as DashboardRoutes, urlFolderUid: queryParams.folderUid, }); @@ -45,7 +47,7 @@ export function DashboardScenePage({ route, queryParams, location }: Props) { return () => { stateManager.clearState(); }; - }, [stateManager, uid, route.routeName, queryParams.folderUid, routeReloadCounter, slug, type]); + }, [stateManager, uid, route.routeName, queryParams.folderUid, routeReloadCounter, slug, type, path]); if (!dashboard) { let errorElement; diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx index 96e52c2346a..2d3428f3807 100644 --- a/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx +++ b/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx @@ -4,9 +4,11 @@ import { UseFormSetValue, useForm } from 'react-hook-form'; import { selectors } from '@grafana/e2e-selectors'; import { Button, Input, Switch, Field, Label, TextArea, Stack, Alert, Box } from '@grafana/ui'; +import { RepositoryView } from 'app/api/clients/provisioning'; import { FolderPicker } from 'app/core/components/Select/FolderPicker'; import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv'; +import { AnnoKeyManagerIdentity, AnnoKeyManagerKind, ManagerKind } from '../../apiserver/types'; import { DashboardScene } from '../scene/DashboardScene'; import { DashboardChangeInfo, NameAlreadyExistsError, SaveButton, isNameExistsError } from './shared'; @@ -131,10 +133,20 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) { { + onChange={(uid: string | undefined, title: string | undefined, repository?: RepositoryView) => { + const name = repository?.name; setValue('folder', { uid, title }); const folderUid = dashboard.state.meta.folderUid; setHasFolderChanged(uid !== folderUid); + dashboard.setState({ + // This is necessary to switch to the provisioning flow if a folder is provisioned + meta: { + k8s: name + ? { annotations: { [AnnoKeyManagerIdentity]: name, [AnnoKeyManagerKind]: ManagerKind.Repo } } + : undefined, + folderUid: uid, + }, + }); }} // Old folder picker fields value={formValues.folder?.uid} diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx index a3d824cab71..72e5dca3639 100644 --- a/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx +++ b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx @@ -1,12 +1,14 @@ import { SceneComponentProps, SceneObjectBase, SceneObjectState, SceneObjectRef } from '@grafana/scenes'; import { Drawer, Tab, TabsBar } from '@grafana/ui'; import { SaveDashboardDiff } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDiff'; +import { useIsProvisionedNG } from 'app/features/provisioning/hooks/useIsProvisionedNG'; import { DashboardScene } from '../scene/DashboardScene'; import { SaveDashboardAsForm } from './SaveDashboardAsForm'; import { SaveDashboardForm } from './SaveDashboardForm'; import { SaveProvisionedDashboardForm } from './SaveProvisionedDashboardForm'; +import { SaveProvisionedDashboard } from './provisioned/SaveProvisionedDashboard'; interface SaveDashboardDrawerState extends SceneObjectState { dashboardRef: SceneObjectRef; @@ -20,7 +22,13 @@ interface SaveDashboardDrawerState extends SceneObjectState { export class SaveDashboardDrawer extends SceneObjectBase { public onClose = () => { - this.state.dashboardRef.resolve().setState({ overlay: undefined }); + const dashboard = this.state.dashboardRef.resolve(); + const changeInfo = dashboard.getDashboardChanges(); + dashboard.setState({ + overlay: undefined, + // Reset meta to initial state if it's a new dashboard to remove provisioned fields + meta: changeInfo.isNew ? dashboard.getInitialState()?.meta : dashboard.state.meta, + }); }; public onToggleSaveTimeRange = () => { @@ -47,6 +55,7 @@ export class SaveDashboardDrawer extends SceneObjectBase @@ -65,7 +74,7 @@ export class SaveDashboardDrawer extends SceneObjectBase; + } + if (saveAsCopy || changeInfo.isNew) { return ; } diff --git a/public/app/features/dashboard-scene/saving/provisioned/ProvisionedResourceDeleteModal.tsx b/public/app/features/dashboard-scene/saving/provisioned/ProvisionedResourceDeleteModal.tsx new file mode 100644 index 00000000000..79e9131ac4b --- /dev/null +++ b/public/app/features/dashboard-scene/saving/provisioned/ProvisionedResourceDeleteModal.tsx @@ -0,0 +1,59 @@ +import { Button, Modal } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; + +import { FolderDTO, FolderListItemDTO } from '../../../../types'; +import { NestedFolderDTO } from '../../../search/service/types'; +import { DashboardScene } from '../../scene/DashboardScene'; + +export type FolderDataType = FolderListItemDTO | NestedFolderDTO | FolderDTO; + +export interface Props { + onDismiss: () => void; + resource: DashboardScene | FolderDataType; +} + +export function ProvisionedResourceDeleteModal({ onDismiss, resource }: Props) { + const type = isDashboard(resource) ? 'dashboard' : 'folder'; + return ( + + <> +

+ + This {type} is managed by version control and cannot be deleted. To remove it, delete it from the repository + and synchronise to apply the changes. + +

+ {isDashboard(resource) && ( +

+ File path:{' '} + {resource.getPath()} +

+ )} + + + + + +
+ ); +} + +function isDashboard(resource: DashboardScene | FolderDataType): resource is DashboardScene { + return resource instanceof DashboardScene; +} + +export function isFolder(resource: DashboardScene | FolderDataType): resource is FolderDataType { + return !isDashboard(resource); +} diff --git a/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboard.tsx b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboard.tsx new file mode 100644 index 00000000000..0c41acce505 --- /dev/null +++ b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboard.tsx @@ -0,0 +1,41 @@ +import { useUrlParams } from 'app/core/navigation/hooks'; + +import { DashboardScene } from '../../scene/DashboardScene'; +import { SaveDashboardDrawer } from '../SaveDashboardDrawer'; +import { DashboardChangeInfo } from '../shared'; + +import { SaveProvisionedDashboardForm } from './SaveProvisionedDashboardForm'; +import { useDefaultValues } from './hooks'; + +export interface SaveProvisionedDashboardProps { + dashboard: DashboardScene; + drawer: SaveDashboardDrawer; + changeInfo: DashboardChangeInfo; +} + +export function SaveProvisionedDashboard({ drawer, changeInfo, dashboard }: SaveProvisionedDashboardProps) { + const { meta, title: defaultTitle, description: defaultDescription } = dashboard.useState(); + + const [params] = useUrlParams(); + const loadedFromRef = params.get('ref') ?? undefined; + + const defaultValues = useDefaultValues({ meta, defaultTitle, defaultDescription }); + + if (!defaultValues) { + return null; + } + const { values, isNew, isGitHub, repositoryConfig } = defaultValues; + + return ( + + ); +} diff --git a/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.test.tsx b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.test.tsx new file mode 100644 index 00000000000..9aca67ddbb5 --- /dev/null +++ b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.test.tsx @@ -0,0 +1,381 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { AppEvents } from '@grafana/data'; +import { getAppEvents, locationService } from '@grafana/runtime'; +import { Dashboard } from '@grafana/schema'; +import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv'; +import { useCreateOrUpdateRepositoryFile } from 'app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile'; + +import { DashboardScene } from '../../scene/DashboardScene'; +import { SaveDashboardDrawer } from '../SaveDashboardDrawer'; + +import { SaveProvisionedDashboardForm, Props } from './SaveProvisionedDashboardForm'; + +jest.mock('@grafana/runtime', () => { + const actual = jest.requireActual('@grafana/runtime'); + return { + ...actual, + getAppEvents: jest.fn(), + locationService: { + partial: jest.fn(), + }, + config: { + ...actual.config, + panels: { + debug: { + state: 'alpha', + }, + }, + }, + }; +}); + +jest.mock('app/core/components/Select/FolderPicker', () => { + const actual = jest.requireActual('app/core/components/Select/FolderPicker'); + return { + ...actual, + FolderPicker: function MockFolderPicker() { + return
Folder Picker
; + }, + }; +}); + +jest.mock('app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile', () => { + return { + useCreateOrUpdateRepositoryFile: jest.fn(), + }; +}); + +jest.mock('app/features/provisioning/hooks/useGetResourceRepository', () => { + return { + useGetResourceRepository: jest.fn(), + }; +}); + +jest.mock('app/features/provisioning/hooks/useRepositoryList', () => { + return { + useRepositoryList: jest.fn(), + }; +}); + +jest.mock('app/features/manage-dashboards/services/ValidationSrv', () => { + const actual = jest.requireActual('app/features/manage-dashboards/services/ValidationSrv'); + return { + ...actual, + validationSrv: { + validateNewDashboardName: jest.fn(), + }, + }; +}); + +jest.mock('react-router-dom-v5-compat', () => { + const actual = jest.requireActual('react-router-dom-v5-compat'); + return { + ...actual, + useNavigate: () => jest.fn(), + }; +}); + +jest.mock('../SaveDashboardForm', () => { + const actual = jest.requireActual('../SaveDashboardForm'); + return { + ...actual, + SaveDashboardFormCommonOptions: () =>
Common Options
, + }; +}); + +function setup(props: Partial = {}) { + const user = userEvent.setup(); + + const mockDashboard: Dashboard = { + title: 'Test Dashboard', + panels: [], + schemaVersion: 36, + }; + + const defaultProps: Props = { + dashboard: { + useState: () => ({ + meta: { folderUid: 'folder-uid', slug: 'test-dashboard' }, + title: 'Test Dashboard', + description: 'Test Description', + isDirty: true, + }), + setState: jest.fn(), + closeModal: jest.fn(), + getSaveAsModel: jest.fn().mockReturnValue(mockDashboard), + setManager: jest.fn(), + } as unknown as DashboardScene, + drawer: { + onClose: jest.fn(), + } as unknown as SaveDashboardDrawer, + changeInfo: { + changedSaveModel: mockDashboard, + initialSaveModel: mockDashboard, + diffCount: 0, + hasChanges: true, + hasTimeChanges: false, + hasVariableValueChanges: false, + hasRefreshChange: false, + diffs: {}, + }, + isNew: true, + isGitHub: true, + defaultValues: { + ref: 'dashboard/2023-01-01-abcde', + path: 'test-dashboard.json', + repo: 'test-repo', + comment: '', + folder: { uid: 'folder-uid', title: '' }, + title: 'Test Dashboard', + description: 'Test Description', + workflow: 'write', + }, + repositoryConfig: { + type: 'github', + workflows: ['write', 'branch'], + sync: { enabled: false, target: 'folder' }, + title: 'Test Repository', + github: { + branch: 'main', + }, + }, + ...props, + }; + + return { + user, + props: defaultProps, + ...render(), + }; +} + +const mockRequestBase = { + isSuccess: true, + isError: false, + isLoading: false, + error: null, + data: { resource: { upsert: {} } }, +}; + +describe('SaveProvisionedDashboardForm', () => { + beforeEach(() => { + jest.clearAllMocks(); + (getAppEvents as jest.Mock).mockReturnValue({ publish: jest.fn() }); + (validationSrv.validateNewDashboardName as jest.Mock).mockResolvedValue(true); + const mockRequest = { ...mockRequestBase, isSuccess: false }; + (useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([jest.fn(), mockRequest]); + }); + + it('should render the form with correct fields for a new dashboard', () => { + setup(); + expect(screen.getByRole('form')).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: /title/i })).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: /description/i })).toBeInTheDocument(); + expect(screen.getByTestId('folder-picker')).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: /path/i })).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: /comment/i })).toBeInTheDocument(); + expect(screen.getByRole('radiogroup')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + + it('should render the form with correct fields for an existing dashboard', () => { + // existing dashboards show "Common Options" instead of the title/desc fields + setup({ isNew: false }); + expect(screen.getByTestId('common-options')).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: /path/i })).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: /comment/i })).toBeInTheDocument(); + expect(screen.getByRole('radiogroup')).toBeInTheDocument(); + expect(screen.queryByRole('textbox', { name: /title/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('textbox', { name: /description/i })).not.toBeInTheDocument(); + }); + + it('should save a new dashboard successfully', async () => { + const { user, props } = setup(); + const newDashboard = { + title: 'New Dashboard', + description: 'New Description', + panels: [], + schemaVersion: 36, + }; + props.dashboard.getSaveAsModel = jest.fn().mockReturnValue(newDashboard); + const mockAction = jest.fn(); + const mockRequest = { ...mockRequestBase, isSuccess: true }; + (useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]); + const titleInput = screen.getByRole('textbox', { name: /title/i }); + const descriptionInput = screen.getByRole('textbox', { name: /description/i }); + const pathInput = screen.getByRole('textbox', { name: /path/i }); + const commentInput = screen.getByRole('textbox', { name: /comment/i }); + + await user.clear(titleInput); + await user.clear(descriptionInput); + await user.clear(pathInput); + await user.clear(commentInput); + + await user.type(titleInput, 'New Dashboard'); + await user.type(descriptionInput, 'New Description'); + await user.type(pathInput, 'test-dashboard.json'); + await user.type(commentInput, 'Initial commit'); + + const submitButton = screen.getByRole('button', { name: /save/i }); + await user.click(submitButton); + + await waitFor(() => { + expect(props.dashboard.setState).toHaveBeenCalledWith({ isDirty: false }); + }); + await waitFor(() => { + expect(mockAction).toHaveBeenCalledWith({ + ref: undefined, + name: 'test-repo', + path: 'test-dashboard.json', + message: 'Initial commit', + body: newDashboard, + }); + }); + const appEvents = getAppEvents(); + expect(appEvents.publish).toHaveBeenCalledWith({ + type: AppEvents.alertSuccess.name, + payload: ['Dashboard changes saved'], + }); + expect(props.dashboard.closeModal).toHaveBeenCalled(); + expect(locationService.partial).toHaveBeenCalledWith({ viewPanel: null, editPanel: null }); + }); + + it('should update an existing dashboard successfully', async () => { + const { user, props } = setup({ + isNew: false, + dashboard: { + useState: () => ({ + meta: { + folderUid: 'folder-uid', + slug: 'test-dashboard', + k8s: { name: 'test-dashboard' }, + }, + title: 'Test Dashboard', + description: 'Test Description', + isDirty: true, + }), + setState: jest.fn(), + closeModal: jest.fn(), + getSaveAsModel: jest.fn().mockReturnValue({ title: 'Test Dashboard', description: 'Test Description' }), + setManager: jest.fn(), + } as unknown as DashboardScene, + }); + const mockAction = jest.fn(); + const mockRequest = { ...mockRequestBase, isSuccess: true }; + (useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]); + const pathInput = screen.getByRole('textbox', { name: /path/i }); + const commentInput = screen.getByRole('textbox', { name: /comment/i }); + await user.clear(pathInput); + await user.clear(commentInput); + await user.type(pathInput, 'test-dashboard.json'); + await user.type(commentInput, 'Update dashboard'); + const submitButton = screen.getByRole('button', { name: /save/i }); + await user.click(submitButton); + await waitFor(() => { + expect(props.dashboard.setState).toHaveBeenCalledWith({ isDirty: false }); + }); + await waitFor(() => { + expect(mockAction).toHaveBeenCalledWith({ + ref: undefined, + name: 'test-repo', + path: 'test-dashboard.json', + message: 'Update dashboard', + body: expect.any(Object), + }); + }); + expect(props.dashboard.closeModal).toHaveBeenCalled(); + expect(locationService.partial).toHaveBeenCalledWith({ viewPanel: null, editPanel: null }); + }); + + it('should show error when save fails', async () => { + const { user, props } = setup(); + const newDashboard = { + title: 'New Dashboard', + description: 'New Description', + panels: [], + schemaVersion: 36, + }; + props.dashboard.getSaveAsModel = jest.fn().mockReturnValue(newDashboard); + const mockAction = jest.fn(); + const mockRequest = { + ...mockRequestBase, + isSuccess: false, + isError: true, + error: 'Failed to save dashboard', + }; + (useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]); + const titleInput = screen.getByRole('textbox', { name: /title/i }); + const descriptionInput = screen.getByRole('textbox', { name: /description/i }); + const pathInput = screen.getByRole('textbox', { name: /path/i }); + const commentInput = screen.getByRole('textbox', { name: /comment/i }); + + await user.clear(titleInput); + await user.clear(descriptionInput); + await user.clear(pathInput); + await user.clear(commentInput); + + await user.type(titleInput, 'New Dashboard'); + await user.type(descriptionInput, 'New Description'); + await user.type(pathInput, 'error-dashboard.json'); + await user.type(commentInput, 'Error commit'); + + const submitButton = screen.getByRole('button', { name: /save/i }); + await user.click(submitButton); + + await waitFor(() => { + expect(mockAction).toHaveBeenCalledWith({ + ref: undefined, + name: 'test-repo', + path: 'error-dashboard.json', + message: 'Error commit', + body: newDashboard, + }); + }); + await waitFor(() => { + const appEvents = getAppEvents(); + expect(appEvents.publish).toHaveBeenCalledWith({ + type: AppEvents.alertError.name, + payload: ['Error saving dashboard', 'Failed to save dashboard'], + }); + }); + }); + + it('should disable save button when dashboard is not dirty', () => { + setup({ + dashboard: { + useState: () => ({ + meta: { + folderUid: 'folder-uid', + slug: 'test-dashboard', + k8s: { name: 'test-dashboard' }, + }, + title: 'Test Dashboard', + description: 'Test Description', + isDirty: false, + }), + setState: jest.fn(), + closeModal: jest.fn(), + getSaveAsModel: jest.fn().mockReturnValue({}), + setManager: jest.fn(), + } as unknown as DashboardScene, + }); + + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); + }); + + it('should show read-only alert when repository has no workflows', () => { + setup({ + repositoryConfig: { + type: 'github', + workflows: [], + sync: { enabled: false, target: 'folder' }, + title: 'Read-only Repository', + }, + }); + + expect(screen.getByText('This repository is read only')).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx new file mode 100644 index 00000000000..8cf33921f95 --- /dev/null +++ b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx @@ -0,0 +1,347 @@ +import { useEffect } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom-v5-compat'; + +import { AppEvents, locationUtil } from '@grafana/data'; +import { getAppEvents, locationService } from '@grafana/runtime'; +import { Dashboard } from '@grafana/schema'; +import { Alert, Button, Field, Input, RadioButtonGroup, Stack, TextArea } from '@grafana/ui'; +import { RepositorySpec, RepositoryView } from 'app/api/clients/provisioning'; +import { FolderPicker } from 'app/core/components/Select/FolderPicker'; +import { t, Trans } from 'app/core/internationalization'; +import kbn from 'app/core/utils/kbn'; +import { AnnoKeyManagerIdentity, AnnoKeyManagerKind, ManagerKind, Resource } from 'app/features/apiserver/types'; +import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv'; +import { PROVISIONING_URL } from 'app/features/provisioning/constants'; +import { useCreateOrUpdateRepositoryFile } from 'app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile'; +import { WorkflowOption } from 'app/features/provisioning/types'; +import { validateBranchName } from 'app/features/provisioning/utils/git'; + +import { getDashboardUrl } from '../../utils/getDashboardUrl'; +import { SaveDashboardFormCommonOptions } from '../SaveDashboardForm'; + +import { SaveProvisionedDashboardProps } from './SaveProvisionedDashboard'; +import { getWorkflowOptions } from './defaults'; + +type FormData = { + ref?: string; + path: string; + comment?: string; + repo: string; + workflow?: WorkflowOption; + title: string; + description: string; + folder: { + uid?: string; + title?: string; + }; +}; + +export interface Props extends SaveProvisionedDashboardProps { + isNew: boolean; + defaultValues: FormData; + isGitHub: boolean; + repositoryConfig?: RepositorySpec; + loadedFromRef?: string; +} + +export function SaveProvisionedDashboardForm({ + defaultValues, + dashboard, + drawer, + changeInfo, + isNew, + loadedFromRef, + repositoryConfig, + isGitHub, +}: Props) { + const navigate = useNavigate(); + const appEvents = getAppEvents(); + const { meta, isDirty } = dashboard.useState(); + + const [createOrUpdateFile, request] = useCreateOrUpdateRepositoryFile(isNew ? undefined : defaultValues.path); + + const { + register, + handleSubmit, + watch, + formState: { errors }, + control, + setValue, + } = useForm({ defaultValues }); + + const [ref, workflow, path] = watch(['ref', 'workflow', 'path']); + + useEffect(() => { + if (request.isSuccess) { + dashboard.setState({ isDirty: false }); + if (workflow === 'branch' && ref !== '' && path !== '') { + // Redirect to the provisioning preview pages + navigate(`${PROVISIONING_URL}/${defaultValues.repo}/dashboard/preview/${path}?ref=${ref}`); + return; + } + + appEvents.publish({ + type: AppEvents.alertSuccess.name, + payload: [t('dashboard-scene.save-provisioned-dashboard-form.api-success', 'Dashboard changes saved')], + }); + + // Load the new URL + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const upsert = request.data.resource.upsert as Resource; + if (isNew && upsert?.metadata?.name) { + const url = locationUtil.assureBaseUrl( + getDashboardUrl({ + uid: upsert.metadata.name, + slug: kbn.slugifyForUrl(upsert.spec.title ?? ''), + currentQueryParams: window.location.search, + }) + ); + + navigate(url); + return; + } + + // Keep the same URL + dashboard.closeModal(); + locationService.partial({ + viewPanel: null, + editPanel: null, + }); + } else if (request.isError) { + appEvents.publish({ + type: AppEvents.alertError.name, + payload: [ + t('dashboard-scene.save-provisioned-dashboard-form.api-error', 'Error saving dashboard'), + request.error, + ], + }); + } + }, [appEvents, dashboard, defaultValues.repo, isNew, navigate, path, ref, request, workflow]); + + // Submit handler for saving the form data + const handleFormSubmit = async ({ title, description, repo, path, comment, ref }: FormData) => { + if (!repo || !path) { + return; + } + + // The dashboard spec + const saveModel = dashboard.getSaveAsModel({ + isNew, + title, + description, + }); + + // If user is writing to the original branch, override ref with whatever we loaded from + if (workflow === 'write') { + ref = loadedFromRef; + } + + createOrUpdateFile({ + ref, + name: repo, + path, + message: comment, + body: { ...saveModel, uid: meta.uid }, + }); + }; + + const workflowOptions = getWorkflowOptions(repositoryConfig, loadedFromRef); + + return ( +
+ + {!repositoryConfig?.workflows.length && ( + + + If you have direct access to the target, copy the JSON and paste it there. + + + )} + + {isNew && ( + <> + + + + +