diff --git a/invokeai/app/api/routers/boards.py b/invokeai/app/api/routers/boards.py index 55cd7c8ca2c..94d8667ae44 100644 --- a/invokeai/app/api/routers/boards.py +++ b/invokeai/app/api/routers/boards.py @@ -5,6 +5,7 @@ from invokeai.app.services.image_record_storage import OffsetPaginatedResults from invokeai.app.services.models.board_record import BoardDTO + from ..dependencies import ApiDependencies boards_router = APIRouter(prefix="/v1/boards", tags=["boards"]) @@ -71,11 +72,19 @@ async def update_board( @boards_router.delete("/{board_id}", operation_id="delete_board") async def delete_board( board_id: str = Path(description="The id of board to delete"), + include_images: Optional[bool] = Query( + description="Permanently delete all images on the board", default=False + ), ) -> None: """Deletes a board""" - try: - ApiDependencies.invoker.services.boards.delete(board_id=board_id) + if include_images is True: + ApiDependencies.invoker.services.images.delete_images_on_board( + board_id=board_id + ) + ApiDependencies.invoker.services.boards.delete(board_id=board_id) + else: + ApiDependencies.invoker.services.boards.delete(board_id=board_id) except Exception as e: # TODO: Does this need any exception handling at all? pass diff --git a/invokeai/app/services/image_file_storage.py b/invokeai/app/services/image_file_storage.py index b90b9b2f8b4..f30499ea265 100644 --- a/invokeai/app/services/image_file_storage.py +++ b/invokeai/app/services/image_file_storage.py @@ -85,8 +85,10 @@ def __init__(self, output_folder: str | Path): self.__cache_ids = Queue() self.__max_cache_size = 10 # TODO: get this from config - self.__output_folder: Path = output_folder if isinstance(output_folder, Path) else Path(output_folder) - self.__thumbnails_folder = self.__output_folder / 'thumbnails' + self.__output_folder: Path = ( + output_folder if isinstance(output_folder, Path) else Path(output_folder) + ) + self.__thumbnails_folder = self.__output_folder / "thumbnails" # Validate required output folders at launch self.__validate_storage_folders() @@ -94,7 +96,7 @@ def __init__(self, output_folder: str | Path): def get(self, image_name: str) -> PILImageType: try: image_path = self.get_path(image_name) - + cache_item = self.__get_cache(image_path) if cache_item: return cache_item @@ -155,7 +157,7 @@ def delete(self, image_name: str) -> None: # TODO: make this a bit more flexible for e.g. cloud storage def get_path(self, image_name: str, thumbnail: bool = False) -> Path: path = self.__output_folder / image_name - + if thumbnail: thumbnail_name = get_thumbnail_name(image_name) path = self.__thumbnails_folder / thumbnail_name @@ -166,7 +168,7 @@ def validate_path(self, path: str | Path) -> bool: """Validates the path given for an image or thumbnail.""" path = path if isinstance(path, Path) else Path(path) return path.exists() - + def __validate_storage_folders(self) -> None: """Checks if the required output folders exist and create them if they don't""" folders: list[Path] = [self.__output_folder, self.__thumbnails_folder] @@ -179,7 +181,9 @@ def __get_cache(self, image_name: Path) -> PILImageType | None: def __set_cache(self, image_name: Path, image: PILImageType): if not image_name in self.__cache: self.__cache[image_name] = image - self.__cache_ids.put(image_name) # TODO: this should refresh position for LRU cache + self.__cache_ids.put( + image_name + ) # TODO: this should refresh position for LRU cache if len(self.__cache) > self.__max_cache_size: cache_id = self.__cache_ids.get() if cache_id in self.__cache: diff --git a/invokeai/app/services/image_record_storage.py b/invokeai/app/services/image_record_storage.py index c34d2ca5c82..066e6f8d5fb 100644 --- a/invokeai/app/services/image_record_storage.py +++ b/invokeai/app/services/image_record_storage.py @@ -94,6 +94,11 @@ def delete(self, image_name: str) -> None: """Deletes an image record.""" pass + @abstractmethod + def delete_many(self, image_names: list[str]) -> None: + """Deletes many image records.""" + pass + @abstractmethod def save( self, @@ -385,6 +390,25 @@ def delete(self, image_name: str) -> None: finally: self._lock.release() + def delete_many(self, image_names: list[str]) -> None: + try: + placeholders = ",".join("?" for _ in image_names) + + self._lock.acquire() + + # Construct the SQLite query with the placeholders + query = f"DELETE FROM images WHERE image_name IN ({placeholders})" + + # Execute the query with the list of IDs as parameters + self._cursor.execute(query, image_names) + + self._conn.commit() + except sqlite3.Error as e: + self._conn.rollback() + raise ImageRecordDeleteException from e + finally: + self._lock.release() + def save( self, image_name: str, diff --git a/invokeai/app/services/images.py b/invokeai/app/services/images.py index 542f874f1d7..aeb5e520d8c 100644 --- a/invokeai/app/services/images.py +++ b/invokeai/app/services/images.py @@ -112,6 +112,11 @@ def delete(self, image_name: str): """Deletes an image.""" pass + @abstractmethod + def delete_images_on_board(self, board_id: str): + """Deletes all images on a board.""" + pass + class ImageServiceDependencies: """Service dependencies for the ImageService.""" @@ -341,6 +346,28 @@ def delete(self, image_name: str): self._services.logger.error("Problem deleting image record and file") raise e + def delete_images_on_board(self, board_id: str): + try: + images = self._services.board_image_records.get_images_for_board(board_id) + image_name_list = list( + map( + lambda r: r.image_name, + images.items, + ) + ) + for image_name in image_name_list: + self._services.image_files.delete(image_name) + self._services.image_records.delete_many(image_name_list) + except ImageRecordDeleteException: + self._services.logger.error(f"Failed to delete image records") + raise + except ImageFileDeleteException: + self._services.logger.error(f"Failed to delete image files") + raise + except Exception as e: + self._services.logger.error("Problem deleting image records and files") + raise e + def _get_metadata( self, session_id: Optional[str] = None, node_id: Optional[str] = None ) -> Union[ImageMetadata, None]: diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx index c93bd8791cc..5b3cf5925f8 100644 --- a/invokeai/frontend/web/src/app/components/App.tsx +++ b/invokeai/frontend/web/src/app/components/App.tsx @@ -25,6 +25,7 @@ import DeleteImageModal from 'features/gallery/components/DeleteImageModal'; import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale'; import UpdateImageBoardModal from '../../features/gallery/components/Boards/UpdateImageBoardModal'; import { useListModelsQuery } from 'services/api/endpoints/models'; +import DeleteBoardImagesModal from '../../features/gallery/components/Boards/DeleteBoardImagesModal'; const DEFAULT_CONFIG = {}; @@ -158,6 +159,7 @@ const App = ({ + diff --git a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx index 4d83a407c0e..7259f6105dd 100644 --- a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx +++ b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx @@ -24,6 +24,7 @@ import { import UpdateImageBoardModal from '../../features/gallery/components/Boards/UpdateImageBoardModal'; import { AddImageToBoardContextProvider } from '../contexts/AddImageToBoardContext'; import { $authToken, $baseUrl } from 'services/api/client'; +import { DeleteBoardImagesContextProvider } from '../contexts/DeleteBoardImagesContext'; const App = lazy(() => import('./App')); const ThemeLocaleProvider = lazy(() => import('./ThemeLocaleProvider')); @@ -86,11 +87,13 @@ const InvokeAIUI = ({ - + + + diff --git a/invokeai/frontend/web/src/app/contexts/DeleteBoardImagesContext.tsx b/invokeai/frontend/web/src/app/contexts/DeleteBoardImagesContext.tsx new file mode 100644 index 00000000000..38c89bfcf9d --- /dev/null +++ b/invokeai/frontend/web/src/app/contexts/DeleteBoardImagesContext.tsx @@ -0,0 +1,170 @@ +import { useDisclosure } from '@chakra-ui/react'; +import { PropsWithChildren, createContext, useCallback, useState } from 'react'; +import { BoardDTO } from 'services/api/types'; +import { useDeleteBoardMutation } from '../../services/api/endpoints/boards'; +import { defaultSelectorOptions } from '../store/util/defaultMemoizeOptions'; +import { createSelector } from '@reduxjs/toolkit'; +import { some } from 'lodash-es'; +import { canvasSelector } from '../../features/canvas/store/canvasSelectors'; +import { controlNetSelector } from '../../features/controlNet/store/controlNetSlice'; +import { selectImagesById } from '../../features/gallery/store/imagesSlice'; +import { nodesSelector } from '../../features/nodes/store/nodesSlice'; +import { generationSelector } from '../../features/parameters/store/generationSelectors'; +import { RootState } from '../store/store'; +import { useAppDispatch, useAppSelector } from '../store/storeHooks'; +import { ImageUsage } from './DeleteImageContext'; +import { requestedBoardImagesDeletion } from '../../features/gallery/store/actions'; + +export const selectBoardImagesUsage = createSelector( + [ + (state: RootState) => state, + generationSelector, + canvasSelector, + nodesSelector, + controlNetSelector, + (state: RootState, board_id?: string) => board_id, + ], + (state, generation, canvas, nodes, controlNet, board_id) => { + const initialImage = generation.initialImage + ? selectImagesById(state, generation.initialImage.imageName) + : undefined; + const isInitialImage = initialImage?.board_id === board_id; + + const isCanvasImage = canvas.layerState.objects.some((obj) => { + if (obj.kind === 'image') { + const image = selectImagesById(state, obj.imageName); + return image?.board_id === board_id; + } + return false; + }); + + const isNodesImage = nodes.nodes.some((node) => { + return some(node.data.inputs, (input) => { + if (input.type === 'image' && input.value) { + const image = selectImagesById(state, input.value.image_name); + return image?.board_id === board_id; + } + return false; + }); + }); + + const isControlNetImage = some(controlNet.controlNets, (c) => { + const controlImage = c.controlImage + ? selectImagesById(state, c.controlImage) + : undefined; + const processedControlImage = c.processedControlImage + ? selectImagesById(state, c.processedControlImage) + : undefined; + return ( + controlImage?.board_id === board_id || + processedControlImage?.board_id === board_id + ); + }); + + const imageUsage: ImageUsage = { + isInitialImage, + isCanvasImage, + isNodesImage, + isControlNetImage, + }; + + return imageUsage; + }, + defaultSelectorOptions +); + +type DeleteBoardImagesContextValue = { + /** + * Whether the move image dialog is open. + */ + isOpen: boolean; + /** + * Closes the move image dialog. + */ + onClose: () => void; + imagesUsage?: ImageUsage; + board?: BoardDTO; + onClickDeleteBoardImages: (board: BoardDTO) => void; + handleDeleteBoardImages: (boardId: string) => void; + handleDeleteBoardOnly: (boardId: string) => void; +}; + +export const DeleteBoardImagesContext = + createContext({ + isOpen: false, + onClose: () => undefined, + onClickDeleteBoardImages: () => undefined, + handleDeleteBoardImages: () => undefined, + handleDeleteBoardOnly: () => undefined, + }); + +type Props = PropsWithChildren; + +export const DeleteBoardImagesContextProvider = (props: Props) => { + const [boardToDelete, setBoardToDelete] = useState(); + const { isOpen, onOpen, onClose } = useDisclosure(); + const dispatch = useAppDispatch(); + + // Check where the board images to be deleted are used (eg init image, controlnet, etc.) + const imagesUsage = useAppSelector((state) => + selectBoardImagesUsage(state, boardToDelete?.board_id) + ); + + const [deleteBoard] = useDeleteBoardMutation(); + + // Clean up after deleting or dismissing the modal + const closeAndClearBoardToDelete = useCallback(() => { + setBoardToDelete(undefined); + onClose(); + }, [onClose]); + + const onClickDeleteBoardImages = useCallback( + (board?: BoardDTO) => { + console.log({ board }); + if (!board) { + return; + } + setBoardToDelete(board); + onOpen(); + }, + [setBoardToDelete, onOpen] + ); + + const handleDeleteBoardImages = useCallback( + (boardId: string) => { + if (boardToDelete) { + dispatch( + requestedBoardImagesDeletion({ board: boardToDelete, imagesUsage }) + ); + closeAndClearBoardToDelete(); + } + }, + [dispatch, closeAndClearBoardToDelete, boardToDelete, imagesUsage] + ); + + const handleDeleteBoardOnly = useCallback( + (boardId: string) => { + if (boardToDelete) { + deleteBoard(boardId); + closeAndClearBoardToDelete(); + } + }, + [deleteBoard, closeAndClearBoardToDelete, boardToDelete] + ); + + return ( + + {props.children} + + ); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts index a5fde1d0c27..a36141fafce 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -83,6 +83,7 @@ import { addImageRemovedFromBoardRejectedListener, } from './listeners/imageRemovedFromBoard'; import { addReceivedOpenAPISchemaListener } from './listeners/receivedOpenAPISchema'; +import { addRequestedBoardImageDeletionListener } from './listeners/boardImagesDeleted'; export const listenerMiddleware = createListenerMiddleware(); @@ -124,6 +125,7 @@ addRequestedImageDeletionListener(); addImageDeletedPendingListener(); addImageDeletedFulfilledListener(); addImageDeletedRejectedListener(); +addRequestedBoardImageDeletionListener(); // Image metadata addImageMetadataReceivedFulfilledListener(); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardImagesDeleted.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardImagesDeleted.ts new file mode 100644 index 00000000000..c4d3c5f0ba6 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardImagesDeleted.ts @@ -0,0 +1,79 @@ +import { requestedBoardImagesDeletion } from 'features/gallery/store/actions'; +import { startAppListening } from '..'; +import { imageSelected } from 'features/gallery/store/gallerySlice'; +import { + imagesRemoved, + selectImagesAll, + selectImagesById, +} from 'features/gallery/store/imagesSlice'; +import { resetCanvas } from 'features/canvas/store/canvasSlice'; +import { controlNetReset } from 'features/controlNet/store/controlNetSlice'; +import { clearInitialImage } from 'features/parameters/store/generationSlice'; +import { nodeEditorReset } from 'features/nodes/store/nodesSlice'; +import { LIST_TAG, api } from 'services/api'; +import { boardsApi } from '../../../../../services/api/endpoints/boards'; + +export const addRequestedBoardImageDeletionListener = () => { + startAppListening({ + actionCreator: requestedBoardImagesDeletion, + effect: async (action, { dispatch, getState, condition }) => { + const { board, imagesUsage } = action.payload; + + const { board_id } = board; + + const state = getState(); + const selectedImage = state.gallery.selectedImage + ? selectImagesById(state, state.gallery.selectedImage) + : undefined; + + if (selectedImage && selectedImage.board_id === board_id) { + dispatch(imageSelected()); + } + + // We need to reset the features where the board images are in use - none of these work if their image(s) don't exist + + if (imagesUsage.isCanvasImage) { + dispatch(resetCanvas()); + } + + if (imagesUsage.isControlNetImage) { + dispatch(controlNetReset()); + } + + if (imagesUsage.isInitialImage) { + dispatch(clearInitialImage()); + } + + if (imagesUsage.isNodesImage) { + dispatch(nodeEditorReset()); + } + + // Preemptively remove from gallery + const images = selectImagesAll(state).reduce((acc: string[], img) => { + if (img.board_id === board_id) { + acc.push(img.image_name); + } + return acc; + }, []); + dispatch(imagesRemoved(images)); + + // Delete from server + dispatch(boardsApi.endpoints.deleteBoardAndImages.initiate(board_id)); + const result = + boardsApi.endpoints.deleteBoardAndImages.select(board_id)(state); + const { isSuccess } = result; + + // Wait for successful deletion, then trigger boards to re-fetch + const wasBoardDeleted = await condition(() => !!isSuccess, 30000); + + if (wasBoardDeleted) { + dispatch( + api.util.invalidateTags([ + { type: 'Board', id: board_id }, + { type: 'Image', id: LIST_TAG }, + ]) + ); + } + }, + }); +}; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardImagesModal.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardImagesModal.tsx new file mode 100644 index 00000000000..736d72f862c --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardImagesModal.tsx @@ -0,0 +1,114 @@ +import { + AlertDialog, + AlertDialogBody, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + Divider, + Flex, + ListItem, + Text, + UnorderedList, +} from '@chakra-ui/react'; +import IAIButton from 'common/components/IAIButton'; +import { memo, useContext, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { DeleteBoardImagesContext } from '../../../../app/contexts/DeleteBoardImagesContext'; +import { some } from 'lodash-es'; +import { ImageUsage } from '../../../../app/contexts/DeleteImageContext'; + +const BoardImageInUseMessage = (props: { imagesUsage?: ImageUsage }) => { + const { imagesUsage } = props; + + if (!imagesUsage) { + return null; + } + + if (!some(imagesUsage)) { + return null; + } + + return ( + <> + + An image from this board is currently in use in the following features: + + + {imagesUsage.isInitialImage && Image to Image} + {imagesUsage.isCanvasImage && Unified Canvas} + {imagesUsage.isControlNetImage && ControlNet} + {imagesUsage.isNodesImage && Node Editor} + + + If you delete images from this board, those features will immediately be + reset. + + + ); +}; + +const DeleteBoardImagesModal = () => { + const { t } = useTranslation(); + + const { + isOpen, + onClose, + board, + handleDeleteBoardImages, + handleDeleteBoardOnly, + imagesUsage, + } = useContext(DeleteBoardImagesContext); + + const cancelRef = useRef(null); + + return ( + + + {board && ( + + + Delete Board + + + + + + + {t('common.areYouSure')} + + This board has {board.image_count} image(s) that will be + deleted. + + + + + + Cancel + + handleDeleteBoardOnly(board.board_id)} + > + Delete Board Only + + handleDeleteBoardImages(board.board_id)} + > + Delete Board and Images + + + + )} + + + ); +}; + +export default memo(DeleteBoardImagesModal); diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/HoverableBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/HoverableBoard.tsx index ba43f792bf3..535cab1b15b 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/HoverableBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/HoverableBoard.tsx @@ -11,7 +11,7 @@ import { } from '@chakra-ui/react'; import { useAppDispatch } from 'app/store/storeHooks'; -import { memo, useCallback } from 'react'; +import { memo, useCallback, useContext } from 'react'; import { FaFolder, FaTrash } from 'react-icons/fa'; import { ContextMenu } from 'chakra-ui-contextmenu'; import { BoardDTO, ImageDTO } from 'services/api/types'; @@ -29,6 +29,7 @@ import { useDroppable } from '@dnd-kit/core'; import { AnimatePresence } from 'framer-motion'; import IAIDropOverlay from 'common/components/IAIDropOverlay'; import { SelectedItemOverlay } from '../SelectedItemOverlay'; +import { DeleteBoardImagesContext } from '../../../../app/contexts/DeleteBoardImagesContext'; interface HoverableBoardProps { board: BoardDTO; @@ -44,6 +45,8 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => { const { board_name, board_id } = board; + const { onClickDeleteBoardImages } = useContext(DeleteBoardImagesContext); + const handleSelectBoard = useCallback(() => { dispatch(boardIdSelected(board_id)); }, [board_id, dispatch]); @@ -65,6 +68,11 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => { deleteBoard(board_id); }, [board_id, deleteBoard]); + const handleDeleteBoardAndImages = useCallback(() => { + console.log({ board }); + onClickDeleteBoardImages(board); + }, [board, onClickDeleteBoardImages]); + const handleDrop = useCallback( (droppedImage: ImageDTO) => { if (droppedImage.board_id === board_id) { @@ -92,6 +100,15 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => { menuProps={{ size: 'sm', isLazy: true }} renderMenu={() => ( + {board.image_count > 0 && ( + } + onClickCapture={handleDeleteBoardAndImages} + > + Delete Board and Images + + )} } diff --git a/invokeai/frontend/web/src/features/gallery/store/actions.ts b/invokeai/frontend/web/src/features/gallery/store/actions.ts index aa767b14224..42347781204 100644 --- a/invokeai/frontend/web/src/features/gallery/store/actions.ts +++ b/invokeai/frontend/web/src/features/gallery/store/actions.ts @@ -1,6 +1,6 @@ import { createAction } from '@reduxjs/toolkit'; import { ImageUsage } from 'app/contexts/DeleteImageContext'; -import { ImageDTO } from 'services/api/types'; +import { ImageDTO, BoardDTO } from 'services/api/types'; export type RequestedImageDeletionArg = { image: ImageDTO; @@ -11,6 +11,16 @@ export const requestedImageDeletion = createAction( 'gallery/requestedImageDeletion' ); +export type RequestedBoardImagesDeletionArg = { + board: BoardDTO; + imagesUsage: ImageUsage; +}; + +export const requestedBoardImagesDeletion = + createAction( + 'gallery/requestedBoardImagesDeletion' + ); + export const sentImageToCanvas = createAction('gallery/sentImageToCanvas'); export const sentImageToImg2Img = createAction('gallery/sentImageToImg2Img'); diff --git a/invokeai/frontend/web/src/features/gallery/store/imagesSlice.ts b/invokeai/frontend/web/src/features/gallery/store/imagesSlice.ts index 4bdaa796cd3..8041ffd5c58 100644 --- a/invokeai/frontend/web/src/features/gallery/store/imagesSlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/imagesSlice.ts @@ -60,6 +60,9 @@ const imagesSlice = createSlice({ imageRemoved: (state, action: PayloadAction) => { imagesAdapter.removeOne(state, action.payload); }, + imagesRemoved: (state, action: PayloadAction) => { + imagesAdapter.removeMany(state, action.payload); + }, imageCategoriesChanged: (state, action: PayloadAction) => { state.categories = action.payload; }, @@ -117,6 +120,7 @@ export const { imageUpserted, imageUpdatedOne, imageRemoved, + imagesRemoved, imageCategoriesChanged, } = imagesSlice.actions; diff --git a/invokeai/frontend/web/src/services/api/endpoints/boards.ts b/invokeai/frontend/web/src/services/api/endpoints/boards.ts index 9816d88eb94..64ab21075d0 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/boards.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/boards.ts @@ -82,11 +82,14 @@ export const boardsApi = api.injectEndpoints({ { type: 'Board', id: arg.board_id }, ], }), - deleteBoard: build.mutation({ query: (board_id) => ({ url: `boards/${board_id}`, method: 'DELETE' }), invalidatesTags: (result, error, arg) => [{ type: 'Board', id: arg }], }), + deleteBoardAndImages: build.mutation({ + query: (board_id) => ({ url: `boards/${board_id}`, method: 'DELETE', params: { include_images: true } }), + invalidatesTags: (result, error, arg) => [{ type: 'Board', id: arg }, { type: 'Image', id: LIST_TAG }], + }), }), }); @@ -96,4 +99,5 @@ export const { useCreateBoardMutation, useUpdateBoardMutation, useDeleteBoardMutation, + useDeleteBoardAndImagesMutation } = boardsApi;