mirror of
https://github.com/grafana/grafana.git
synced 2025-07-31 11:52:09 +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) {
|
export function DashboardScenePage({ route, queryParams, location }: Props) {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const { type, slug, uid } = params;
|
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 prevMatch = usePrevious({ params });
|
||||||
const stateManager = getDashboardScenePageStateManager();
|
const stateManager = getDashboardScenePageStateManager();
|
||||||
const { dashboard, isLoading, loadError } = stateManager.useState();
|
const { dashboard, isLoading, loadError } = stateManager.useState();
|
||||||
@ -34,9 +36,9 @@ export function DashboardScenePage({ route, queryParams, location }: Props) {
|
|||||||
stateManager.loadSnapshot(slug!);
|
stateManager.loadSnapshot(slug!);
|
||||||
} else {
|
} else {
|
||||||
stateManager.loadDashboard({
|
stateManager.loadDashboard({
|
||||||
|
uid: (route.routeName === DashboardRoutes.Provisioning ? path : uid) ?? '',
|
||||||
type,
|
type,
|
||||||
slug,
|
slug,
|
||||||
uid: uid ?? '',
|
|
||||||
route: route.routeName as DashboardRoutes,
|
route: route.routeName as DashboardRoutes,
|
||||||
urlFolderUid: queryParams.folderUid,
|
urlFolderUid: queryParams.folderUid,
|
||||||
});
|
});
|
||||||
@ -45,7 +47,7 @@ export function DashboardScenePage({ route, queryParams, location }: Props) {
|
|||||||
return () => {
|
return () => {
|
||||||
stateManager.clearState();
|
stateManager.clearState();
|
||||||
};
|
};
|
||||||
}, [stateManager, uid, route.routeName, queryParams.folderUid, routeReloadCounter, slug, type]);
|
}, [stateManager, uid, route.routeName, queryParams.folderUid, routeReloadCounter, slug, type, path]);
|
||||||
|
|
||||||
if (!dashboard) {
|
if (!dashboard) {
|
||||||
let errorElement;
|
let errorElement;
|
||||||
|
@ -4,9 +4,11 @@ import { UseFormSetValue, useForm } from 'react-hook-form';
|
|||||||
|
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { Button, Input, Switch, Field, Label, TextArea, Stack, Alert, Box } from '@grafana/ui';
|
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 { FolderPicker } from 'app/core/components/Select/FolderPicker';
|
||||||
import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
|
import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
|
||||||
|
|
||||||
|
import { AnnoKeyManagerIdentity, AnnoKeyManagerKind, ManagerKind } from '../../apiserver/types';
|
||||||
import { DashboardScene } from '../scene/DashboardScene';
|
import { DashboardScene } from '../scene/DashboardScene';
|
||||||
|
|
||||||
import { DashboardChangeInfo, NameAlreadyExistsError, SaveButton, isNameExistsError } from './shared';
|
import { DashboardChangeInfo, NameAlreadyExistsError, SaveButton, isNameExistsError } from './shared';
|
||||||
@ -131,10 +133,20 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
|
|||||||
|
|
||||||
<Field label="Folder">
|
<Field label="Folder">
|
||||||
<FolderPicker
|
<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 });
|
setValue('folder', { uid, title });
|
||||||
const folderUid = dashboard.state.meta.folderUid;
|
const folderUid = dashboard.state.meta.folderUid;
|
||||||
setHasFolderChanged(uid !== 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
|
// Old folder picker fields
|
||||||
value={formValues.folder?.uid}
|
value={formValues.folder?.uid}
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
import { SceneComponentProps, SceneObjectBase, SceneObjectState, SceneObjectRef } from '@grafana/scenes';
|
import { SceneComponentProps, SceneObjectBase, SceneObjectState, SceneObjectRef } from '@grafana/scenes';
|
||||||
import { Drawer, Tab, TabsBar } from '@grafana/ui';
|
import { Drawer, Tab, TabsBar } from '@grafana/ui';
|
||||||
import { SaveDashboardDiff } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDiff';
|
import { SaveDashboardDiff } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDiff';
|
||||||
|
import { useIsProvisionedNG } from 'app/features/provisioning/hooks/useIsProvisionedNG';
|
||||||
|
|
||||||
import { DashboardScene } from '../scene/DashboardScene';
|
import { DashboardScene } from '../scene/DashboardScene';
|
||||||
|
|
||||||
import { SaveDashboardAsForm } from './SaveDashboardAsForm';
|
import { SaveDashboardAsForm } from './SaveDashboardAsForm';
|
||||||
import { SaveDashboardForm } from './SaveDashboardForm';
|
import { SaveDashboardForm } from './SaveDashboardForm';
|
||||||
import { SaveProvisionedDashboardForm } from './SaveProvisionedDashboardForm';
|
import { SaveProvisionedDashboardForm } from './SaveProvisionedDashboardForm';
|
||||||
|
import { SaveProvisionedDashboard } from './provisioned/SaveProvisionedDashboard';
|
||||||
|
|
||||||
interface SaveDashboardDrawerState extends SceneObjectState {
|
interface SaveDashboardDrawerState extends SceneObjectState {
|
||||||
dashboardRef: SceneObjectRef<DashboardScene>;
|
dashboardRef: SceneObjectRef<DashboardScene>;
|
||||||
@ -20,7 +22,13 @@ interface SaveDashboardDrawerState extends SceneObjectState {
|
|||||||
|
|
||||||
export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerState> {
|
export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerState> {
|
||||||
public onClose = () => {
|
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 = () => {
|
public onToggleSaveTimeRange = () => {
|
||||||
@ -47,6 +55,7 @@ export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerStat
|
|||||||
const dashboard = model.state.dashboardRef.resolve();
|
const dashboard = model.state.dashboardRef.resolve();
|
||||||
const { meta } = dashboard.useState();
|
const { meta } = dashboard.useState();
|
||||||
const { provisioned: isProvisioned, folderTitle } = meta;
|
const { provisioned: isProvisioned, folderTitle } = meta;
|
||||||
|
const isProvisionedNG = useIsProvisionedNG(dashboard);
|
||||||
|
|
||||||
const tabs = (
|
const tabs = (
|
||||||
<TabsBar>
|
<TabsBar>
|
||||||
@ -65,7 +74,7 @@ export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerStat
|
|||||||
let title = 'Save dashboard';
|
let title = 'Save dashboard';
|
||||||
if (saveAsCopy) {
|
if (saveAsCopy) {
|
||||||
title = 'Save dashboard copy';
|
title = 'Save dashboard copy';
|
||||||
} else if (isProvisioned) {
|
} else if (isProvisioned || isProvisionedNG) {
|
||||||
title = 'Provisioned dashboard';
|
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) {
|
if (saveAsCopy || changeInfo.isNew) {
|
||||||
return <SaveDashboardAsForm dashboard={dashboard} changeInfo={changeInfo} />;
|
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 { Trans, t } from 'app/core/internationalization';
|
||||||
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||||
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
|
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
|
||||||
|
import { useSelector } from 'app/types';
|
||||||
|
|
||||||
import { shareDashboardType } from '../../dashboard/components/ShareModal/utils';
|
import { shareDashboardType } from '../../dashboard/components/ShareModal/utils';
|
||||||
|
import { selectFolderRepository } from '../../provisioning/utils/selectors';
|
||||||
import { PanelEditor, buildPanelEditScene } from '../panel-edit/PanelEditor';
|
import { PanelEditor, buildPanelEditScene } from '../panel-edit/PanelEditor';
|
||||||
import ExportButton from '../sharing/ExportButton/ExportButton';
|
import ExportButton from '../sharing/ExportButton/ExportButton';
|
||||||
import ShareButton from '../sharing/ShareButton/ShareButton';
|
import ShareButton from '../sharing/ShareButton/ShareButton';
|
||||||
@ -76,7 +78,8 @@ export function ToolbarActions({ dashboard }: Props) {
|
|||||||
const isShowingDashboard = !editview && !isViewingPanel && !isEditingPanel;
|
const isShowingDashboard = !editview && !isViewingPanel && !isEditingPanel;
|
||||||
const isEditingAndShowingDashboard = isEditing && isShowingDashboard;
|
const isEditingAndShowingDashboard = isEditing && isShowingDashboard;
|
||||||
const dashboardNewLayouts = config.featureToggles.dashboardNewLayouts;
|
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) {
|
if (!isEditingPanel) {
|
||||||
// This adds the presence indicators in enterprise
|
// This adds the presence indicators in enterprise
|
||||||
@ -130,7 +133,7 @@ export function ToolbarActions({ dashboard }: Props) {
|
|||||||
group: 'icon-actions',
|
group: 'icon-actions',
|
||||||
condition: true,
|
condition: true,
|
||||||
render: () => {
|
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 { t, Trans } from 'app/core/internationalization';
|
||||||
|
|
||||||
import { useDeleteItemsMutation } from '../../browse-dashboards/api/browseDashboardsAPI';
|
import { useDeleteItemsMutation } from '../../browse-dashboards/api/browseDashboardsAPI';
|
||||||
|
import { ProvisionedResourceDeleteModal } from '../saving/provisioned/ProvisionedResourceDeleteModal';
|
||||||
import { DashboardScene } from '../scene/DashboardScene';
|
import { DashboardScene } from '../scene/DashboardScene';
|
||||||
|
|
||||||
interface ButtonProps {
|
interface ButtonProps {
|
||||||
@ -53,6 +54,10 @@ export function DeleteDashboardButton({ dashboard }: ButtonProps) {
|
|||||||
return <ProvisionedDeleteModal dashboardId={dashboard.state.meta.provisionedExternalId} onClose={toggleModal} />;
|
return <ProvisionedDeleteModal dashboardId={dashboard.state.meta.provisionedExternalId} onClose={toggleModal} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dashboard.isManagedRepository() && showModal) {
|
||||||
|
return <ProvisionedResourceDeleteModal resource={dashboard} onDismiss={toggleModal} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
@ -20,6 +20,7 @@ export type DashboardPageRouteSearchParams = {
|
|||||||
kiosk?: string | true;
|
kiosk?: string | true;
|
||||||
scenes?: boolean;
|
scenes?: boolean;
|
||||||
shareView?: string;
|
shareView?: string;
|
||||||
|
ref?: string; // used for repo preview
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PublicDashboardPageRouteParams = {
|
export type PublicDashboardPageRouteParams = {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { skipToken } from '@reduxjs/toolkit/query/react';
|
import { skipToken } from '@reduxjs/toolkit/query/react';
|
||||||
|
|
||||||
import { useGetFolderQuery } from '../../../api/clients/folder';
|
import { useGetFolderQuery } from '../../../api/clients/folder';
|
||||||
import { AnnoKeyManagerKind } from '../../apiserver/types';
|
import { AnnoKeyManagerIdentity } from '../../apiserver/types';
|
||||||
|
|
||||||
import { useRepositoryList } from './useRepositoryList';
|
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
|
// Get the folder data from API to get the repository data for nested folders
|
||||||
const folderQuery = useGetFolderQuery(name || !folderUid ? skipToken : { name: folderUid });
|
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) {
|
if (!items?.length || isLoading || !repoName) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
@ -2,8 +2,7 @@ import { DataQuery } from '@grafana/data';
|
|||||||
import { Dashboard, DataSourceRef } from '@grafana/schema';
|
import { Dashboard, DataSourceRef } from '@grafana/schema';
|
||||||
import { ObjectMeta } from 'app/features/apiserver/types';
|
import { ObjectMeta } from 'app/features/apiserver/types';
|
||||||
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
||||||
|
import { ProvisioningPreview } from 'app/features/provisioning/types';
|
||||||
import { ProvisioningPreview } from '../features/provisioning/types';
|
|
||||||
|
|
||||||
export interface HomeDashboardRedirectDTO {
|
export interface HomeDashboardRedirectDTO {
|
||||||
redirectUri: string;
|
redirectUri: string;
|
||||||
|
@ -1413,6 +1413,43 @@
|
|||||||
"title": "There are no dashboard links added yet"
|
"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": {
|
"dashboard-settings": {
|
||||||
"annotations": {
|
"annotations": {
|
||||||
"title": "Annotations"
|
"title": "Annotations"
|
||||||
|
Reference in New Issue
Block a user