mirror of https://github.com/grafana/grafana
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 kindpull/102303/head
parent
b11daf57bb
commit
71cee10cb6
@ -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 ( |
||||
<Modal |
||||
isOpen={true} |
||||
title={t( |
||||
'dashboard-scene.provisioned-resource-delete-modal.title-cannot-delete-provisioned-resource', |
||||
'Cannot delete provisioned resource' |
||||
)} |
||||
onDismiss={onDismiss} |
||||
> |
||||
<> |
||||
<p> |
||||
<Trans |
||||
i18nKey="dashboard-scene.provisioned-resource-delete-modal.managed-by-version-control" |
||||
values={{ type }} |
||||
> |
||||
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. |
||||
</Trans> |
||||
</p> |
||||
{isDashboard(resource) && ( |
||||
<p> |
||||
<Trans i18nKey="dashboard-scene.provisioned-resource-delete-modal.file-path">File path:</Trans>{' '} |
||||
{resource.getPath()} |
||||
</p> |
||||
)} |
||||
</> |
||||
|
||||
<Modal.ButtonRow> |
||||
<Button variant="primary" onClick={onDismiss}> |
||||
<Trans i18nKey="dashboard-scene.provisioned-resource-delete-modal.ok">OK</Trans> |
||||
</Button> |
||||
</Modal.ButtonRow> |
||||
</Modal> |
||||
); |
||||
} |
||||
|
||||
function isDashboard(resource: DashboardScene | FolderDataType): resource is DashboardScene { |
||||
return resource instanceof DashboardScene; |
||||
} |
||||
|
||||
export function isFolder(resource: DashboardScene | FolderDataType): resource is FolderDataType { |
||||
return !isDashboard(resource); |
||||
} |
@ -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 ( |
||||
<SaveProvisionedDashboardForm |
||||
dashboard={dashboard} |
||||
drawer={drawer} |
||||
changeInfo={changeInfo} |
||||
isNew={isNew} |
||||
defaultValues={values} |
||||
loadedFromRef={loadedFromRef} |
||||
isGitHub={isGitHub} |
||||
repositoryConfig={repositoryConfig} |
||||
/> |
||||
); |
||||
} |
@ -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 <div data-testid="folder-picker">Folder Picker</div>; |
||||
}, |
||||
}; |
||||
}); |
||||
|
||||
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: () => <div data-testid="common-options">Common Options</div>, |
||||
}; |
||||
}); |
||||
|
||||
function setup(props: Partial<Props> = {}) { |
||||
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(<SaveProvisionedDashboardForm {...defaultProps} />), |
||||
}; |
||||
} |
||||
|
||||
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(); |
||||
}); |
||||
}); |
@ -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<FormData>({ 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<Dashboard>; |
||||
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 ( |
||||
<form onSubmit={handleSubmit(handleFormSubmit)} name="save-provisioned-form"> |
||||
<Stack direction="column" gap={2}> |
||||
{!repositoryConfig?.workflows.length && ( |
||||
<Alert |
||||
title={t( |
||||
'dashboard-scene.save-provisioned-dashboard-form.title-this-repository-is-read-only', |
||||
'This repository is read only' |
||||
)} |
||||
> |
||||
<Trans i18nKey="dashboard-scene.save-provisioned-dashboard-form.copy-json-message"> |
||||
If you have direct access to the target, copy the JSON and paste it there. |
||||
</Trans> |
||||
</Alert> |
||||
)} |
||||
|
||||
{isNew && ( |
||||
<> |
||||
<Field |
||||
label={t('dashboard-scene.save-provisioned-dashboard-form.label-title', 'Title')} |
||||
invalid={!!errors.title} |
||||
error={errors.title?.message} |
||||
> |
||||
<Input |
||||
id="dashboard-title" |
||||
{...register('title', { |
||||
required: t( |
||||
'dashboard-scene.save-provisioned-dashboard-form.title-required', |
||||
'Dashboard title is required' |
||||
), |
||||
validate: validateTitle, |
||||
})} |
||||
/> |
||||
</Field> |
||||
<Field |
||||
label={t('dashboard-scene.save-provisioned-dashboard-form.label-description', 'Description')} |
||||
invalid={!!errors.description} |
||||
error={errors.description?.message} |
||||
> |
||||
<TextArea id="dashboard-description" {...register('description')} /> |
||||
</Field> |
||||
|
||||
<Field label={t('dashboard-scene.save-provisioned-dashboard-form.label-target-folder', 'Target folder')}> |
||||
<Controller |
||||
control={control} |
||||
name={'folder'} |
||||
render={({ field: { ref, value, onChange, ...field } }) => { |
||||
return ( |
||||
<FolderPicker |
||||
inputId="dashboard-folder" |
||||
onChange={(uid?: string, title?: string, repository?: RepositoryView) => { |
||||
onChange({ uid, title }); |
||||
const name = repository?.name; |
||||
if (name) { |
||||
setValue('repo', name); |
||||
} |
||||
dashboard.setState({ |
||||
meta: { |
||||
k8s: name |
||||
? { |
||||
annotations: { |
||||
[AnnoKeyManagerIdentity]: name, |
||||
[AnnoKeyManagerKind]: ManagerKind.Repo, |
||||
}, |
||||
} |
||||
: undefined, |
||||
folderUid: uid, |
||||
}, |
||||
}); |
||||
}} |
||||
value={value.uid} |
||||
{...field} |
||||
/> |
||||
); |
||||
}} |
||||
/> |
||||
</Field> |
||||
</> |
||||
)} |
||||
|
||||
{!isNew && <SaveDashboardFormCommonOptions drawer={drawer} changeInfo={changeInfo} />} |
||||
|
||||
<Field |
||||
label={t('dashboard-scene.save-provisioned-dashboard-form.label-path', 'Path')} |
||||
description={t( |
||||
'dashboard-scene.save-provisioned-dashboard-form.description-inside-repository', |
||||
'File path inside the repository (.json or .yaml)' |
||||
)} |
||||
> |
||||
<Input id="dashboard-path" {...register('path')} /> |
||||
</Field> |
||||
|
||||
<Field label={t('dashboard-scene.save-provisioned-dashboard-form.label-comment', 'Comment')}> |
||||
<TextArea |
||||
id="dashboard-comment" |
||||
{...register('comment')} |
||||
placeholder={t( |
||||
'dashboard-scene.save-provisioned-dashboard-form.dashboard-comment-placeholder-describe-changes-optional', |
||||
'Add a note to describe your changes (optional)' |
||||
)} |
||||
rows={5} |
||||
/> |
||||
</Field> |
||||
|
||||
{isGitHub && ( |
||||
<> |
||||
<Field label={t('dashboard-scene.save-provisioned-dashboard-form.label-workflow', 'Workflow')}> |
||||
<Controller |
||||
control={control} |
||||
name="workflow" |
||||
render={({ field: { ref: _, ...field } }) => ( |
||||
<RadioButtonGroup id="dashboard-workflow" {...field} options={workflowOptions} /> |
||||
)} |
||||
/> |
||||
</Field> |
||||
{workflow === 'branch' && ( |
||||
<Field |
||||
label={t('dashboard-scene.save-provisioned-dashboard-form.label-branch', 'Branch')} |
||||
description={t( |
||||
'dashboard-scene.save-provisioned-dashboard-form.description-branch-name-in-git-hub', |
||||
'Branch name in GitHub' |
||||
)} |
||||
invalid={!!errors.ref} |
||||
error={errors.ref && <BranchValidationError />} |
||||
> |
||||
<Input id="dashboard-branch" {...register('ref', { validate: validateBranchName })} /> |
||||
</Field> |
||||
)} |
||||
</> |
||||
)} |
||||
|
||||
<Stack gap={2}> |
||||
<Button variant="primary" type="submit" disabled={request.isLoading || !isDirty}> |
||||
{request.isLoading |
||||
? t('dashboard-scene.save-provisioned-dashboard-form.saving', 'Saving...') |
||||
: t('dashboard-scene.save-provisioned-dashboard-form.save', 'Save')} |
||||
</Button> |
||||
<Button variant="secondary" onClick={drawer.onClose} fill="outline"> |
||||
<Trans i18nKey="dashboard-scene.save-provisioned-dashboard-form.cancel">Cancel</Trans> |
||||
</Button> |
||||
</Stack> |
||||
</Stack> |
||||
</form> |
||||
); |
||||
} |
||||
|
||||
const BranchValidationError = () => ( |
||||
<> |
||||
<Trans i18nKey="dashboard-scene.branch-validation-error.invalid-branch-name">Invalid branch name.</Trans> |
||||
<ul style={{ padding: '0 20px' }}> |
||||
<li> |
||||
<Trans i18nKey="dashboard-scene.branch-validation-error.cannot-start-with"> |
||||
It cannot start with '/' or end with '/', '.', or whitespace. |
||||
</Trans> |
||||
</li> |
||||
<li> |
||||
<Trans i18nKey="dashboard-scene.branch-validation-error.it-cannot-contain-or"> |
||||
It cannot contain '//' or '..'. |
||||
</Trans> |
||||
</li> |
||||
<li> |
||||
<Trans i18nKey="dashboard-scene.branch-validation-error.cannot-contain-invalid-characters"> |
||||
It cannot contain invalid characters: '~', '^', ':', '?', '*', '[', '\\', or ']'. |
||||
</Trans> |
||||
</li> |
||||
<li> |
||||
<Trans i18nKey="dashboard-scene.branch-validation-error.least-valid-character"> |
||||
It must have at least one valid character. |
||||
</Trans> |
||||
</li> |
||||
</ul> |
||||
</> |
||||
); |
||||
|
||||
/** |
||||
* Dashboard title validation to ensure it's not the same as the folder name |
||||
* and meets other naming requirements. |
||||
*/ |
||||
async function validateTitle(title: string, formValues: FormData) { |
||||
if (title === formValues.folder.title?.trim()) { |
||||
return t( |
||||
'dashboard-scene.save-provisioned-dashboard-form.title-same-as-folder', |
||||
'Dashboard name cannot be the same as the folder name' |
||||
); |
||||
} |
||||
try { |
||||
await validationSrv.validateNewDashboardName(formValues.folder.uid ?? 'general', title); |
||||
return true; |
||||
} catch (error) { |
||||
return error instanceof Error |
||||
? error.message |
||||
: t( |
||||
'dashboard-scene.save-provisioned-dashboard-form.title-validation-failed', |
||||
'Dashboard title validation failed.' |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,25 @@ |
||||
import { RepositorySpec } from 'app/api/clients/provisioning'; |
||||
import { WorkflowOption } from 'app/features/provisioning/types'; |
||||
|
||||
export function getDefaultWorkflow(config?: RepositorySpec) { |
||||
return config?.workflows?.[0]; |
||||
} |
||||
|
||||
export function getWorkflowOptions(config?: RepositorySpec, ref?: string) { |
||||
if (!config) { |
||||
return []; |
||||
} |
||||
|
||||
if (config.local?.path) { |
||||
return [{ label: `Write to ${config.local.path}`, value: 'write' }]; |
||||
} |
||||
|
||||
let branch = ref ?? config.github?.branch; |
||||
const availableOptions: Array<{ label: string; value: WorkflowOption }> = [ |
||||
{ label: `Push to ${branch ?? 'main'}`, value: 'write' }, |
||||
{ label: 'Push to different branch', value: 'branch' }, |
||||
]; |
||||
|
||||
// Filter options based on the workflows in the config
|
||||
return availableOptions.filter((option) => config.workflows?.includes(option.value)); |
||||
} |
@ -0,0 +1,91 @@ |
||||
import { skipToken } from '@reduxjs/toolkit/query/react'; |
||||
|
||||
import { useGetFolderQuery } from 'app/api/clients/folder'; |
||||
import { AnnoKeyManagerIdentity, AnnoKeyManagerKind, AnnoKeySourcePath } from 'app/features/apiserver/types'; |
||||
import { useGetResourceRepository } from 'app/features/provisioning/hooks/useGetResourceRepository'; |
||||
import { useRepositoryList } from 'app/features/provisioning/hooks/useRepositoryList'; |
||||
import { DashboardMeta } from 'app/types'; |
||||
|
||||
import { getDefaultWorkflow } from './defaults'; |
||||
import { generatePath } from './utils/path'; |
||||
import { generateTimestamp } from './utils/timestamp'; |
||||
|
||||
interface UseDefaultValuesParams { |
||||
meta: DashboardMeta; |
||||
defaultTitle: string; |
||||
defaultDescription?: string; |
||||
} |
||||
|
||||
export function useDefaultValues({ meta, defaultTitle, defaultDescription }: UseDefaultValuesParams) { |
||||
const annotations = meta.k8s?.annotations; |
||||
const managerKind = annotations?.[AnnoKeyManagerKind]; |
||||
const managerIdentity = annotations?.[AnnoKeyManagerIdentity]; |
||||
const sourcePath = annotations?.[AnnoKeySourcePath]; |
||||
const repositoryConfig = useConfig({ folderUid: meta.folderUid, managerKind, managerIdentity }); |
||||
const repository = repositoryConfig?.spec; |
||||
const timestamp = generateTimestamp(); |
||||
|
||||
// Get folder data to retrieve the folder path
|
||||
const folderQuery = useGetFolderQuery(meta.folderUid ? { name: meta.folderUid } : skipToken); |
||||
|
||||
const folderPath = meta.folderUid ? (folderQuery.data?.metadata?.annotations?.[AnnoKeySourcePath] ?? '') : ''; |
||||
|
||||
const dashboardPath = generatePath({ |
||||
timestamp, |
||||
pathFromAnnotation: sourcePath, |
||||
slug: meta.slug, |
||||
folderPath, |
||||
}); |
||||
|
||||
if (folderQuery.isLoading || !repositoryConfig) { |
||||
return null; |
||||
} |
||||
|
||||
return { |
||||
values: { |
||||
ref: `dashboard/${timestamp}`, |
||||
path: dashboardPath, |
||||
repo: managerIdentity || repositoryConfig?.metadata?.name || '', |
||||
comment: '', |
||||
folder: { |
||||
uid: meta.folderUid, |
||||
title: '', |
||||
}, |
||||
title: defaultTitle, |
||||
description: defaultDescription ?? '', |
||||
workflow: getDefaultWorkflow(repository), |
||||
}, |
||||
isNew: !meta.k8s?.name, |
||||
repositoryConfig: repository, |
||||
isGitHub: repository?.type === 'github', |
||||
}; |
||||
} |
||||
|
||||
type UseConfigArgs = { |
||||
folderUid?: string; |
||||
managerKind?: string; |
||||
managerIdentity?: string; |
||||
}; |
||||
const useConfig = ({ folderUid, managerKind, managerIdentity }: UseConfigArgs) => { |
||||
const repositoryConfig = useGetResourceRepository({ |
||||
name: managerKind === 'repo' ? managerIdentity : undefined, |
||||
folderUid, |
||||
}); |
||||
|
||||
const [items, isLoading] = useRepositoryList(repositoryConfig ? skipToken : undefined); |
||||
|
||||
if (repositoryConfig) { |
||||
return repositoryConfig; |
||||
} |
||||
|
||||
if (isLoading) { |
||||
return null; |
||||
} |
||||
const instanceConfig = items?.find((repo) => repo.spec?.sync.target === 'instance'); |
||||
if (instanceConfig) { |
||||
return instanceConfig; |
||||
} |
||||
|
||||
// Return the config, which targets the folder
|
||||
return items?.find((repo) => repo?.metadata?.name === folderUid); |
||||
}; |
@ -0,0 +1,71 @@ |
||||
import { generatePath } from './path'; |
||||
|
||||
describe('generatePath', () => { |
||||
const timestamp = '2023-05-15-abcde'; |
||||
|
||||
it('should generate path using slug when pathFromAnnotation is not provided', () => { |
||||
const result = generatePath({ |
||||
timestamp, |
||||
slug: 'my-dashboard', |
||||
}); |
||||
|
||||
expect(result).toBe('my-dashboard.json'); |
||||
}); |
||||
|
||||
it('should use default slug with timestamp when neither pathFromAnnotation nor slug is provided', () => { |
||||
const result = generatePath({ |
||||
timestamp, |
||||
}); |
||||
|
||||
expect(result).toBe('new-dashboard-2023-05-15-abcde.json'); |
||||
}); |
||||
|
||||
it('should use pathFromAnnotation when provided', () => { |
||||
const result = generatePath({ |
||||
timestamp, |
||||
pathFromAnnotation: 'dashboards/my-custom-path.json', |
||||
slug: 'my-dashboard', // This should be ignored when pathFromAnnotation is provided
|
||||
}); |
||||
|
||||
expect(result).toBe('dashboards/my-custom-path.json'); |
||||
}); |
||||
|
||||
it('should remove hash from pathFromAnnotation', () => { |
||||
const result = generatePath({ |
||||
timestamp, |
||||
pathFromAnnotation: 'dashboards/my-custom-path.json#some-hash', |
||||
}); |
||||
|
||||
expect(result).toBe('dashboards/my-custom-path.json'); |
||||
}); |
||||
|
||||
it('should prepend folderPath when provided', () => { |
||||
const result = generatePath({ |
||||
timestamp, |
||||
slug: 'my-dashboard', |
||||
folderPath: 'folder/path', |
||||
}); |
||||
|
||||
expect(result).toBe('folder/path/my-dashboard.json'); |
||||
}); |
||||
|
||||
it('should prepend folderPath to pathFromAnnotation when both are provided', () => { |
||||
const result = generatePath({ |
||||
timestamp, |
||||
pathFromAnnotation: 'my-custom-path.json', |
||||
folderPath: 'folder/path', |
||||
}); |
||||
|
||||
expect(result).toBe('folder/path/my-custom-path.json'); |
||||
}); |
||||
|
||||
it('should handle empty folderPath', () => { |
||||
const result = generatePath({ |
||||
timestamp, |
||||
slug: 'my-dashboard', |
||||
folderPath: '', |
||||
}); |
||||
|
||||
expect(result).toBe('my-dashboard.json'); |
||||
}); |
||||
}); |
@ -0,0 +1,34 @@ |
||||
/** |
||||
* Parameters for generating a dashboard path |
||||
*/ |
||||
export interface GeneratePathParams { |
||||
timestamp: string; |
||||
pathFromAnnotation?: string; |
||||
slug?: string; |
||||
folderPath?: string; |
||||
} |
||||
|
||||
/** |
||||
* Generates a path for a dashboard based on provided parameters |
||||
* If pathFromAnnotation is provided, it will be used as the base path |
||||
* Otherwise, a path will be generated using the slug or a default name with timestamp |
||||
* If folderPath is provided, it will be prepended to the path |
||||
*/ |
||||
export function generatePath({ timestamp, pathFromAnnotation, slug, folderPath = '' }: GeneratePathParams): string { |
||||
let path = ''; |
||||
|
||||
if (pathFromAnnotation) { |
||||
const hashIndex = pathFromAnnotation.indexOf('#'); |
||||
path = hashIndex > 0 ? pathFromAnnotation.substring(0, hashIndex) : pathFromAnnotation; |
||||
} else { |
||||
const pathSlug = slug || `new-dashboard-${timestamp}`; |
||||
path = `${pathSlug}.json`; |
||||
} |
||||
|
||||
// Add folder path if it exists
|
||||
if (folderPath) { |
||||
return `${folderPath}/${path}`; |
||||
} |
||||
|
||||
return path; |
||||
} |
@ -0,0 +1,26 @@ |
||||
import { generateTimestamp } from './timestamp'; |
||||
|
||||
describe('generateTimestamp', () => { |
||||
it('should generate a timestamp in the expected format', () => { |
||||
const timestamp = generateTimestamp(); |
||||
|
||||
// Check that the timestamp is a string
|
||||
expect(typeof timestamp).toBe('string'); |
||||
|
||||
// Check that the timestamp follows the format YYYY-MM-DD-xxxxx
|
||||
// where xxxxx is a random string of 5 alphabetic characters
|
||||
expect(timestamp).toMatch(/^\d{4}-\d{2}-\d{2}-[a-zA-Z]{5}$/); |
||||
}); |
||||
|
||||
it('should generate unique timestamps', () => { |
||||
// Generate multiple timestamps and check that they're different
|
||||
const timestamp1 = generateTimestamp(); |
||||
const timestamp2 = generateTimestamp(); |
||||
const timestamp3 = generateTimestamp(); |
||||
|
||||
// The date part might be the same, but the random part should make them different
|
||||
expect(timestamp1).not.toBe(timestamp2); |
||||
expect(timestamp1).not.toBe(timestamp3); |
||||
expect(timestamp2).not.toBe(timestamp3); |
||||
}); |
||||
}); |
@ -0,0 +1,11 @@ |
||||
import { Chance } from 'chance'; |
||||
|
||||
import { dateTime } from '@grafana/data'; |
||||
|
||||
/** |
||||
* Generates a timestamp string in the format YYYY-MM-DD-xxxxx where xxxxx is a random string |
||||
*/ |
||||
export function generateTimestamp(): string { |
||||
const random = new Chance(); |
||||
return `${dateTime().format('YYYY-MM-DD')}-${random.string({ length: 5, alpha: true })}`; |
||||
} |
Loading…
Reference in new issue