diff --git a/public/app/api/clients/folder/v1beta1/hooks.test.ts b/public/app/api/clients/folder/v1beta1/hooks.test.ts index 5c3936a4847..5c7a4a2bf61 100644 --- a/public/app/api/clients/folder/v1beta1/hooks.test.ts +++ b/public/app/api/clients/folder/v1beta1/hooks.test.ts @@ -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 }); + }); +}); diff --git a/public/app/api/clients/folder/v1beta1/hooks.ts b/public/app/api/clients/folder/v1beta1/hooks.ts index bda84c1c1da..ae7c6cd9859 100644 --- a/public/app/api/clients/folder/v1beta1/hooks.ts +++ b/public/app/api/clients/folder/v1beta1/hooks.ts @@ -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, resultParents: ReturnType, diff --git a/public/app/api/clients/folder/v1beta1/index.ts b/public/app/api/clients/folder/v1beta1/index.ts index 9e693879053..748159f1b46 100644 --- a/public/app/api/clients/folder/v1beta1/index.ts +++ b/public/app/api/clients/folder/v1beta1/index.ts @@ -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; diff --git a/public/app/api/clients/folder/v1beta1/utils.ts b/public/app/api/clients/folder/v1beta1/utils.ts new file mode 100644 index 00000000000..96606671723 --- /dev/null +++ b/public/app/api/clients/folder/v1beta1/utils.ts @@ -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, 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; + } +} diff --git a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts index 19b4ab2880f..0c91b1414e8 100644 --- a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts +++ b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts @@ -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; +interface DeleteFoldersArgs { + folderUIDs: string[]; } -interface MoveItemsArgs extends DeleteItemsArgs { +interface DeleteDashboardsArgs { + dashboardUIDs: string[]; +} + +interface MoveItemsArgs { destinationUID: string; + selectedItems: Omit; } 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({ + // delete *multiple* folders. used in the delete modal. + deleteFolders: builder.mutation({ 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({ + 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, diff --git a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx index ad0a72df516..7a1fef2634d 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx @@ -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(); }; diff --git a/public/app/features/browse-dashboards/types.ts b/public/app/features/browse-dashboards/types.ts index 5aece134d5e..960cd9713da 100644 --- a/public/app/features/browse-dashboards/types.ts +++ b/public/app/features/browse-dashboards/types.ts @@ -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> & { $all: boolean; }; diff --git a/public/app/features/dashboard-scene/settings/DeleteDashboardButton.tsx b/public/app/features/dashboard-scene/settings/DeleteDashboardButton.tsx index 5f88d334eae..28b0cbbfecc 100644 --- a/public/app/features/dashboard-scene/settings/DeleteDashboardButton.tsx +++ b/public/app/features/dashboard-scene/settings/DeleteDashboardButton.tsx @@ -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]); diff --git a/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardModal.tsx b/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardModal.tsx index 802aed03ec1..176f40ecdd9 100644 --- a/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardModal.tsx +++ b/public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardModal.tsx @@ -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; 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('/'); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index b5a8cf1376d..5dde61069ec 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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": {