diff --git a/api/src/chat/controllers/category.contoller.spec.ts b/api/src/chat/controllers/category.contoller.spec.ts index 0f764030..e4f1b1f8 100644 --- a/api/src/chat/controllers/category.contoller.spec.ts +++ b/api/src/chat/controllers/category.contoller.spec.ts @@ -6,7 +6,7 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ -import { NotFoundException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { MongooseModule } from '@nestjs/mongoose'; import { Test } from '@nestjs/testing'; @@ -185,6 +185,44 @@ describe('CategoryController', () => { }); }); + describe('deleteMany', () => { + it('should delete multiple categories by ids', async () => { + const deleteResult = { acknowledged: true, deletedCount: 2 }; + jest.spyOn(categoryService, 'deleteMany').mockResolvedValue(deleteResult); + + const result = await categoryController.deleteMany([ + category.id, + categoryToDelete.id, + ]); + + expect(categoryService.deleteMany).toHaveBeenCalledWith({ + _id: { $in: [category.id, categoryToDelete.id] }, + }); + expect(result).toEqual(deleteResult); + }); + + it('should throw a NotFoundException when no categories are deleted', async () => { + const deleteResult = { acknowledged: true, deletedCount: 0 }; + jest.spyOn(categoryService, 'deleteMany').mockResolvedValue(deleteResult); + + await expect( + categoryController.deleteMany([category.id, categoryToDelete.id]), + ).rejects.toThrow( + new NotFoundException('Categories with provided IDs not found'), + ); + + expect(categoryService.deleteMany).toHaveBeenCalledWith({ + _id: { $in: [category.id, categoryToDelete.id] }, + }); + }); + + it('should throw a BadRequestException when no ids are provided', async () => { + await expect(categoryController.deleteMany([])).rejects.toThrow( + new BadRequestException('No IDs provided for deletion.'), + ); + }); + }); + describe('updateOne', () => { const categoryUpdateDto: CategoryUpdateDto = { builtin: false, diff --git a/api/src/chat/controllers/category.controller.ts b/api/src/chat/controllers/category.controller.ts index e4c8fdfa..bc49ca01 100644 --- a/api/src/chat/controllers/category.controller.ts +++ b/api/src/chat/controllers/category.controller.ts @@ -7,6 +7,7 @@ */ import { + BadRequestException, Body, Controller, Delete, @@ -137,4 +138,29 @@ export class CategoryController extends BaseController { } return result; } + + /** + * Deletes multiple categories by their IDs. + * @param ids - IDs of categories to be deleted. + * @returns A Promise that resolves to the deletion result. + */ + @CsrfCheck(true) + @Delete('') + @HttpCode(204) + async deleteMany(@Body('ids') ids: string[]): Promise { + if (!ids || ids.length === 0) { + throw new BadRequestException('No IDs provided for deletion.'); + } + const deleteResult = await this.categoryService.deleteMany({ + _id: { $in: ids }, + }); + + if (deleteResult.deletedCount === 0) { + this.logger.warn(`Unable to delete categories with provided IDs: ${ids}`); + throw new NotFoundException('Categories with provided IDs not found'); + } + + this.logger.log(`Successfully deleted categories with IDs: ${ids}`); + return deleteResult; + } } diff --git a/api/src/chat/repositories/category.repository.ts b/api/src/chat/repositories/category.repository.ts index 74e134e0..6147b63d 100644 --- a/api/src/chat/repositories/category.repository.ts +++ b/api/src/chat/repositories/category.repository.ts @@ -37,7 +37,7 @@ export class CategoryRepository extends BaseRepository { * @param criteria - The filter criteria for finding blocks to delete. */ async preDelete( - _query: Query< + query: Query< DeleteResult, Document, unknown, @@ -46,11 +46,18 @@ export class CategoryRepository extends BaseRepository { >, criteria: TFilterQuery, ) { - const associatedBlocks = await this.blockService.findOne({ - category: criteria._id, - }); - if (associatedBlocks) { - throw new ForbiddenException(`Category have blocks associated to it`); + criteria = query.getQuery(); + const ids = Array.isArray(criteria._id) ? criteria._id : [criteria._id]; + + for (const id of ids) { + const associatedBlocks = await this.blockService.findOne({ + category: id, + }); + if (associatedBlocks) { + throw new ForbiddenException( + `Category ${id} has blocks associated with it`, + ); + } } } } diff --git a/frontend/src/components/categories/index.tsx b/frontend/src/components/categories/index.tsx index 93b5d5af..e7620bb3 100644 --- a/frontend/src/components/categories/index.tsx +++ b/frontend/src/components/categories/index.tsx @@ -7,9 +7,11 @@ */ import AddIcon from "@mui/icons-material/Add"; +import DeleteIcon from "@mui/icons-material/Delete"; import FolderIcon from "@mui/icons-material/Folder"; import { Button, Grid, Paper } from "@mui/material"; -import { GridColDef } from "@mui/x-data-grid"; +import { GridColDef, GridRowSelectionModel } from "@mui/x-data-grid"; +import { useState } from "react"; import { DeleteDialog } from "@/app-components/dialogs/DeleteDialog"; import { FilterTextfield } from "@/app-components/inputs/FilterTextfield"; @@ -20,6 +22,7 @@ import { import { renderHeader } from "@/app-components/tables/columns/renderHeader"; import { DataGrid } from "@/app-components/tables/DataGrid"; import { useDelete } from "@/hooks/crud/useDelete"; +import { useDeleteMany } from "@/hooks/crud/useDeleteMany"; import { useFind } from "@/hooks/crud/useFind"; import { getDisplayDialogs, useDialog } from "@/hooks/useDialog"; import { useHasPermission } from "@/hooks/useHasPermission"; @@ -56,9 +59,21 @@ export const Categories = () => { }, onSuccess: () => { deleteDialogCtl.closeDialog(); + setSelectedCategories([]); toast.success(t("message.item_delete_success")); }, }); + const { mutateAsync: deleteCategories } = useDeleteMany(EntityType.CATEGORY, { + onError: (error) => { + toast.error(error.message || t("message.internal_server_error")); + }, + onSuccess: () => { + deleteDialogCtl.closeDialog(); + setSelectedCategories([]); + toast.success(t("message.item_delete_success")); + }, + }); + const [selectedCategories, setSelectedCategories] = useState([]); const actionColumns = useActionColumns( EntityType.CATEGORY, [ @@ -109,6 +124,9 @@ export const Categories = () => { }, actionColumns, ]; + const handleSelectionChange = (selection: GridRowSelectionModel) => { + setSelectedCategories(selection as string[]); + }; return ( @@ -116,8 +134,17 @@ export const Categories = () => { { - if (deleteDialogCtl?.data) deleteCategory(deleteDialogCtl.data); + callback={async () => { + if (selectedCategories.length > 0) { + deleteCategories(selectedCategories), setSelectedCategories([]); + deleteDialogCtl.closeDialog(); + } + if (deleteDialogCtl?.data) { + { + deleteCategory(deleteDialogCtl.data); + deleteDialogCtl.closeDialog(); + } + } }} /> @@ -145,13 +172,30 @@ export const Categories = () => { ) : null} + {selectedCategories.length > 0 && ( + + + + )} - + diff --git a/frontend/src/hooks/crud/useDeleteMany.tsx b/frontend/src/hooks/crud/useDeleteMany.tsx new file mode 100644 index 00000000..ee5b8921 --- /dev/null +++ b/frontend/src/hooks/crud/useDeleteMany.tsx @@ -0,0 +1,68 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { useMutation, useQueryClient } from "react-query"; + +import { QueryType, TMutationOptions } from "@/services/types"; +import { IBaseSchema, IDynamicProps, TType } from "@/types/base.types"; + +import { isSameEntity } from "./helpers"; +import { useEntityApiClient } from "../useApiClient"; + +export const useDeleteMany = < + TEntity extends IDynamicProps["entity"], + TAttr = TType["attributes"], + TBasic extends IBaseSchema = TType["basic"], + TFull extends IBaseSchema = TType["full"], +>( + entity: TEntity, + options?: Omit< + TMutationOptions, + "mutationFn" | "mutationKey" + > & { + invalidate?: boolean; + }, +) => { + const api = useEntityApiClient(entity); + const queryClient = useQueryClient(); + const { invalidate = true, ...otherOptions } = options || {}; + + return useMutation({ + mutationFn: async (ids: string[]) => { + const result = await api.deleteMany(ids); + + queryClient.removeQueries({ + predicate: ({ queryKey }) => { + const [qType, qEntity, qId] = queryKey; + + return ( + qType === QueryType.item && + isSameEntity(qEntity, entity) && + ids.includes(qId as string) + ); + }, + }); + + if (invalidate) { + queryClient.invalidateQueries({ + predicate: ({ queryKey }) => { + const [qType, qEntity] = queryKey; + + return ( + (qType === QueryType.count || qType === QueryType.collection) && + isSameEntity(qEntity, entity) + ); + }, + }); + } + + return result; + }, + ...otherOptions, + }); +}; diff --git a/frontend/src/services/api.class.ts b/frontend/src/services/api.class.ts index 88cfdedf..bd7af240 100644 --- a/frontend/src/services/api.class.ts +++ b/frontend/src/services/api.class.ts @@ -338,6 +338,21 @@ export class EntityApiClient extends ApiClient { return data; } + /** + * Bulk Delete entries. + */ + async deleteMany(ids: string[]) { + const { _csrf } = await this.getCsrf(); + const { data } = await this.request.delete(`${ROUTES[this.type]}`, { + data: { + _csrf, + ids, + }, + }); + + return data; + } + /** * Count elements. */