Folders: Migrate bulk delete actions (#109525)

* Split delete items call and use new API

* add test

* Add translation

* Remove unnecessary array spread

* move check provisioned into util function

* wip conflict fix

* Undo file rename

* Remove unneeded handler for now

* Fix delete invalidating getFolder calls

* Don't call hooks conditionally

---------

Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com>
This commit is contained in:
Andrej Ocenas 2025-08-25 13:55:46 +02:00 committed by GitHub
parent 1e0587001d
commit dcea3315fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 221 additions and 53 deletions

View file

@ -1,12 +1,34 @@
import { renderHook, getWrapper, waitFor } from 'test/test-utils';
import { AppEvents } from '@grafana/data';
import { config, setBackendSrv } from '@grafana/runtime';
import { setupMockServer } from '@grafana/test-utils/server';
import { getFolderFixtures } from '@grafana/test-utils/unstable';
import { backendSrv } from 'app/core/services/backend_srv';
import { useDeleteFoldersMutation as useDeleteFoldersMutationLegacy } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
import { useGetFolderQueryFacade } from './hooks';
import { useGetFolderQueryFacade, useDeleteMultipleFoldersMutationFacade } from './hooks';
import { useDeleteFolderMutation } from './index';
// Mocks for the hooks used inside useGetFolderQueryFacade
jest.mock('./index', () => ({
...jest.requireActual('./index'),
useDeleteFolderMutation: jest.fn(),
}));
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getAppEvents: jest.fn(() => ({
publish: jest.fn(),
})),
}));
const mockGetAppEvents = jest.mocked(require('@grafana/runtime').getAppEvents);
jest.mock('app/features/browse-dashboards/api/browseDashboardsAPI', () => ({
...jest.requireActual('app/features/browse-dashboards/api/browseDashboardsAPI'),
useDeleteFoldersMutation: jest.fn(),
}));
setBackendSrv(backendSrv);
setupMockServer();
@ -105,3 +127,61 @@ describe('useGetFolderQueryFacade', () => {
});
});
});
describe('useDeleteMultipleFoldersMutationFacade', () => {
const dispatchMock = jest.fn();
const mockDeleteFolder = jest.fn(() => ({ error: undefined }));
const mockDeleteFolderLegacy = jest.fn(() => ({ error: undefined }));
const publishMock = jest.fn();
const oldToggleValue = config.featureToggles.foldersAppPlatformAPI;
afterAll(() => {
config.featureToggles.foldersAppPlatformAPI = oldToggleValue;
});
beforeEach(() => {
mockDeleteFolder.mockClear();
mockDeleteFolderLegacy.mockClear();
(useDeleteFolderMutation as jest.Mock).mockReturnValue([mockDeleteFolder]);
(useDeleteFoldersMutationLegacy as jest.Mock).mockReturnValue([mockDeleteFolderLegacy]);
// Mock useDispatch
jest.spyOn(require('../../../../types/store'), 'useDispatch').mockReturnValue(dispatchMock);
});
it('deletes multiple folders and publishes success alert', async () => {
mockGetAppEvents.mockReturnValue({
publish: publishMock,
});
config.featureToggles.foldersAppPlatformAPI = true;
const folderUIDs = ['uid1', 'uid2'];
const deleteFolders = useDeleteMultipleFoldersMutationFacade();
await deleteFolders({ folderUIDs });
// Should call deleteFolder for each UID
expect(mockDeleteFolder).toHaveBeenCalledTimes(folderUIDs.length);
expect(mockDeleteFolder).toHaveBeenCalledWith({ name: 'uid1' });
expect(mockDeleteFolder).toHaveBeenCalledWith({ name: 'uid2' });
// Should publish success alert
expect(publishMock).toHaveBeenCalledWith({
type: AppEvents.alertSuccess.name,
payload: ['Folder deleted'],
});
// Should dispatch refreshParents
expect(dispatchMock).toHaveBeenCalled();
});
it('uses legacy call when flag is false', async () => {
config.featureToggles.foldersAppPlatformAPI = false;
const folderUIDs = ['uid1', 'uid2'];
const deleteFolders = useDeleteMultipleFoldersMutationFacade();
await deleteFolders({ folderUIDs });
// Should call deleteFolder for each UID
expect(mockDeleteFolderLegacy).toHaveBeenCalledTimes(1);
expect(mockDeleteFolderLegacy).toHaveBeenCalledWith({ folderUIDs });
});
});

View file

@ -6,6 +6,7 @@ import { config, getAppEvents } from '@grafana/runtime';
import {
useDeleteFolderMutation as useDeleteFolderMutationLegacy,
useGetFolderQuery as useGetFolderQueryLegacy,
useDeleteFoldersMutation as useDeleteFoldersMutationLegacy,
} from 'app/features/browse-dashboards/api/browseDashboardsAPI';
import { FolderDTO } from 'app/types/folders';
@ -20,11 +21,12 @@ import {
ManagerKind,
} from '../../../../features/apiserver/types';
import { PAGE_SIZE } from '../../../../features/browse-dashboards/api/services';
import { refetchChildren } from '../../../../features/browse-dashboards/state/actions';
import { refetchChildren, refreshParents } from '../../../../features/browse-dashboards/state/actions';
import { GENERAL_FOLDER_UID } from '../../../../features/search/constants';
import { useDispatch } from '../../../../types/store';
import { useGetDisplayMappingQuery } from '../../iam/v0alpha1';
import { isProvisionedFolderCheck } from './utils';
import { rootFolder, sharedWithMeFolder } from './virtualFolders';
import { useGetFolderQuery, useGetFolderParentsQuery, useDeleteFolderMutation } from './index';
@ -190,6 +192,38 @@ export function useDeleteFolderMutationFacade() {
};
}
export function useDeleteMultipleFoldersMutationFacade() {
const [deleteFolders] = useDeleteFoldersMutationLegacy();
const [deleteFolder] = useDeleteFolderMutation();
const dispatch = useDispatch();
if (!config.featureToggles.foldersAppPlatformAPI) {
return deleteFolders;
}
return async function deleteFolders({ folderUIDs }: { folderUIDs: string[] }) {
// Delete all the folders sequentially
// TODO error handling here
for (const folderUID of folderUIDs) {
// This also shows warning alert
if (await isProvisionedFolderCheck(dispatch, folderUID)) {
continue;
}
const result = await deleteFolder({ name: folderUID });
if (!result.error) {
// Before this was done in backend srv automatically because the old API sent a message wiht 200 request. see
// public/app/core/services/backend_srv.ts#L341-L361. New API does not do that so we do it here.
getAppEvents().publish({
type: AppEvents.alertSuccess.name,
payload: [t('folders.api.folder-deleted-success', 'Folder deleted')],
});
dispatch(refreshParents(folderUIDs));
}
}
return { data: undefined };
};
}
function combinedState(
result: ReturnType<typeof useGetFolderQuery>,
resultParents: ReturnType<typeof useGetFolderParentsQuery>,

View file

@ -1,6 +1,25 @@
import { generatedAPI } from './endpoints.gen';
export const folderAPIv1beta1 = generatedAPI.enhanceEndpoints({});
export const folderAPIv1beta1 = generatedAPI.enhanceEndpoints({
endpoints: {
getFolder: {
providesTags: (result, error, arg) => (result ? [{ type: 'Folder', id: arg.name }] : []),
},
listFolder: {
providesTags: (result) =>
result
? [
{ type: 'Folder', id: 'LIST' },
...result.items.map((folder) => ({ type: 'Folder' as const, id: folder.metadata?.name })).filter(Boolean),
]
: [{ type: 'Folder', id: 'LIST' }],
},
deleteFolder: {
// We don't want delete to invalidate getFolder tags, as that would lead to unnecessary 404s
invalidatesTags: (result, error) => (error ? [] : [{ type: 'Folder', id: 'LIST' }]),
},
},
});
export const { useGetFolderQuery, useGetFolderParentsQuery, useDeleteFolderMutation } = folderAPIv1beta1;

View file

@ -0,0 +1,31 @@
import { AppEvents } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import appEvents from '../../../../core/app_events';
import { isProvisionedFolder } from '../../../../features/browse-dashboards/api/isProvisioned';
import { useDispatch } from '../../../../types/store';
import { folderAPIv1beta1 as folderAPI } from './index';
export async function isProvisionedFolderCheck(dispatch: ReturnType<typeof useDispatch>, folderUID: string) {
if (config.featureToggles.provisioning) {
const folder = await dispatch(folderAPI.endpoints.getFolder.initiate({ name: folderUID }));
// TODO: taken from browseDashboardAPI as it is, but this error handling should be moved up to UI code.
if (folder.data && isProvisionedFolder(folder.data)) {
appEvents.publish({
type: AppEvents.alertWarning.name,
payload: [
t(
'folders.api.folder-delete-error-provisioned',
'Cannot delete provisioned folder. To remove it, delete it from the repository and synchronise to apply the changes.'
),
],
});
return true;
}
return false;
} else {
return false;
}
}

View file

@ -6,6 +6,7 @@ import { config, getBackendSrv, isFetchError, locationService } from '@grafana/r
import { Dashboard } from '@grafana/schema';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { folderAPIv1beta1 as folderAPI } from 'app/api/clients/folder/v1beta1';
import { isProvisionedFolderCheck } from 'app/api/clients/folder/v1beta1/utils';
import { createBaseQuery, handleRequestError } from 'app/api/createBaseQuery';
import appEvents from 'app/core/app_events';
import { contextSrv } from 'app/core/core';
@ -26,12 +27,17 @@ import { DashboardTreeSelection } from '../types';
import { isProvisionedDashboard, isProvisionedFolder } from './isProvisioned';
import { PAGE_SIZE } from './services';
interface DeleteItemsArgs {
selectedItems: Omit<DashboardTreeSelection, 'panel' | '$all'>;
interface DeleteFoldersArgs {
folderUIDs: string[];
}
interface MoveItemsArgs extends DeleteItemsArgs {
interface DeleteDashboardsArgs {
dashboardUIDs: string[];
}
interface MoveItemsArgs {
destinationUID: string;
selectedItems: Omit<DashboardTreeSelection, 'panel' | '$all'>;
}
export interface ImportInputs {
@ -281,27 +287,16 @@ export const browseDashboardsAPI = createApi({
},
}),
// delete *multiple* items (folders and dashboards). used in the delete modal.
deleteItems: builder.mutation<void, DeleteItemsArgs>({
// delete *multiple* folders. used in the delete modal.
deleteFolders: builder.mutation<void, DeleteFoldersArgs>({
invalidatesTags: ['getFolder'],
queryFn: async ({ selectedItems }, _api, _extraOptions, baseQuery) => {
const selectedDashboards = Object.keys(selectedItems.dashboard).filter((uid) => selectedItems.dashboard[uid]);
const selectedFolders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]);
const pageStateManager = getDashboardScenePageStateManager();
queryFn: async ({ folderUIDs }, _api, _extraOptions, baseQuery) => {
// Delete all the folders sequentially
// TODO error handling here
for (const folderUID of selectedFolders) {
if (config.featureToggles.provisioning) {
const folder = await dispatch(folderAPI.endpoints.getFolder.initiate({ name: folderUID }));
if (isProvisionedFolder(folder.data)) {
appEvents.publish({
type: AppEvents.alertWarning.name,
payload: [
'Cannot delete provisioned folder. To remove it, delete it from the repository and synchronise to apply the changes.',
],
});
continue;
}
for (const folderUID of folderUIDs) {
// This also shows warning alert
if (await isProvisionedFolderCheck(dispatch, folderUID)) {
continue;
}
await baseQuery({
url: `/folders/${folderUID}`,
@ -313,9 +308,23 @@ export const browseDashboardsAPI = createApi({
},
});
}
return { data: undefined };
},
onQueryStarted: ({ folderUIDs }, { queryFulfilled, dispatch }) => {
queryFulfilled.then(() => {
dispatch(refreshParents(folderUIDs));
});
},
}),
// delete *multiple* dashboards. used in the delete modal.
deleteDashboards: builder.mutation<void, DeleteDashboardsArgs>({
invalidatesTags: ['getFolder'],
queryFn: async ({ dashboardUIDs }, _api, _extraOptions, baseQuery) => {
const pageStateManager = getDashboardScenePageStateManager();
// Delete all the dashboards sequentially
// TODO error handling here
for (const dashboardUID of selectedDashboards) {
for (const dashboardUID of dashboardUIDs) {
if (config.featureToggles.provisioning) {
const dto = await getDashboardAPI().getDashboardDTO(dashboardUID);
if (isProvisionedDashboard(dto)) {
@ -346,11 +355,9 @@ export const browseDashboardsAPI = createApi({
}
return { data: undefined };
},
onQueryStarted: ({ selectedItems }, { queryFulfilled, dispatch }) => {
const selectedDashboards = Object.keys(selectedItems.dashboard).filter((uid) => selectedItems.dashboard[uid]);
const selectedFolders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]);
onQueryStarted: ({ dashboardUIDs }, { queryFulfilled, dispatch }) => {
queryFulfilled.then(() => {
dispatch(refreshParents([...selectedFolders, ...selectedDashboards]));
dispatch(refreshParents(dashboardUIDs));
});
},
}),
@ -487,7 +494,8 @@ export const browseDashboardsAPI = createApi({
export const {
endpoints,
useDeleteFolderMutation,
useDeleteItemsMutation,
useDeleteFoldersMutation,
useDeleteDashboardsMutation,
useGetAffectedItemsQuery,
useGetFolderQuery,
useLazyGetFolderQuery,

View file

@ -13,7 +13,8 @@ import { ShowModalReactEvent } from 'app/types/events';
import { FolderDTO } from 'app/types/folders';
import { useDispatch } from 'app/types/store';
import { useDeleteItemsMutation, useMoveItemsMutation } from '../../api/browseDashboardsAPI';
import { useDeleteMultipleFoldersMutationFacade } from '../../../../api/clients/folder/v1beta1/hooks';
import { useDeleteDashboardsMutation, useMoveItemsMutation } from '../../api/browseDashboardsAPI';
import { useActionSelectionState } from '../../state/hooks';
import { setAllSelection } from '../../state/slice';
import { DashboardTreeSelection } from '../../types';
@ -32,7 +33,8 @@ export function BrowseActions({ folderDTO }: Props) {
const dispatch = useDispatch();
const selectedItems = useActionSelectionState();
const [deleteItems] = useDeleteItemsMutation();
const [deleteDashboards] = useDeleteDashboardsMutation();
const deleteFolders = useDeleteMultipleFoldersMutationFacade();
const [moveItems] = useMoveItemsMutation();
const [, stateManager] = useSearchStateManager();
const provisioningEnabled = config.featureToggles.provisioning;
@ -54,7 +56,10 @@ export function BrowseActions({ folderDTO }: Props) {
};
const onDelete = async () => {
await deleteItems({ selectedItems });
const selectedDashboards = Object.keys(selectedItems.dashboard).filter((uid) => selectedItems.dashboard[uid]);
const selectedFolders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]);
await deleteDashboards({ dashboardUIDs: selectedDashboards });
await deleteFolders({ folderUIDs: selectedFolders });
trackAction('delete', selectedItems);
onActionComplete();
};

View file

@ -2,6 +2,10 @@ import { CellProps, Column, HeaderProps } from 'react-table';
import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
/**
* Object of what is selected in the tree. It is record where keys are categories from DashboardViewItemKind and
* each category is a record where the key is the UID of the object and value is whether it is selected or not.
*/
export type DashboardTreeSelection = Record<DashboardViewItemKind, Record<string, boolean | undefined>> & {
$all: boolean;
};

View file

@ -6,7 +6,7 @@ import { config, reportInteraction } from '@grafana/runtime';
import { Button, ConfirmModal, Modal, Space, Text, TextLink } from '@grafana/ui';
import { DeleteProvisionedDashboardDrawer } from 'app/features/provisioning/components/Dashboards/DeleteProvisionedDashboardDrawer';
import { useDeleteItemsMutation } from '../../browse-dashboards/api/browseDashboardsAPI';
import { useDeleteDashboardsMutation } from '../../browse-dashboards/api/browseDashboardsAPI';
import { DashboardScene } from '../scene/DashboardScene';
interface ButtonProps {
@ -26,7 +26,7 @@ interface DeleteModalProps {
export function DeleteDashboardButton({ dashboard }: ButtonProps) {
const [showModal, toggleModal] = useToggle(false);
const [deleteItems] = useDeleteItemsMutation();
const [deleteDashboards] = useDeleteDashboardsMutation();
const [, onConfirm] = useAsyncFn(async () => {
reportInteraction('grafana_manage_dashboards_delete_clicked', {
@ -38,14 +38,7 @@ export function DeleteDashboardButton({ dashboard }: ButtonProps) {
});
toggleModal();
if (dashboard.state.uid) {
await deleteItems({
selectedItems: {
dashboard: {
[dashboard.state.uid]: true,
},
folder: {},
},
});
await deleteDashboards({ dashboardUIDs: [dashboard.state.uid] });
}
await dashboard.onDashboardDelete();
}, [dashboard, toggleModal]);

View file

@ -8,7 +8,7 @@ import { Modal, Button, Text, Space, TextLink } from '@grafana/ui';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { cleanUpDashboardAndVariables } from 'app/features/dashboard/state/actions';
import { useDeleteItemsMutation } from '../../../browse-dashboards/api/browseDashboardsAPI';
import { useDeleteDashboardsMutation } from '../../../browse-dashboards/api/browseDashboardsAPI';
import { DeleteDashboardModal as DeleteModal } from '../../../dashboard-scene/settings/DeleteDashboardButton';
type DeleteDashboardModalProps = {
@ -26,7 +26,7 @@ type Props = DeleteDashboardModalProps & ConnectedProps<typeof connector>;
const DeleteDashboardModalUnconnected = ({ hideModal, cleanUpDashboardAndVariables, dashboard }: Props) => {
const isProvisioned = dashboard.meta.provisioned;
const [deleteItems] = useDeleteItemsMutation();
const [deleteDashboards] = useDeleteDashboardsMutation();
const [, onConfirm] = useAsyncFn(async () => {
reportInteraction('grafana_manage_dashboards_delete_clicked', {
@ -36,14 +36,7 @@ const DeleteDashboardModalUnconnected = ({ hideModal, cleanUpDashboardAndVariabl
source: 'dashboard_settings',
restore_enabled: Boolean(config.featureToggles.restoreDashboards),
});
await deleteItems({
selectedItems: {
dashboard: {
[dashboard.uid]: true,
},
folder: {},
},
});
await deleteDashboards({ dashboardUIDs: [dashboard.uid] });
cleanUpDashboardAndVariables();
hideModal();
locationService.replace('/');

View file

@ -7559,6 +7559,7 @@
},
"folders": {
"api": {
"folder-delete-error-provisioned": "Cannot delete provisioned folder. To remove it, delete it from the repository and synchronise to apply the changes.",
"folder-deleted-success": "Folder deleted"
},
"get-loading-nav": {