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