diff --git a/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.test.tsx b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.test.tsx index c67d94a2c75..caa0da74f8c 100644 --- a/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.test.tsx +++ b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.test.tsx @@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event'; import { AppEvents } from '@grafana/data'; import { getAppEvents, locationService } from '@grafana/runtime'; import { Dashboard } from '@grafana/schema'; +import { AnnoKeyFolder, AnnoKeySourcePath } from 'app/features/apiserver/types'; import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv'; import { useCreateOrUpdateRepositoryFile } from 'app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile'; @@ -196,12 +197,20 @@ describe('SaveProvisionedDashboardForm', () => { it('should save a new dashboard successfully', async () => { const { user, props } = setup(); const newDashboard = { - title: 'New Dashboard', - description: 'New Description', - panels: [], - schemaVersion: 36, + apiVersion: 'dashboard.grafana.app/v1alpha1', + kind: 'Dashboard', + metadata: { + generateName: 'p', + name: undefined, + }, + spec: { + title: 'New Dashboard', + description: 'New Description', + panels: [], + schemaVersion: 36, + }, }; - props.dashboard.getSaveAsModel = jest.fn().mockReturnValue(newDashboard); + props.dashboard.getSaveResource = jest.fn().mockReturnValue(newDashboard); const mockAction = jest.fn(); const mockRequest = { ...mockRequestBase, isSuccess: true }; (useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]); @@ -245,14 +254,26 @@ describe('SaveProvisionedDashboardForm', () => { }); it('should update an existing dashboard successfully', async () => { + const updatedDashboard = { + apiVersion: 'dashboard.grafana.app/vXyz', + metadata: { + name: 'test-dashboard', + annotations: { + [AnnoKeyFolder]: 'folder-uid', + [AnnoKeySourcePath]: 'path/to/file.json', + }, + }, + spec: { title: 'Test Dashboard', description: 'Test Description' }, + }; const { user, props } = setup({ isNew: false, dashboard: { useState: () => ({ meta: { - folderUid: 'folder-uid', + folderUid: updatedDashboard.metadata.annotations[AnnoKeyFolder], slug: 'test-dashboard', - k8s: { name: 'test-dashboard' }, + uid: updatedDashboard.metadata.name, + k8s: updatedDashboard.metadata, }, title: 'Test Dashboard', description: 'Test Description', @@ -260,7 +281,7 @@ describe('SaveProvisionedDashboardForm', () => { }), setState: jest.fn(), closeModal: jest.fn(), - getSaveAsModel: jest.fn().mockReturnValue({ title: 'Test Dashboard', description: 'Test Description' }), + getSaveResource: jest.fn().mockReturnValue(updatedDashboard), setManager: jest.fn(), } as unknown as DashboardScene, }); @@ -268,10 +289,13 @@ describe('SaveProvisionedDashboardForm', () => { 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 }); + expect(pathInput).toHaveAttribute('readonly'); // can not edit the path value + pathInput.removeAttribute('readonly'); // save won't get called unless we have a value await user.clear(pathInput); + await user.type(pathInput, 'path/to/file.json'); + + const commentInput = screen.getByRole('textbox', { name: /comment/i }); 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); @@ -282,9 +306,9 @@ describe('SaveProvisionedDashboardForm', () => { expect(mockAction).toHaveBeenCalledWith({ ref: undefined, name: 'test-repo', - path: 'test-dashboard.json', + path: 'path/to/file.json', message: 'Update dashboard', - body: expect.any(Object), + body: updatedDashboard, }); }); expect(props.dashboard.closeModal).toHaveBeenCalled(); @@ -294,12 +318,20 @@ describe('SaveProvisionedDashboardForm', () => { it('should show error when save fails', async () => { const { user, props } = setup(); const newDashboard = { - title: 'New Dashboard', - description: 'New Description', - panels: [], - schemaVersion: 36, + apiVersion: 'dashboard.grafana.app/v1alpha1', + kind: 'Dashboard', + metadata: { + generateName: 'p', + name: undefined, + }, + spec: { + title: 'New Dashboard', + description: 'New Description', + panels: [], + schemaVersion: 36, + }, }; - props.dashboard.getSaveAsModel = jest.fn().mockReturnValue(newDashboard); + props.dashboard.getSaveResource = jest.fn().mockReturnValue(newDashboard); const mockAction = jest.fn(); const mockRequest = { ...mockRequestBase, diff --git a/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx index 9b082028799..2cb4bf4837e 100644 --- a/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx +++ b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboardForm.tsx @@ -59,7 +59,7 @@ export function SaveProvisionedDashboardForm({ }: Props) { const navigate = useNavigate(); const appEvents = getAppEvents(); - const { meta, isDirty, editPanel: panelEditor } = dashboard.useState(); + const { isDirty, editPanel: panelEditor } = dashboard.useState(); const [createOrUpdateFile, request] = useCreateOrUpdateRepositoryFile(isNew ? undefined : defaultValues.path); @@ -134,24 +134,23 @@ export function SaveProvisionedDashboardForm({ 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; } + const body = dashboard.getSaveResource({ + isNew, + title, + description, + }); + createOrUpdateFile({ ref, name: repo, path, message: comment, - body: { ...saveModel, uid: meta.uid }, + body, }); }; @@ -238,7 +237,7 @@ export function SaveProvisionedDashboardForm({ 'File path inside the repository (.json or .yaml)' )} > - + 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 index fc25878d2fa..234f799d6da 100644 --- a/public/app/features/dashboard-scene/saving/provisioned/utils/path.test.ts +++ b/public/app/features/dashboard-scene/saving/provisioned/utils/path.test.ts @@ -49,14 +49,14 @@ describe('generatePath', () => { expect(result).toBe('folder/path/my-dashboard.json'); }); - it('should prepend folderPath to pathFromAnnotation when both are provided', () => { + it('should use pathFromAnnotation when both are provided', () => { const result = generatePath({ timestamp, - pathFromAnnotation: 'my-custom-path.json', - folderPath: 'folder/path', + pathFromAnnotation: 'full/path/my-custom-path.json', // this is always the full path + folderPath: 'full/path', // this will be a substring }); - expect(result).toBe('folder/path/my-custom-path.json'); + expect(result).toBe('full/path/my-custom-path.json'); }); it('should handle empty folderPath', () => { diff --git a/public/app/features/dashboard-scene/saving/provisioned/utils/path.ts b/public/app/features/dashboard-scene/saving/provisioned/utils/path.ts index 5464d13dbee..7a25d663065 100644 --- a/public/app/features/dashboard-scene/saving/provisioned/utils/path.ts +++ b/public/app/features/dashboard-scene/saving/provisioned/utils/path.ts @@ -19,12 +19,12 @@ export function generatePath({ timestamp, pathFromAnnotation, slug, folderPath = if (pathFromAnnotation) { const hashIndex = pathFromAnnotation.indexOf('#'); - path = hashIndex > 0 ? pathFromAnnotation.substring(0, hashIndex) : pathFromAnnotation; - } else { - const pathSlug = slug || `new-dashboard-${timestamp}`; - path = `${pathSlug}.json`; + return hashIndex > 0 ? pathFromAnnotation.substring(0, hashIndex) : pathFromAnnotation; } + const pathSlug = slug || `new-dashboard-${timestamp}`; + path = `${pathSlug}.json`; + // Add folder path if it exists if (folderPath) { return `${folderPath}/${path}`; diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index c7a2504f1c0..b2e91341c82 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -33,7 +33,7 @@ import { VariablesChanged } from 'app/features/variables/types'; import { DashboardDTO, DashboardMeta, KioskMode, SaveDashboardResponseDTO } from 'app/types'; import { ShowConfirmModalEvent } from 'app/types/events'; -import { AnnoKeyManagerKind, AnnoKeySourcePath, ManagerKind } from '../../apiserver/types'; +import { AnnoKeyManagerKind, AnnoKeySourcePath, ManagerKind, ResourceForCreate } from '../../apiserver/types'; import { DashboardEditPane } from '../edit-pane/DashboardEditPane'; import { PanelEditor } from '../panel-edit/PanelEditor'; import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker'; @@ -763,6 +763,22 @@ export class DashboardScene extends SceneObjectBase impleme return this.serializer.getSaveModel(this); } + // Get the dashboard in native K8s form (using the appropriate apiVersion) + getSaveResource(options: SaveDashboardAsOptions): ResourceForCreate { + const { meta } = this.state; + const spec = this.getSaveAsModel(options); + return { + apiVersion: 'dashboard.grafana.app/v1alpha1', // get from the dashboard? + kind: 'Dashboard', + metadata: { + ...meta.k8s, + name: meta.uid, // ideally the name is preserved + generateName: options.isNew ? 'd' : undefined, + }, + spec, + }; + } + getSaveAsModel(options: SaveDashboardAsOptions): Dashboard | DashboardV2Spec { return this.serializer.getSaveAsModel(this, options); } diff --git a/public/app/features/provisioning/Shared/FolderRepositoryList.tsx b/public/app/features/provisioning/Shared/FolderRepositoryList.tsx index 7af68d68f61..512d5ef53d1 100644 --- a/public/app/features/provisioning/Shared/FolderRepositoryList.tsx +++ b/public/app/features/provisioning/Shared/FolderRepositoryList.tsx @@ -16,10 +16,10 @@ interface Props { export function FolderRepositoryList({ items }: Props) { const [query, setQuery] = useState(''); const filteredItems = items.filter((item) => item.metadata?.name?.includes(query)); - const instanceProvisioned = checkSyncSettings(items); + const { instanceConnected } = checkSyncSettings(items); return ( - {!instanceProvisioned && ( + {!instanceConnected && (