mirror of
https://github.com/grafana/grafana.git
synced 2025-07-30 02:52:27 +08:00
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
This commit is contained in:
@ -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;
|
||||
|
@ -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) {
|
||||
|
||||
<Field label="Folder">
|
||||
<FolderPicker
|
||||
onChange={(uid: string | undefined, title: string | undefined) => {
|
||||
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}
|
||||
|
@ -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<DashboardScene>;
|
||||
@ -20,7 +22,13 @@ interface SaveDashboardDrawerState extends SceneObjectState {
|
||||
|
||||
export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerState> {
|
||||
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<SaveDashboardDrawerStat
|
||||
const dashboard = model.state.dashboardRef.resolve();
|
||||
const { meta } = dashboard.useState();
|
||||
const { provisioned: isProvisioned, folderTitle } = meta;
|
||||
const isProvisionedNG = useIsProvisionedNG(dashboard);
|
||||
|
||||
const tabs = (
|
||||
<TabsBar>
|
||||
@ -65,7 +74,7 @@ export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerStat
|
||||
let title = 'Save dashboard';
|
||||
if (saveAsCopy) {
|
||||
title = 'Save dashboard copy';
|
||||
} else if (isProvisioned) {
|
||||
} else if (isProvisioned || isProvisionedNG) {
|
||||
title = 'Provisioned dashboard';
|
||||
}
|
||||
|
||||
@ -84,6 +93,10 @@ export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerStat
|
||||
);
|
||||
}
|
||||
|
||||
if (isProvisionedNG) {
|
||||
return <SaveProvisionedDashboard dashboard={dashboard} changeInfo={changeInfo} drawer={model} />;
|
||||
}
|
||||
|
||||
if (saveAsCopy || changeInfo.isNew) {
|
||||
return <SaveDashboardAsForm dashboard={dashboard} changeInfo={changeInfo} />;
|
||||
}
|
||||
|
@ -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 })}`;
|
||||
}
|
@ -25,8 +25,10 @@ import { contextSrv } from 'app/core/core';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
|
||||
import { useSelector } from 'app/types';
|
||||
|
||||
import { shareDashboardType } from '../../dashboard/components/ShareModal/utils';
|
||||
import { selectFolderRepository } from '../../provisioning/utils/selectors';
|
||||
import { PanelEditor, buildPanelEditScene } from '../panel-edit/PanelEditor';
|
||||
import ExportButton from '../sharing/ExportButton/ExportButton';
|
||||
import ShareButton from '../sharing/ShareButton/ShareButton';
|
||||
@ -76,7 +78,8 @@ export function ToolbarActions({ dashboard }: Props) {
|
||||
const isShowingDashboard = !editview && !isViewingPanel && !isEditingPanel;
|
||||
const isEditingAndShowingDashboard = isEditing && isShowingDashboard;
|
||||
const dashboardNewLayouts = config.featureToggles.dashboardNewLayouts;
|
||||
const isManaged = Boolean(dashboard.isManaged());
|
||||
const folderRepo = useSelector((state) => selectFolderRepository(state, meta.folderUid));
|
||||
const isManaged = Boolean(dashboard.isManaged() || folderRepo);
|
||||
|
||||
if (!isEditingPanel) {
|
||||
// This adds the presence indicators in enterprise
|
||||
@ -130,7 +133,7 @@ export function ToolbarActions({ dashboard }: Props) {
|
||||
group: 'icon-actions',
|
||||
condition: true,
|
||||
render: () => {
|
||||
return <ManagedDashboardNavBarBadge meta={meta} />;
|
||||
return <ManagedDashboardNavBarBadge meta={meta} key="managed-dashboard-badge" />;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import { Button, ConfirmModal, Modal, Space, Text } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
|
||||
import { useDeleteItemsMutation } from '../../browse-dashboards/api/browseDashboardsAPI';
|
||||
import { ProvisionedResourceDeleteModal } from '../saving/provisioned/ProvisionedResourceDeleteModal';
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
|
||||
interface ButtonProps {
|
||||
@ -53,6 +54,10 @@ export function DeleteDashboardButton({ dashboard }: ButtonProps) {
|
||||
return <ProvisionedDeleteModal dashboardId={dashboard.state.meta.provisionedExternalId} onClose={toggleModal} />;
|
||||
}
|
||||
|
||||
if (dashboard.isManagedRepository() && showModal) {
|
||||
return <ProvisionedResourceDeleteModal resource={dashboard} onDismiss={toggleModal} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
|
@ -20,6 +20,7 @@ export type DashboardPageRouteSearchParams = {
|
||||
kiosk?: string | true;
|
||||
scenes?: boolean;
|
||||
shareView?: string;
|
||||
ref?: string; // used for repo preview
|
||||
};
|
||||
|
||||
export type PublicDashboardPageRouteParams = {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { skipToken } from '@reduxjs/toolkit/query/react';
|
||||
|
||||
import { useGetFolderQuery } from '../../../api/clients/folder';
|
||||
import { AnnoKeyManagerKind } from '../../apiserver/types';
|
||||
import { AnnoKeyManagerIdentity } from '../../apiserver/types';
|
||||
|
||||
import { useRepositoryList } from './useRepositoryList';
|
||||
|
||||
@ -15,7 +15,7 @@ export const useGetResourceRepository = ({ name, folderUid }: GetResourceReposit
|
||||
// Get the folder data from API to get the repository data for nested folders
|
||||
const folderQuery = useGetFolderQuery(name || !folderUid ? skipToken : { name: folderUid });
|
||||
|
||||
const repoName = name || folderQuery.data?.metadata?.annotations?.[AnnoKeyManagerKind];
|
||||
const repoName = name || folderQuery.data?.metadata?.annotations?.[AnnoKeyManagerIdentity];
|
||||
|
||||
if (!items?.length || isLoading || !repoName) {
|
||||
return undefined;
|
||||
|
@ -2,8 +2,7 @@ import { DataQuery } from '@grafana/data';
|
||||
import { Dashboard, DataSourceRef } from '@grafana/schema';
|
||||
import { ObjectMeta } from 'app/features/apiserver/types';
|
||||
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
||||
|
||||
import { ProvisioningPreview } from '../features/provisioning/types';
|
||||
import { ProvisioningPreview } from 'app/features/provisioning/types';
|
||||
|
||||
export interface HomeDashboardRedirectDTO {
|
||||
redirectUri: string;
|
||||
|
@ -1413,6 +1413,43 @@
|
||||
"title": "There are no dashboard links added yet"
|
||||
}
|
||||
},
|
||||
"dashboard-scene": {
|
||||
"branch-validation-error": {
|
||||
"cannot-contain-invalid-characters": "It cannot contain invalid characters: '~', '^', ':', '?', '*', '[', '\\\\', or ']'.",
|
||||
"cannot-start-with": "It cannot start with '/' or end with '/', '.', or whitespace.",
|
||||
"invalid-branch-name": "Invalid branch name.",
|
||||
"it-cannot-contain-or": "It cannot contain '//' or '..'.",
|
||||
"least-valid-character": "It must have at least one valid character."
|
||||
},
|
||||
"provisioned-resource-delete-modal": {
|
||||
"file-path": "File path:",
|
||||
"managed-by-version-control": "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.",
|
||||
"ok": "OK",
|
||||
"title-cannot-delete-provisioned-resource": "Cannot delete provisioned resource"
|
||||
},
|
||||
"save-provisioned-dashboard-form": {
|
||||
"api-error": "Error saving dashboard",
|
||||
"api-success": "Dashboard changes saved",
|
||||
"cancel": "Cancel",
|
||||
"copy-json-message": "If you have direct access to the target, copy the JSON and paste it there.",
|
||||
"dashboard-comment-placeholder-describe-changes-optional": "Add a note to describe your changes (optional)",
|
||||
"description-branch-name-in-git-hub": "Branch name in GitHub",
|
||||
"description-inside-repository": "File path inside the repository (.json or .yaml)",
|
||||
"label-branch": "Branch",
|
||||
"label-comment": "Comment",
|
||||
"label-description": "Description",
|
||||
"label-path": "Path",
|
||||
"label-target-folder": "Target folder",
|
||||
"label-title": "Title",
|
||||
"label-workflow": "Workflow",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"title-required": "Dashboard title is required",
|
||||
"title-same-as-folder": "Dashboard name cannot be the same as the folder name",
|
||||
"title-this-repository-is-read-only": "This repository is read only",
|
||||
"title-validation-failed": "Dashboard title validation failed."
|
||||
}
|
||||
},
|
||||
"dashboard-settings": {
|
||||
"annotations": {
|
||||
"title": "Annotations"
|
||||
|
Reference in New Issue
Block a user