Folders: Deprecate getFolderByUID method (#113173)

This commit is contained in:
Tom Ratcliffe
2025-11-11 18:11:57 +00:00
committed by GitHub
parent d8363bdfcf
commit a9b52589ff
15 changed files with 134 additions and 91 deletions
+102 -43
View File
@@ -4,6 +4,7 @@ import { useEffect, useMemo } from 'react';
import { AppEvents } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config, getAppEvents } from '@grafana/runtime';
import { DisplayList, iamAPIv0alpha1, useLazyGetDisplayMappingQuery } from 'app/api/clients/iam/v0alpha1';
import { useAppNotification } from 'app/core/copy/appNotification';
import {
useDeleteFolderMutation as useDeleteFolderMutationLegacy,
@@ -17,9 +18,11 @@ import {
MoveFoldersArgs,
DeleteFoldersArgs,
MoveFolderArgs,
browseDashboardsAPI,
} from 'app/features/browse-dashboards/api/browseDashboardsAPI';
import { DashboardTreeSelection } from 'app/features/browse-dashboards/types';
import { FolderDTO, NewFolder } from 'app/types/folders';
import { dispatch } from 'app/types/store';
import kbn from '../../../../core/utils/kbn';
import {
@@ -36,12 +39,12 @@ import { refetchChildren, refreshParents } from '../../../../features/browse-das
import { GENERAL_FOLDER_UID } from '../../../../features/search/constants';
import { deletedDashboardsCache } from '../../../../features/search/service/deletedDashboardsCache';
import { useDispatch } from '../../../../types/store';
import { useLazyGetDisplayMappingQuery } from '../../iam/v0alpha1';
import { isProvisionedFolderCheck } from './utils';
import { rootFolder, sharedWithMeFolder } from './virtualFolders';
import {
folderAPIv1beta1,
useGetFolderQuery,
useGetFolderParentsQuery,
useDeleteFolderMutation,
@@ -52,6 +55,7 @@ import {
useReplaceFolderMutation,
ReplaceFolderApiArg,
useGetAffectedItemsQuery,
FolderInfo,
} from './index';
function getFolderUrl(uid: string, title: string): string {
@@ -62,6 +66,91 @@ function getFolderUrl(uid: string, title: string): string {
return `${config.appSubUrl}/dashboards/f/${uid}/${slug}`;
}
const combineFolderResponses = (
folder: Folder,
legacyFolder: FolderDTO,
parents: FolderInfo[],
userDisplay?: DisplayList
) => {
const updatedBy = folder.metadata.annotations?.[AnnoKeyUpdatedBy];
const createdBy = folder.metadata.annotations?.[AnnoKeyCreatedBy];
const newData: FolderDTO = {
canAdmin: legacyFolder.canAdmin,
canDelete: legacyFolder.canDelete,
canEdit: legacyFolder.canEdit,
canSave: legacyFolder.canSave,
accessControl: legacyFolder.accessControl,
createdBy: (createdBy && userDisplay?.display[userDisplay?.keys.indexOf(createdBy)]?.displayName) || 'Anonymous',
updatedBy: (updatedBy && userDisplay?.display[userDisplay?.keys.indexOf(updatedBy)]?.displayName) || 'Anonymous',
...appPlatformFolderToLegacyFolder(folder),
};
if (parents.length) {
newData.parents = parents
.filter((i) => i.name !== folder.metadata.name)
.map(({ name, title }) => ({
title: title,
uid: name,
// No idea how to make slug, on the server it uses a go lib: https://github.com/grafana/grafana/blob/aac66e91198004bc044754105e18bfff8fbfd383/pkg/infra/slugify/slugify.go#L56
// Don't think slug is needed for the URL to work though
url: getFolderUrl(name, title),
}));
}
return newData;
};
export async function getFolderByUidFacade(uid: string): Promise<FolderDTO> {
const isVirtualFolder = uid && [GENERAL_FOLDER_UID, config.sharedWithMeFolderUID].includes(uid);
// We need the legacy API call regardless, for now
const legacyApiCall = dispatch(browseDashboardsAPI.endpoints.getFolder.initiate(uid));
const shouldUseAppPlatformAPI = Boolean(config.featureToggles.foldersAppPlatformAPI);
if (shouldUseAppPlatformAPI) {
let virtualFolderResponse;
if (isVirtualFolder) {
virtualFolderResponse = GENERAL_FOLDER_UID === uid ? rootFolder : sharedWithMeFolder;
}
const responses = await Promise.all([
// We still need to call legacy endpoints for access control metadata
legacyApiCall,
isVirtualFolder
? Promise.resolve({ data: virtualFolderResponse })
: dispatch(folderAPIv1beta1.endpoints.getFolder.initiate({ name: uid })),
dispatch(folderAPIv1beta1.endpoints.getFolderParents.initiate({ name: uid })),
]);
const [legacyFolderResponse, folderResponse, parentsResponse] = responses;
if (!folderResponse?.data || !legacyFolderResponse?.data || !parentsResponse?.data) {
throw new Error('One of the folder responses is undefined');
}
const userKeys = getUserKeys(folderResponse.data);
let userResponse;
if (userKeys.length) {
userResponse = await dispatch(iamAPIv0alpha1.endpoints.getDisplayMapping.initiate({ key: userKeys }));
}
return combineFolderResponses(
folderResponse.data,
legacyFolderResponse.data,
parentsResponse.data.items,
userResponse?.data
);
}
const legacyFolderResponse = await legacyApiCall;
if (legacyFolderResponse.error || !legacyFolderResponse.data) {
throw legacyFolderResponse.error || new Error('Legacy folder response is undefined');
}
return legacyFolderResponse.data;
}
/**
* A proxy function that uses either legacy folder client or the new app platform APIs to get the data in the same
* format of a FolderDTO object. As the schema isn't the same, using the app platform needs multiple different calls
@@ -84,12 +173,12 @@ export function useGetFolderQueryFacade(uid?: string) {
const [triggerGetUserDisplayMapping, resultUserDisplay] = useLazyGetDisplayMappingQuery();
const needsUserData = useMemo(() => {
const userKeys = getUserKeys(resultFolder);
const userKeys = getUserKeys(resultFolder.data);
return !isVirtualFolder && Boolean(userKeys.length);
}, [isVirtualFolder, resultFolder]);
useEffect(() => {
const userKeys = getUserKeys(resultFolder);
const userKeys = getUserKeys(resultFolder.data);
if (needsUserData && userKeys.length) {
triggerGetUserDisplayMapping({ key: userKeys }, true);
}
@@ -125,39 +214,12 @@ export function useGetFolderQueryFacade(uid?: string) {
legacyFolderResult.data &&
(needsUserData ? resultUserDisplay.data : true)
) {
const updatedBy = resultFolder.data.metadata.annotations?.[AnnoKeyUpdatedBy];
const createdBy = resultFolder.data.metadata.annotations?.[AnnoKeyCreatedBy];
const parsed = appPlatformFolderToLegacyFolder(resultFolder.data);
newData = {
canAdmin: legacyFolderResult.data.canAdmin,
canDelete: legacyFolderResult.data.canDelete,
canEdit: legacyFolderResult.data.canEdit,
canSave: legacyFolderResult.data.canSave,
accessControl: legacyFolderResult.data.accessControl,
createdBy:
(createdBy && resultUserDisplay.data?.display[resultUserDisplay.data?.keys.indexOf(createdBy)]?.displayName) ||
'Anonymous',
updatedBy:
(updatedBy && resultUserDisplay.data?.display[resultUserDisplay.data?.keys.indexOf(updatedBy)]?.displayName) ||
'Anonymous',
...parsed,
};
if (resultParents.data.items?.length) {
newData.parents = resultParents.data.items
.filter((i) => i.name !== resultFolder.data!.metadata.name)
.map((i) => ({
title: i.title,
uid: i.name,
// No idea how to make slug, on the server it uses a go lib: https://github.com/grafana/grafana/blob/aac66e91198004bc044754105e18bfff8fbfd383/pkg/infra/slugify/slugify.go#L56
// Don't think slug is needed for the URL to work though
url: getFolderUrl(i.name, i.title),
}));
}
newData = combineFolderResponses(
resultFolder.data,
legacyFolderResult.data,
resultParents.data.items,
resultUserDisplay.data
);
}
// Wrap the stitched data into single RTK query response type object so this looks like a single API call
@@ -440,13 +502,10 @@ function combinedState(
};
}
function getUserKeys(resultFolder: ReturnType<typeof useGetFolderQuery>): string[] {
return resultFolder.data
? [
resultFolder.data.metadata.annotations?.[AnnoKeyUpdatedBy],
resultFolder.data.metadata.annotations?.[AnnoKeyCreatedBy],
].filter((v) => v !== undefined)
: [];
function getUserKeys(folder?: Folder): string[] {
return [folder?.metadata.annotations?.[AnnoKeyUpdatedBy], folder?.metadata.annotations?.[AnnoKeyCreatedBy]].filter(
(v) => v !== undefined
);
}
const appPlatformFolderToLegacyFolder = (
@@ -80,9 +80,4 @@ export const {
} = folderAPIv1beta1;
// eslint-disable-next-line no-barrel-files/no-barrel-files
export {
type Folder,
type FolderList,
type CreateFolderApiArg,
type ReplaceFolderApiArg,
} from '@grafana/api-clients/rtkq/folder/v1beta1';
export * from '@grafana/api-clients/rtkq/folder/v1beta1';
@@ -3,3 +3,6 @@ import { generatedAPI } from '@grafana/api-clients/rtkq/iam/v0alpha1';
export const iamAPIv0alpha1 = generatedAPI.enhanceEndpoints({});
export const { useGetDisplayMappingQuery, useLazyGetDisplayMappingQuery } = iamAPIv0alpha1;
// eslint-disable-next-line no-barrel-files/no-barrel-files
export type { DisplayList } from '@grafana/api-clients/rtkq/iam/v0alpha1';
+5
View File
@@ -654,7 +654,12 @@ export class BackendSrv implements BackendService {
return this.get<DashboardDTO>(`/api/public/dashboards/${uid}`);
}
/**
* @deprecated Use getFolderByUidFacade from app/api/clients/folder/v1beta1/hooks instead
* or manually handle calling legacy vs app platform API based on feature toggles
*/
getFolderByUid(uid: string, options: FolderRequestOptions = {}) {
deprecationWarning('backend_srv', 'getFolderByUid(uid)', 'getFolderByUidFacade(uid)');
const queryParams = new URLSearchParams();
if (options.withAccessControl) {
queryParams.set('accesscontrol', 'true');
@@ -4,7 +4,7 @@ import { ComponentProps } from 'react';
import { Trans, t } from '@grafana/i18n';
import { locationService } from '@grafana/runtime';
import { Alert, CodeEditor, ConfirmModal, Stack, useStyles2 } from '@grafana/ui';
import { backendSrv } from 'app/core/services/backend_srv';
import { useGetFolderQueryFacade } from 'app/api/clients/folder/v1beta1/hooks';
import { getMessageFromError } from 'app/core/utils/errors';
import { useAsync } from 'app/features/alerting/unified/hooks/useAsync';
import { GrafanaRuleDefinition, RulerGrafanaRuleDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto';
@@ -32,6 +32,7 @@ export const ConfirmRestoreDeletedRuleModal = ({
onRestoreError,
}: ModalProps) => {
const [restoreMethod, { error }] = useRestoreDeletedRule();
const { data: folder } = useGetFolderQueryFacade(ruleToRestore?.grafana_alert.namespace_uid);
const title = t('alerting.deleted-rules.restore-modal.title', 'Restore deleted alert rule');
const errorTitle = t('alerting.deleted-rules.restore-modal.error', 'Could not restore deleted alert rule');
@@ -60,7 +61,7 @@ export const ConfirmRestoreDeletedRuleModal = ({
if (!ruleToRestore) {
return;
}
await redirectToRestoreForm(ruleToRestore);
await redirectToRestoreForm(ruleToRestore, folder?.title ?? '');
}
return (
@@ -141,11 +142,8 @@ export function useRestoreDeletedRule() {
});
}
const redirectToRestoreForm = async (ruleToRecover: RulerGrafanaRuleDTO) => {
const redirectToRestoreForm = async (ruleToRecover: RulerGrafanaRuleDTO, namespaceName: string) => {
let formValues: Partial<RuleFormValues> | undefined;
const namespaceName = await backendSrv
.getFolderByUid(ruleToRecover.grafana_alert.namespace_uid)
.then((folder) => folder.title);
try {
formValues = grafanaRuleDtoToFormValues(ruleToRecover, namespaceName);
@@ -1,4 +1,4 @@
import { backendSrv } from 'app/core/services/backend_srv';
import * as folderHooks from 'app/api/clients/folder/v1beta1/hooks';
import { configureStore } from 'app/store/configureStore';
import { updateNavModel } from './utils';
@@ -7,7 +7,7 @@ describe('utils', () => {
it('Should update nav model', async () => {
const reduxStore = configureStore();
jest.spyOn(backendSrv, 'getFolderByUid').mockResolvedValue({
jest.spyOn(folderHooks, 'getFolderByUidFacade').mockResolvedValue({
id: 1,
uid: 'new-folder',
title: 'NewFolder',
@@ -1,13 +1,13 @@
import { UrlQueryMap, getTimeZone, getDefaultTimeRange, dateMath } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { getFolderByUidFacade } from 'app/api/clients/folder/v1beta1/hooks';
import { updateNavIndex } from 'app/core/actions';
import { backendSrv } from 'app/core/services/backend_srv';
import { buildNavModel } from 'app/features/folders/state/navModel';
import { store } from 'app/store/store';
export async function updateNavModel(folderUid: string) {
try {
const folder = await backendSrv.getFolderByUid(folderUid);
const folder = await getFolderByUidFacade(folderUid);
store.dispatch(updateNavIndex(buildNavModel(folder)));
} catch (err) {
console.warn('Error fetching parent folder', folderUid, 'for dashboard', err);
@@ -4,7 +4,6 @@ import { byTestId } from 'testing-library-selector';
import { PromOptions } from '@grafana/prometheus';
import { config, locationService, setPluginLinksHook } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv';
import * as ruler from 'app/features/alerting/unified/api/ruler';
import * as ruleActionButtons from 'app/features/alerting/unified/components/rules/RuleActionsButtons';
import * as alertingAbilities from 'app/features/alerting/unified/hooks/useAbilities';
@@ -12,7 +11,6 @@ import { mockAlertRuleApi, setupMswServer } from 'app/features/alerting/unified/
import {
grantUserPermissions,
mockDataSource,
mockFolder,
mockPromAlert,
mockPromAlertingRule,
mockRulerAlertingRule,
@@ -190,7 +188,6 @@ describe('PanelAlertTabContent', () => {
AccessControlAction.AlertingRuleExternalWrite,
]);
jest.spyOn(backendSrv, 'getFolderByUid').mockResolvedValue(mockFolder());
setupDataSources(...Object.values(dataSources));
mocks.rulerBuilderMock.mockReturnValue({
@@ -89,12 +89,7 @@ describe('UnifiedDashboardAPI', () => {
};
v1Client.getDashboardDTO.mockRejectedValue(new DashboardVersionError('v2beta1', 'Dashboard is V1 format'));
v2Client.getDashboardDTO.mockImplementation((params) => {
const actualClient = jest.requireActual('./v2').K8sDashboardV2API;
const client = new actualClient();
return client.getDashboardDTO(params);
});
mockBackendSrvGet = mockV2Response;
v2Client.getDashboardDTO.mockResolvedValue(mockV2Response as DashboardWithAccessInfo<DashboardV2Spec>);
const result = await api.getDashboardDTO('123');
expect(result).toEqual(mockV2Response);
expect(v2Client.getDashboardDTO).toHaveBeenCalledWith('123');
+4 -3
View File
@@ -1,4 +1,5 @@
import { GrafanaConfig, locationUtil } from '@grafana/data';
import * as folderHooks from 'app/api/clients/folder/v1beta1/hooks';
import { backendSrv } from 'app/core/services/backend_srv';
import { AnnoKeyFolder, AnnoKeyMessage, AnnoReloadOnParamsChange } from 'app/features/apiserver/types';
import { DashboardDataDTO } from 'app/types/dashboard';
@@ -128,7 +129,7 @@ describe('v1 dashboard API', () => {
},
});
jest.spyOn(backendSrv, 'getFolderByUid').mockResolvedValueOnce({
jest.spyOn(folderHooks, 'getFolderByUidFacade').mockResolvedValueOnce({
id: 1,
uid: 'new-folder',
title: 'New Folder',
@@ -174,7 +175,7 @@ describe('v1 dashboard API', () => {
},
});
jest
.spyOn(backendSrv, 'getFolderByUid')
.spyOn(folderHooks, 'getFolderByUidFacade')
.mockRejectedValueOnce({ message: 'folder not found', status: 'not-found' });
const api = new K8sDashboardAPI();
@@ -186,7 +187,7 @@ describe('v1 dashboard API', () => {
...mockDashboardDto,
metadata: { ...mockDashboardDto.metadata, annotations: { [AnnoKeyFolder]: 'new-folder' } },
});
jest.spyOn(backendSrv, 'getFolderByUid').mockRejectedValueOnce({ message: 'folder not found', status: 403 });
jest.spyOn(folderHooks, 'getFolderByUidFacade').mockRejectedValueOnce({ message: 'folder not found', status: 403 });
const api = new K8sDashboardAPI();
const dashboardDTO = await api.getDashboardDTO('test');
+2 -2
View File
@@ -2,7 +2,7 @@ import { locationUtil, UrlQueryMap } from '@grafana/data';
import { t } from '@grafana/i18n';
import { Dashboard } from '@grafana/schema';
import { Status } from '@grafana/schema/src/schema/dashboard/v2';
import { backendSrv } from 'app/core/services/backend_srv';
import { getFolderByUidFacade } from 'app/api/clients/folder/v1beta1/hooks';
import { getMessageFromError, getStatusFromError } from 'app/core/utils/errors';
import { ScopedResourceClient } from 'app/features/apiserver/client';
import {
@@ -166,7 +166,7 @@ export class K8sDashboardAPI implements DashboardAPI<DashboardDTO, Dashboard> {
if (dash.metadata.annotations?.[AnnoKeyFolder]) {
try {
const folder = await backendSrv.getFolderByUid(dash.metadata.annotations[AnnoKeyFolder]);
const folder = await getFolderByUidFacade(dash.metadata.annotations[AnnoKeyFolder]);
result.meta.folderTitle = folder.title;
result.meta.folderUrl = folder.url;
result.meta.folderUid = folder.uid;
+4 -4
View File
@@ -2,7 +2,7 @@ import {
Spec as DashboardV2Spec,
defaultSpec as defaultDashboardV2Spec,
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { backendSrv } from 'app/core/services/backend_srv';
import * as folderHooks from 'app/api/clients/folder/v1beta1/hooks';
import {
AnnoKeyFolder,
AnnoKeyFolderTitle,
@@ -101,7 +101,7 @@ describe('v2 dashboard API', () => {
},
});
jest.spyOn(backendSrv, 'getFolderByUid').mockResolvedValue({
jest.spyOn(folderHooks, 'getFolderByUidFacade').mockResolvedValue({
id: 1,
uid: 'new-folder',
title: 'New Folder',
@@ -136,7 +136,7 @@ describe('v2 dashboard API', () => {
},
});
jest
.spyOn(backendSrv, 'getFolderByUid')
.spyOn(folderHooks, 'getFolderByUidFacade')
.mockRejectedValueOnce({ message: 'folder not found', status: 'not-found' });
const api = new K8sDashboardV2API();
@@ -148,7 +148,7 @@ describe('v2 dashboard API', () => {
...mockDashboardDto,
metadata: { ...mockDashboardDto.metadata, annotations: { [AnnoKeyFolder]: 'new-folder' } },
});
jest.spyOn(backendSrv, 'getFolderByUid').mockRejectedValueOnce({ message: 'folder not found', status: 403 });
jest.spyOn(folderHooks, 'getFolderByUidFacade').mockRejectedValueOnce({ message: 'folder not found', status: 403 });
const api = new K8sDashboardV2API();
const dashboardDTO = await api.getDashboardDTO('test');
+2 -2
View File
@@ -2,7 +2,7 @@ import { locationUtil } from '@grafana/data';
import { t } from '@grafana/i18n';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { Status } from '@grafana/schema/src/schema/dashboard/v2';
import { backendSrv } from 'app/core/services/backend_srv';
import { getFolderByUidFacade } from 'app/api/clients/folder/v1beta1/hooks';
import { getMessageFromError, getStatusFromError } from 'app/core/utils/errors';
import { ScopedResourceClient } from 'app/features/apiserver/client';
import {
@@ -59,7 +59,7 @@ export class K8sDashboardV2API
// load folder info if available
if (dashboard.metadata.annotations && dashboard.metadata.annotations[AnnoKeyFolder]) {
try {
const folder = await backendSrv.getFolderByUid(dashboard.metadata.annotations[AnnoKeyFolder]);
const folder = await getFolderByUidFacade(dashboard.metadata.annotations[AnnoKeyFolder]);
dashboard.metadata.annotations[AnnoKeyFolderTitle] = folder.title;
dashboard.metadata.annotations[AnnoKeyFolderUrl] = folder.url;
} catch (e) {
-12
View File
@@ -1,12 +0,0 @@
import { backendSrv } from 'app/core/services/backend_srv';
import { buildNavModel } from '../folders/state/navModel';
export const loadFolderPage = (uid: string) => {
return backendSrv.getFolderByUid(uid, { withAccessControl: true }).then((folder) => {
const navModel = buildNavModel(folder);
navModel.children![0].active = true;
return { folder, folderNav: navModel };
});
};
+2
View File
@@ -18,6 +18,7 @@ import { ThunkAction, ThunkDispatch as GenericThunkDispatch } from 'redux-thunk'
import type { createRootReducer } from 'app/core/reducers/root';
import { AppDispatch, RootState } from 'app/store/configureStore';
import { dispatch as storeDispatch } from 'app/store/store';
export type StoreState = ReturnType<ReturnType<typeof createRootReducer>>;
@@ -41,3 +42,4 @@ export const createAsyncThunk = <Returned, ThunkArg = void, ThunkApiConfig exten
createAsyncThunkUntyped<Returned, ThunkArg, ThunkApiConfig>(typePrefix, payloadCreator, options);
export const addListener = addListenerUntyped as TypedAddListener<RootState, AppDispatch>;
export const dispatch: AppDispatch = storeDispatch;