From 17c3f346881b8904e311c5780600078cc243dae3 Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Wed, 24 Sep 2025 04:45:00 -0600 Subject: [PATCH] Dashboards: Fix moving to root folder (#111515) --- public/app/features/dashboard/api/v1.test.ts | 71 +++++++++++++++++++- public/app/features/dashboard/api/v1.ts | 2 +- public/app/features/dashboard/api/v2.test.ts | 60 +++++++++++++++++ public/app/features/dashboard/api/v2.ts | 2 +- 4 files changed, 132 insertions(+), 3 deletions(-) diff --git a/public/app/features/dashboard/api/v1.test.ts b/public/app/features/dashboard/api/v1.test.ts index f39538a0df4..58a80fe4509 100644 --- a/public/app/features/dashboard/api/v1.test.ts +++ b/public/app/features/dashboard/api/v1.test.ts @@ -1,6 +1,6 @@ import { GrafanaConfig, locationUtil } from '@grafana/data'; import { backendSrv } from 'app/core/services/backend_srv'; -import { AnnoKeyFolder, AnnoReloadOnParamsChange } from 'app/features/apiserver/types'; +import { AnnoKeyFolder, AnnoKeyMessage, AnnoReloadOnParamsChange } from 'app/features/apiserver/types'; import { DashboardDataDTO } from 'app/types/dashboard'; import { DashboardWithAccessInfo } from './types'; @@ -316,6 +316,75 @@ describe('v1 dashboard API', () => { expect(result.url).toBe('/grafana/d/adh59cn/new-dashboard-saved'); }); }); + + it('should handle empty string folderUid for root folder', async () => { + const api = new K8sDashboardAPI(); + const saveCommand = { + dashboard: { + uid: 'test-dash', + title: 'Test Dashboard', + tags: [], + timezone: 'browser', + panels: [], + time: { from: 'now-6h', to: 'now' }, + timepicker: {}, + templating: { list: [] }, + annotations: { list: [] }, + refresh: '5s', + schemaVersion: 16, + version: 0, + links: [], + }, + folderUid: '', + message: 'Move to root folder', + }; + + await api.saveDashboard(saveCommand); + + expect(mockPut).toHaveBeenCalledTimes(1); + expect(mockPut).toHaveBeenCalledWith( + '/apis/dashboard.grafana.app/v1beta1/namespaces/default/dashboards/test-dash', + expect.objectContaining({ + metadata: expect.objectContaining({ + annotations: expect.objectContaining({ + [AnnoKeyFolder]: '', + [AnnoKeyMessage]: 'Move to root folder', + }), + }), + }), + { params: { fieldValidation: 'Ignore' } } + ); + }); + + it('should not set folder annotation when folderUid is undefined', async () => { + const api = new K8sDashboardAPI(); + const saveCommand = { + dashboard: { + uid: 'test-dash', + title: 'Test Dashboard', + tags: [], + timezone: 'browser', + panels: [], + time: { from: 'now-6h', to: 'now' }, + timepicker: {}, + templating: { list: [] }, + annotations: { list: [] }, + refresh: '5s', + schemaVersion: 16, + version: 0, + links: [], + }, + message: 'Save without folder', + }; + + await api.saveDashboard(saveCommand); + + expect(mockPut).toHaveBeenCalledTimes(1); + const callArgs = mockPut.mock.calls[0]; + const requestBody = callArgs[1]; + expect(requestBody.metadata.annotations).not.toHaveProperty(AnnoKeyFolder); + expect(requestBody.metadata.annotations[AnnoKeyMessage]).toBe('Save without folder'); + }); }); describe('version error handling', () => { diff --git a/public/app/features/dashboard/api/v1.ts b/public/app/features/dashboard/api/v1.ts index ca8d4cc0c75..aff32c27e1e 100644 --- a/public/app/features/dashboard/api/v1.ts +++ b/public/app/features/dashboard/api/v1.ts @@ -64,7 +64,7 @@ export class K8sDashboardAPI implements DashboardAPI { delete obj.metadata.annotations[AnnoKeyMessage]; } - if (options.folderUid) { + if (options.folderUid !== undefined) { obj.metadata.annotations = { ...obj.metadata.annotations, [AnnoKeyFolder]: options.folderUid, diff --git a/public/app/features/dashboard/api/v2.test.ts b/public/app/features/dashboard/api/v2.test.ts index 98efae8dc2c..9c529c407d2 100644 --- a/public/app/features/dashboard/api/v2.test.ts +++ b/public/app/features/dashboard/api/v2.test.ts @@ -252,6 +252,66 @@ describe('v2 dashboard API', () => { { params: undefined } ); }); + + it('should handle empty string folderUid for root folder', async () => { + const api = new K8sDashboardV2API(); + const saveCommand = { + dashboard: defaultDashboardV2Spec(), + folderUid: '', + message: 'Move to root folder', + k8s: { + name: 'existing-dash', + annotations: { + [AnnoKeyFolder]: 'some-previous-folder', + [AnnoKeyFolderUrl]: 'some-folder-url', + [AnnoKeyFolderTitle]: 'Some Folder Title', + }, + }, + }; + + await api.saveDashboard(saveCommand); + + expect(mockPut).toHaveBeenCalledTimes(1); + expect(mockPut).toHaveBeenCalledWith( + '/apis/dashboard.grafana.app/v2beta1/namespaces/default/dashboards/existing-dash', + { + metadata: { + name: 'existing-dash', + annotations: { + [AnnoKeyFolder]: '', + [AnnoKeyMessage]: 'Move to root folder', + [AnnoKeySavedFromUI]: '10.0.0', + }, + }, + spec: defaultDashboardV2Spec(), + }, + { params: undefined } + ); + + const callArgs = mockPut.mock.calls[0]; + const requestBody = callArgs[1]; + expect(requestBody.metadata.annotations).not.toHaveProperty(AnnoKeyFolderUrl); + expect(requestBody.metadata.annotations).not.toHaveProperty(AnnoKeyFolderTitle); + }); + + it('should not set folder annotation when folderUid is undefined', async () => { + const api = new K8sDashboardV2API(); + const saveCommand = { + dashboard: defaultDashboardV2Spec(), + message: 'Save without folder', + k8s: { + name: 'existing-dash', + }, + }; + + await api.saveDashboard(saveCommand); + expect(mockPut).toHaveBeenCalledTimes(1); + + const callArgs = mockPut.mock.calls[0]; + const requestBody = callArgs[1]; + expect(requestBody.metadata.annotations).not.toHaveProperty(AnnoKeyFolder); + expect(requestBody.metadata.annotations[AnnoKeyMessage]).toBe('Save without folder'); + }); }); describe('version error handling', () => { diff --git a/public/app/features/dashboard/api/v2.ts b/public/app/features/dashboard/api/v2.ts index 3bb640776d5..624abc43e0c 100644 --- a/public/app/features/dashboard/api/v2.ts +++ b/public/app/features/dashboard/api/v2.ts @@ -130,7 +130,7 @@ export class K8sDashboardV2API } // add folder annotation - if (options.folderUid) { + if (options.folderUid !== undefined) { // remove frontend folder annotations delete obj.metadata.annotations?.[AnnoKeyFolderTitle]; delete obj.metadata.annotations?.[AnnoKeyFolderUrl];