diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx
index 7aad80d73e1..83e72d3b1ca 100644
--- a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx
+++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx
@@ -23,6 +23,8 @@ export interface Props
export function DashboardScenePage({ route, queryParams, location }: Props) {
const params = useParams();
const { type, slug, uid } = params;
+ // User by /admin/provisioning/:slug/dashboard/preview/* to load dashboards based on their file path in a remote repository
+ const path = params['*'];
const prevMatch = usePrevious({ params });
const stateManager = getDashboardScenePageStateManager();
const { dashboard, isLoading, loadError } = stateManager.useState();
@@ -34,9 +36,9 @@ export function DashboardScenePage({ route, queryParams, location }: Props) {
stateManager.loadSnapshot(slug!);
} else {
stateManager.loadDashboard({
+ uid: (route.routeName === DashboardRoutes.Provisioning ? path : uid) ?? '',
type,
slug,
- uid: uid ?? '',
route: route.routeName as DashboardRoutes,
urlFolderUid: queryParams.folderUid,
});
@@ -45,7 +47,7 @@ export function DashboardScenePage({ route, queryParams, location }: Props) {
return () => {
stateManager.clearState();
};
- }, [stateManager, uid, route.routeName, queryParams.folderUid, routeReloadCounter, slug, type]);
+ }, [stateManager, uid, route.routeName, queryParams.folderUid, routeReloadCounter, slug, type, path]);
if (!dashboard) {
let errorElement;
diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx
index 96e52c2346a..2d3428f3807 100644
--- a/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx
+++ b/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx
@@ -4,9 +4,11 @@ import { UseFormSetValue, useForm } from 'react-hook-form';
import { selectors } from '@grafana/e2e-selectors';
import { Button, Input, Switch, Field, Label, TextArea, Stack, Alert, Box } from '@grafana/ui';
+import { RepositoryView } from 'app/api/clients/provisioning';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
+import { AnnoKeyManagerIdentity, AnnoKeyManagerKind, ManagerKind } from '../../apiserver/types';
import { DashboardScene } from '../scene/DashboardScene';
import { DashboardChangeInfo, NameAlreadyExistsError, SaveButton, isNameExistsError } from './shared';
@@ -131,10 +133,20 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) {
{
+ onChange={(uid: string | undefined, title: string | undefined, repository?: RepositoryView) => {
+ const name = repository?.name;
setValue('folder', { uid, title });
const folderUid = dashboard.state.meta.folderUid;
setHasFolderChanged(uid !== folderUid);
+ dashboard.setState({
+ // This is necessary to switch to the provisioning flow if a folder is provisioned
+ meta: {
+ k8s: name
+ ? { annotations: { [AnnoKeyManagerIdentity]: name, [AnnoKeyManagerKind]: ManagerKind.Repo } }
+ : undefined,
+ folderUid: uid,
+ },
+ });
}}
// Old folder picker fields
value={formValues.folder?.uid}
diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx
index a3d824cab71..72e5dca3639 100644
--- a/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx
+++ b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx
@@ -1,12 +1,14 @@
import { SceneComponentProps, SceneObjectBase, SceneObjectState, SceneObjectRef } from '@grafana/scenes';
import { Drawer, Tab, TabsBar } from '@grafana/ui';
import { SaveDashboardDiff } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDiff';
+import { useIsProvisionedNG } from 'app/features/provisioning/hooks/useIsProvisionedNG';
import { DashboardScene } from '../scene/DashboardScene';
import { SaveDashboardAsForm } from './SaveDashboardAsForm';
import { SaveDashboardForm } from './SaveDashboardForm';
import { SaveProvisionedDashboardForm } from './SaveProvisionedDashboardForm';
+import { SaveProvisionedDashboard } from './provisioned/SaveProvisionedDashboard';
interface SaveDashboardDrawerState extends SceneObjectState {
dashboardRef: SceneObjectRef;
@@ -20,7 +22,13 @@ interface SaveDashboardDrawerState extends SceneObjectState {
export class SaveDashboardDrawer extends SceneObjectBase {
public onClose = () => {
- this.state.dashboardRef.resolve().setState({ overlay: undefined });
+ const dashboard = this.state.dashboardRef.resolve();
+ const changeInfo = dashboard.getDashboardChanges();
+ dashboard.setState({
+ overlay: undefined,
+ // Reset meta to initial state if it's a new dashboard to remove provisioned fields
+ meta: changeInfo.isNew ? dashboard.getInitialState()?.meta : dashboard.state.meta,
+ });
};
public onToggleSaveTimeRange = () => {
@@ -47,6 +55,7 @@ export class SaveDashboardDrawer extends SceneObjectBase
@@ -65,7 +74,7 @@ export class SaveDashboardDrawer extends SceneObjectBase ;
+ }
+
if (saveAsCopy || changeInfo.isNew) {
return ;
}
diff --git a/public/app/features/dashboard-scene/saving/provisioned/ProvisionedResourceDeleteModal.tsx b/public/app/features/dashboard-scene/saving/provisioned/ProvisionedResourceDeleteModal.tsx
new file mode 100644
index 00000000000..79e9131ac4b
--- /dev/null
+++ b/public/app/features/dashboard-scene/saving/provisioned/ProvisionedResourceDeleteModal.tsx
@@ -0,0 +1,59 @@
+import { Button, Modal } from '@grafana/ui';
+import { t, Trans } from 'app/core/internationalization';
+
+import { FolderDTO, FolderListItemDTO } from '../../../../types';
+import { NestedFolderDTO } from '../../../search/service/types';
+import { DashboardScene } from '../../scene/DashboardScene';
+
+export type FolderDataType = FolderListItemDTO | NestedFolderDTO | FolderDTO;
+
+export interface Props {
+ onDismiss: () => void;
+ resource: DashboardScene | FolderDataType;
+}
+
+export function ProvisionedResourceDeleteModal({ onDismiss, resource }: Props) {
+ const type = isDashboard(resource) ? 'dashboard' : 'folder';
+ return (
+
+ <>
+
+
+ This {type} is managed by version control and cannot be deleted. To remove it, delete it from the repository
+ and synchronise to apply the changes.
+
+
+ {isDashboard(resource) && (
+
+ File path: {' '}
+ {resource.getPath()}
+
+ )}
+ >
+
+
+
+ OK
+
+
+
+ );
+}
+
+function isDashboard(resource: DashboardScene | FolderDataType): resource is DashboardScene {
+ return resource instanceof DashboardScene;
+}
+
+export function isFolder(resource: DashboardScene | FolderDataType): resource is FolderDataType {
+ return !isDashboard(resource);
+}
diff --git a/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboard.tsx b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboard.tsx
new file mode 100644
index 00000000000..0c41acce505
--- /dev/null
+++ b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboard.tsx
@@ -0,0 +1,41 @@
+import { useUrlParams } from 'app/core/navigation/hooks';
+
+import { DashboardScene } from '../../scene/DashboardScene';
+import { SaveDashboardDrawer } from '../SaveDashboardDrawer';
+import { DashboardChangeInfo } from '../shared';
+
+import { SaveProvisionedDashboardForm } from './SaveProvisionedDashboardForm';
+import { useDefaultValues } from './hooks';
+
+export interface SaveProvisionedDashboardProps {
+ dashboard: DashboardScene;
+ drawer: SaveDashboardDrawer;
+ changeInfo: DashboardChangeInfo;
+}
+
+export function SaveProvisionedDashboard({ drawer, changeInfo, dashboard }: SaveProvisionedDashboardProps) {
+ const { meta, title: defaultTitle, description: defaultDescription } = dashboard.useState();
+
+ const [params] = useUrlParams();
+ const loadedFromRef = params.get('ref') ?? undefined;
+
+ const defaultValues = useDefaultValues({ meta, defaultTitle, defaultDescription });
+
+ if (!defaultValues) {
+ return null;
+ }
+ const { values, isNew, isGitHub, repositoryConfig } = defaultValues;
+
+ return (
+
+ );
+}
diff --git a/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.test.tsx b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.test.tsx
new file mode 100644
index 00000000000..9aca67ddbb5
--- /dev/null
+++ b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.test.tsx
@@ -0,0 +1,381 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { AppEvents } from '@grafana/data';
+import { getAppEvents, locationService } from '@grafana/runtime';
+import { Dashboard } from '@grafana/schema';
+import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
+import { useCreateOrUpdateRepositoryFile } from 'app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile';
+
+import { DashboardScene } from '../../scene/DashboardScene';
+import { SaveDashboardDrawer } from '../SaveDashboardDrawer';
+
+import { SaveProvisionedDashboardForm, Props } from './SaveProvisionedDashboardForm';
+
+jest.mock('@grafana/runtime', () => {
+ const actual = jest.requireActual('@grafana/runtime');
+ return {
+ ...actual,
+ getAppEvents: jest.fn(),
+ locationService: {
+ partial: jest.fn(),
+ },
+ config: {
+ ...actual.config,
+ panels: {
+ debug: {
+ state: 'alpha',
+ },
+ },
+ },
+ };
+});
+
+jest.mock('app/core/components/Select/FolderPicker', () => {
+ const actual = jest.requireActual('app/core/components/Select/FolderPicker');
+ return {
+ ...actual,
+ FolderPicker: function MockFolderPicker() {
+ return Folder Picker
;
+ },
+ };
+});
+
+jest.mock('app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile', () => {
+ return {
+ useCreateOrUpdateRepositoryFile: jest.fn(),
+ };
+});
+
+jest.mock('app/features/provisioning/hooks/useGetResourceRepository', () => {
+ return {
+ useGetResourceRepository: jest.fn(),
+ };
+});
+
+jest.mock('app/features/provisioning/hooks/useRepositoryList', () => {
+ return {
+ useRepositoryList: jest.fn(),
+ };
+});
+
+jest.mock('app/features/manage-dashboards/services/ValidationSrv', () => {
+ const actual = jest.requireActual('app/features/manage-dashboards/services/ValidationSrv');
+ return {
+ ...actual,
+ validationSrv: {
+ validateNewDashboardName: jest.fn(),
+ },
+ };
+});
+
+jest.mock('react-router-dom-v5-compat', () => {
+ const actual = jest.requireActual('react-router-dom-v5-compat');
+ return {
+ ...actual,
+ useNavigate: () => jest.fn(),
+ };
+});
+
+jest.mock('../SaveDashboardForm', () => {
+ const actual = jest.requireActual('../SaveDashboardForm');
+ return {
+ ...actual,
+ SaveDashboardFormCommonOptions: () => Common Options
,
+ };
+});
+
+function setup(props: Partial = {}) {
+ const user = userEvent.setup();
+
+ const mockDashboard: Dashboard = {
+ title: 'Test Dashboard',
+ panels: [],
+ schemaVersion: 36,
+ };
+
+ const defaultProps: Props = {
+ dashboard: {
+ useState: () => ({
+ meta: { folderUid: 'folder-uid', slug: 'test-dashboard' },
+ title: 'Test Dashboard',
+ description: 'Test Description',
+ isDirty: true,
+ }),
+ setState: jest.fn(),
+ closeModal: jest.fn(),
+ getSaveAsModel: jest.fn().mockReturnValue(mockDashboard),
+ setManager: jest.fn(),
+ } as unknown as DashboardScene,
+ drawer: {
+ onClose: jest.fn(),
+ } as unknown as SaveDashboardDrawer,
+ changeInfo: {
+ changedSaveModel: mockDashboard,
+ initialSaveModel: mockDashboard,
+ diffCount: 0,
+ hasChanges: true,
+ hasTimeChanges: false,
+ hasVariableValueChanges: false,
+ hasRefreshChange: false,
+ diffs: {},
+ },
+ isNew: true,
+ isGitHub: true,
+ defaultValues: {
+ ref: 'dashboard/2023-01-01-abcde',
+ path: 'test-dashboard.json',
+ repo: 'test-repo',
+ comment: '',
+ folder: { uid: 'folder-uid', title: '' },
+ title: 'Test Dashboard',
+ description: 'Test Description',
+ workflow: 'write',
+ },
+ repositoryConfig: {
+ type: 'github',
+ workflows: ['write', 'branch'],
+ sync: { enabled: false, target: 'folder' },
+ title: 'Test Repository',
+ github: {
+ branch: 'main',
+ },
+ },
+ ...props,
+ };
+
+ return {
+ user,
+ props: defaultProps,
+ ...render( ),
+ };
+}
+
+const mockRequestBase = {
+ isSuccess: true,
+ isError: false,
+ isLoading: false,
+ error: null,
+ data: { resource: { upsert: {} } },
+};
+
+describe('SaveProvisionedDashboardForm', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (getAppEvents as jest.Mock).mockReturnValue({ publish: jest.fn() });
+ (validationSrv.validateNewDashboardName as jest.Mock).mockResolvedValue(true);
+ const mockRequest = { ...mockRequestBase, isSuccess: false };
+ (useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([jest.fn(), mockRequest]);
+ });
+
+ it('should render the form with correct fields for a new dashboard', () => {
+ setup();
+ expect(screen.getByRole('form')).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: /title/i })).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: /description/i })).toBeInTheDocument();
+ expect(screen.getByTestId('folder-picker')).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: /path/i })).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: /comment/i })).toBeInTheDocument();
+ expect(screen.getByRole('radiogroup')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
+ });
+
+ it('should render the form with correct fields for an existing dashboard', () => {
+ // existing dashboards show "Common Options" instead of the title/desc fields
+ setup({ isNew: false });
+ expect(screen.getByTestId('common-options')).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: /path/i })).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: /comment/i })).toBeInTheDocument();
+ expect(screen.getByRole('radiogroup')).toBeInTheDocument();
+ expect(screen.queryByRole('textbox', { name: /title/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole('textbox', { name: /description/i })).not.toBeInTheDocument();
+ });
+
+ it('should save a new dashboard successfully', async () => {
+ const { user, props } = setup();
+ const newDashboard = {
+ title: 'New Dashboard',
+ description: 'New Description',
+ panels: [],
+ schemaVersion: 36,
+ };
+ props.dashboard.getSaveAsModel = jest.fn().mockReturnValue(newDashboard);
+ const mockAction = jest.fn();
+ const mockRequest = { ...mockRequestBase, isSuccess: true };
+ (useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]);
+ const titleInput = screen.getByRole('textbox', { name: /title/i });
+ const descriptionInput = screen.getByRole('textbox', { name: /description/i });
+ const pathInput = screen.getByRole('textbox', { name: /path/i });
+ const commentInput = screen.getByRole('textbox', { name: /comment/i });
+
+ await user.clear(titleInput);
+ await user.clear(descriptionInput);
+ await user.clear(pathInput);
+ await user.clear(commentInput);
+
+ await user.type(titleInput, 'New Dashboard');
+ await user.type(descriptionInput, 'New Description');
+ await user.type(pathInput, 'test-dashboard.json');
+ await user.type(commentInput, 'Initial commit');
+
+ const submitButton = screen.getByRole('button', { name: /save/i });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(props.dashboard.setState).toHaveBeenCalledWith({ isDirty: false });
+ });
+ await waitFor(() => {
+ expect(mockAction).toHaveBeenCalledWith({
+ ref: undefined,
+ name: 'test-repo',
+ path: 'test-dashboard.json',
+ message: 'Initial commit',
+ body: newDashboard,
+ });
+ });
+ const appEvents = getAppEvents();
+ expect(appEvents.publish).toHaveBeenCalledWith({
+ type: AppEvents.alertSuccess.name,
+ payload: ['Dashboard changes saved'],
+ });
+ expect(props.dashboard.closeModal).toHaveBeenCalled();
+ expect(locationService.partial).toHaveBeenCalledWith({ viewPanel: null, editPanel: null });
+ });
+
+ it('should update an existing dashboard successfully', async () => {
+ const { user, props } = setup({
+ isNew: false,
+ dashboard: {
+ useState: () => ({
+ meta: {
+ folderUid: 'folder-uid',
+ slug: 'test-dashboard',
+ k8s: { name: 'test-dashboard' },
+ },
+ title: 'Test Dashboard',
+ description: 'Test Description',
+ isDirty: true,
+ }),
+ setState: jest.fn(),
+ closeModal: jest.fn(),
+ getSaveAsModel: jest.fn().mockReturnValue({ title: 'Test Dashboard', description: 'Test Description' }),
+ setManager: jest.fn(),
+ } as unknown as DashboardScene,
+ });
+ const mockAction = jest.fn();
+ const mockRequest = { ...mockRequestBase, isSuccess: true };
+ (useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]);
+ const pathInput = screen.getByRole('textbox', { name: /path/i });
+ const commentInput = screen.getByRole('textbox', { name: /comment/i });
+ await user.clear(pathInput);
+ await user.clear(commentInput);
+ await user.type(pathInput, 'test-dashboard.json');
+ await user.type(commentInput, 'Update dashboard');
+ const submitButton = screen.getByRole('button', { name: /save/i });
+ await user.click(submitButton);
+ await waitFor(() => {
+ expect(props.dashboard.setState).toHaveBeenCalledWith({ isDirty: false });
+ });
+ await waitFor(() => {
+ expect(mockAction).toHaveBeenCalledWith({
+ ref: undefined,
+ name: 'test-repo',
+ path: 'test-dashboard.json',
+ message: 'Update dashboard',
+ body: expect.any(Object),
+ });
+ });
+ expect(props.dashboard.closeModal).toHaveBeenCalled();
+ expect(locationService.partial).toHaveBeenCalledWith({ viewPanel: null, editPanel: null });
+ });
+
+ it('should show error when save fails', async () => {
+ const { user, props } = setup();
+ const newDashboard = {
+ title: 'New Dashboard',
+ description: 'New Description',
+ panels: [],
+ schemaVersion: 36,
+ };
+ props.dashboard.getSaveAsModel = jest.fn().mockReturnValue(newDashboard);
+ const mockAction = jest.fn();
+ const mockRequest = {
+ ...mockRequestBase,
+ isSuccess: false,
+ isError: true,
+ error: 'Failed to save dashboard',
+ };
+ (useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]);
+ const titleInput = screen.getByRole('textbox', { name: /title/i });
+ const descriptionInput = screen.getByRole('textbox', { name: /description/i });
+ const pathInput = screen.getByRole('textbox', { name: /path/i });
+ const commentInput = screen.getByRole('textbox', { name: /comment/i });
+
+ await user.clear(titleInput);
+ await user.clear(descriptionInput);
+ await user.clear(pathInput);
+ await user.clear(commentInput);
+
+ await user.type(titleInput, 'New Dashboard');
+ await user.type(descriptionInput, 'New Description');
+ await user.type(pathInput, 'error-dashboard.json');
+ await user.type(commentInput, 'Error commit');
+
+ const submitButton = screen.getByRole('button', { name: /save/i });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(mockAction).toHaveBeenCalledWith({
+ ref: undefined,
+ name: 'test-repo',
+ path: 'error-dashboard.json',
+ message: 'Error commit',
+ body: newDashboard,
+ });
+ });
+ await waitFor(() => {
+ const appEvents = getAppEvents();
+ expect(appEvents.publish).toHaveBeenCalledWith({
+ type: AppEvents.alertError.name,
+ payload: ['Error saving dashboard', 'Failed to save dashboard'],
+ });
+ });
+ });
+
+ it('should disable save button when dashboard is not dirty', () => {
+ setup({
+ dashboard: {
+ useState: () => ({
+ meta: {
+ folderUid: 'folder-uid',
+ slug: 'test-dashboard',
+ k8s: { name: 'test-dashboard' },
+ },
+ title: 'Test Dashboard',
+ description: 'Test Description',
+ isDirty: false,
+ }),
+ setState: jest.fn(),
+ closeModal: jest.fn(),
+ getSaveAsModel: jest.fn().mockReturnValue({}),
+ setManager: jest.fn(),
+ } as unknown as DashboardScene,
+ });
+
+ expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
+ });
+
+ it('should show read-only alert when repository has no workflows', () => {
+ setup({
+ repositoryConfig: {
+ type: 'github',
+ workflows: [],
+ sync: { enabled: false, target: 'folder' },
+ title: 'Read-only Repository',
+ },
+ });
+
+ expect(screen.getByText('This repository is read only')).toBeInTheDocument();
+ });
+});
diff --git a/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx
new file mode 100644
index 00000000000..8cf33921f95
--- /dev/null
+++ b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx
@@ -0,0 +1,347 @@
+import { useEffect } from 'react';
+import { Controller, useForm } from 'react-hook-form';
+import { useNavigate } from 'react-router-dom-v5-compat';
+
+import { AppEvents, locationUtil } from '@grafana/data';
+import { getAppEvents, locationService } from '@grafana/runtime';
+import { Dashboard } from '@grafana/schema';
+import { Alert, Button, Field, Input, RadioButtonGroup, Stack, TextArea } from '@grafana/ui';
+import { RepositorySpec, RepositoryView } from 'app/api/clients/provisioning';
+import { FolderPicker } from 'app/core/components/Select/FolderPicker';
+import { t, Trans } from 'app/core/internationalization';
+import kbn from 'app/core/utils/kbn';
+import { AnnoKeyManagerIdentity, AnnoKeyManagerKind, ManagerKind, Resource } from 'app/features/apiserver/types';
+import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
+import { PROVISIONING_URL } from 'app/features/provisioning/constants';
+import { useCreateOrUpdateRepositoryFile } from 'app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile';
+import { WorkflowOption } from 'app/features/provisioning/types';
+import { validateBranchName } from 'app/features/provisioning/utils/git';
+
+import { getDashboardUrl } from '../../utils/getDashboardUrl';
+import { SaveDashboardFormCommonOptions } from '../SaveDashboardForm';
+
+import { SaveProvisionedDashboardProps } from './SaveProvisionedDashboard';
+import { getWorkflowOptions } from './defaults';
+
+type FormData = {
+ ref?: string;
+ path: string;
+ comment?: string;
+ repo: string;
+ workflow?: WorkflowOption;
+ title: string;
+ description: string;
+ folder: {
+ uid?: string;
+ title?: string;
+ };
+};
+
+export interface Props extends SaveProvisionedDashboardProps {
+ isNew: boolean;
+ defaultValues: FormData;
+ isGitHub: boolean;
+ repositoryConfig?: RepositorySpec;
+ loadedFromRef?: string;
+}
+
+export function SaveProvisionedDashboardForm({
+ defaultValues,
+ dashboard,
+ drawer,
+ changeInfo,
+ isNew,
+ loadedFromRef,
+ repositoryConfig,
+ isGitHub,
+}: Props) {
+ const navigate = useNavigate();
+ const appEvents = getAppEvents();
+ const { meta, isDirty } = dashboard.useState();
+
+ const [createOrUpdateFile, request] = useCreateOrUpdateRepositoryFile(isNew ? undefined : defaultValues.path);
+
+ const {
+ register,
+ handleSubmit,
+ watch,
+ formState: { errors },
+ control,
+ setValue,
+ } = useForm({ defaultValues });
+
+ const [ref, workflow, path] = watch(['ref', 'workflow', 'path']);
+
+ useEffect(() => {
+ if (request.isSuccess) {
+ dashboard.setState({ isDirty: false });
+ if (workflow === 'branch' && ref !== '' && path !== '') {
+ // Redirect to the provisioning preview pages
+ navigate(`${PROVISIONING_URL}/${defaultValues.repo}/dashboard/preview/${path}?ref=${ref}`);
+ return;
+ }
+
+ appEvents.publish({
+ type: AppEvents.alertSuccess.name,
+ payload: [t('dashboard-scene.save-provisioned-dashboard-form.api-success', 'Dashboard changes saved')],
+ });
+
+ // Load the new URL
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ const upsert = request.data.resource.upsert as Resource;
+ if (isNew && upsert?.metadata?.name) {
+ const url = locationUtil.assureBaseUrl(
+ getDashboardUrl({
+ uid: upsert.metadata.name,
+ slug: kbn.slugifyForUrl(upsert.spec.title ?? ''),
+ currentQueryParams: window.location.search,
+ })
+ );
+
+ navigate(url);
+ return;
+ }
+
+ // Keep the same URL
+ dashboard.closeModal();
+ locationService.partial({
+ viewPanel: null,
+ editPanel: null,
+ });
+ } else if (request.isError) {
+ appEvents.publish({
+ type: AppEvents.alertError.name,
+ payload: [
+ t('dashboard-scene.save-provisioned-dashboard-form.api-error', 'Error saving dashboard'),
+ request.error,
+ ],
+ });
+ }
+ }, [appEvents, dashboard, defaultValues.repo, isNew, navigate, path, ref, request, workflow]);
+
+ // Submit handler for saving the form data
+ const handleFormSubmit = async ({ title, description, repo, path, comment, ref }: FormData) => {
+ if (!repo || !path) {
+ return;
+ }
+
+ // The dashboard spec
+ const saveModel = dashboard.getSaveAsModel({
+ isNew,
+ title,
+ description,
+ });
+
+ // If user is writing to the original branch, override ref with whatever we loaded from
+ if (workflow === 'write') {
+ ref = loadedFromRef;
+ }
+
+ createOrUpdateFile({
+ ref,
+ name: repo,
+ path,
+ message: comment,
+ body: { ...saveModel, uid: meta.uid },
+ });
+ };
+
+ const workflowOptions = getWorkflowOptions(repositoryConfig, loadedFromRef);
+
+ return (
+
+ )}
+ >
+ )}
+
+
+
+ {request.isLoading
+ ? t('dashboard-scene.save-provisioned-dashboard-form.saving', 'Saving...')
+ : t('dashboard-scene.save-provisioned-dashboard-form.save', 'Save')}
+
+
+ Cancel
+
+
+
+
+ );
+}
+
+const BranchValidationError = () => (
+ <>
+ Invalid branch name.
+
+
+
+ It cannot start with '/' or end with '/', '.', or whitespace.
+
+
+
+
+ It cannot contain '//' or '..'.
+
+
+
+
+ It cannot contain invalid characters: '~', '^', ':', '?', '*', '[', '\\', or ']'.
+
+
+
+
+ It must have at least one valid character.
+
+
+
+ >
+);
+
+/**
+ * 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.'
+ );
+ }
+}
diff --git a/public/app/features/dashboard-scene/saving/provisioned/defaults.ts b/public/app/features/dashboard-scene/saving/provisioned/defaults.ts
new file mode 100644
index 00000000000..584f4673f71
--- /dev/null
+++ b/public/app/features/dashboard-scene/saving/provisioned/defaults.ts
@@ -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));
+}
diff --git a/public/app/features/dashboard-scene/saving/provisioned/hooks.ts b/public/app/features/dashboard-scene/saving/provisioned/hooks.ts
new file mode 100644
index 00000000000..df998dcdf51
--- /dev/null
+++ b/public/app/features/dashboard-scene/saving/provisioned/hooks.ts
@@ -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);
+};
diff --git a/public/app/features/dashboard-scene/saving/provisioned/utils/path.test.ts b/public/app/features/dashboard-scene/saving/provisioned/utils/path.test.ts
new file mode 100644
index 00000000000..fc25878d2fa
--- /dev/null
+++ b/public/app/features/dashboard-scene/saving/provisioned/utils/path.test.ts
@@ -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');
+ });
+});
diff --git a/public/app/features/dashboard-scene/saving/provisioned/utils/path.ts b/public/app/features/dashboard-scene/saving/provisioned/utils/path.ts
new file mode 100644
index 00000000000..5464d13dbee
--- /dev/null
+++ b/public/app/features/dashboard-scene/saving/provisioned/utils/path.ts
@@ -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;
+}
diff --git a/public/app/features/dashboard-scene/saving/provisioned/utils/timestamp.test.ts b/public/app/features/dashboard-scene/saving/provisioned/utils/timestamp.test.ts
new file mode 100644
index 00000000000..ab7543453dd
--- /dev/null
+++ b/public/app/features/dashboard-scene/saving/provisioned/utils/timestamp.test.ts
@@ -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);
+ });
+});
diff --git a/public/app/features/dashboard-scene/saving/provisioned/utils/timestamp.ts b/public/app/features/dashboard-scene/saving/provisioned/utils/timestamp.ts
new file mode 100644
index 00000000000..a1020951d41
--- /dev/null
+++ b/public/app/features/dashboard-scene/saving/provisioned/utils/timestamp.ts
@@ -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 })}`;
+}
diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
index 8fdf5f3a9bf..82449b4e21a 100644
--- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
+++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
@@ -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 ;
+ return ;
},
});
}
diff --git a/public/app/features/dashboard-scene/settings/DeleteDashboardButton.tsx b/public/app/features/dashboard-scene/settings/DeleteDashboardButton.tsx
index 45ace975935..de4c0bb38c8 100644
--- a/public/app/features/dashboard-scene/settings/DeleteDashboardButton.tsx
+++ b/public/app/features/dashboard-scene/settings/DeleteDashboardButton.tsx
@@ -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 ;
}
+ if (dashboard.isManagedRepository() && showModal) {
+ return ;
+ }
+
return (
<>