From 902596ce7fab22bfc02ac6182c664885076338bb Mon Sep 17 00:00:00 2001 From: hexastack Date: Wed, 23 Oct 2024 15:25:52 +0100 Subject: [PATCH 01/15] feat: move blocks between categories --- frontend/public/locales/en/translation.json | 4 +- frontend/public/locales/fr/translation.json | 4 +- .../src/app-components/dialogs/MoveDialog.tsx | 86 ++++++++++++++++++ .../components/visual-editor/v2/Diagrams.tsx | 87 ++++++++++++++++++- 4 files changed, 175 insertions(+), 6 deletions(-) create mode 100644 frontend/src/app-components/dialogs/MoveDialog.tsx diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 1fd010a..259cd6a 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -106,7 +106,8 @@ "no_label_found": "No label found", "code_is_required": "Language code is required", "text_is_required": "Text is required", - "invalid_file_type": "Invalid file type" + "invalid_file_type": "Invalid file type", + "select_category": "Select a flow" }, "menu": { "terms": "Terms of Use", @@ -505,6 +506,7 @@ "rename": "Rename", "duplicate": "Duplicate", "remove": "Remove", + "move": "Move", "remove_permanently": "Remove", "restore": "Restore", "edit": "Edit", diff --git a/frontend/public/locales/fr/translation.json b/frontend/public/locales/fr/translation.json index 275a80a..8ad387c 100644 --- a/frontend/public/locales/fr/translation.json +++ b/frontend/public/locales/fr/translation.json @@ -106,7 +106,8 @@ "no_label_found": "Aucune étiquette trouvée", "code_is_required": "Le code est requis", "text_is_required": "Texte requis", - "invalid_file_type": "Type de fichier invalide" + "invalid_file_type": "Type de fichier invalide", + "select_category": "Sélectionner une catégorie" }, "menu": { "terms": "Conditions d'utilisation", @@ -506,6 +507,7 @@ "rename": "Renommer", "duplicate": "Dupliquer", "remove": "Supprimer", + "move": "Déplacer", "remove_permanently": "Supprimer de façon permanente", "restore": "Restaurer", "edit": "Modifier", diff --git a/frontend/src/app-components/dialogs/MoveDialog.tsx b/frontend/src/app-components/dialogs/MoveDialog.tsx new file mode 100644 index 0000000..ea92aa4 --- /dev/null +++ b/frontend/src/app-components/dialogs/MoveDialog.tsx @@ -0,0 +1,86 @@ +/* + * 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 { + Button, + Dialog, + DialogActions, + DialogContent, + Grid, + MenuItem, + Select, +} from "@mui/material"; +import { FC, useState } from "react"; + +import { DialogTitle } from "@/app-components/dialogs/DialogTitle"; +import { DialogControl } from "@/hooks/useDialog"; +import { useTranslate } from "@/hooks/useTranslate"; +import { ICategory } from "@/types/category.types"; + +export interface MoveDialogProps extends DialogControl { + categories: ICategory[]; + callback?: (newCategoryId?: string) => Promise; + openDialog: (data?: string) => void; +} + +export const MoveDialog: FC = ({ + open, + callback, + closeDialog, + categories, +}: MoveDialogProps) => { + const { t } = useTranslate(); + const [selectedCategoryId, setSelectedCategoryId] = useState(""); + const handleMove = async () => { + if (selectedCategoryId && callback) { + await callback(selectedCategoryId); + closeDialog(); + } + }; + + return ( + + + {t("message.select_category")} + + + + + + + + + + + + + + ); +}; diff --git a/frontend/src/components/visual-editor/v2/Diagrams.tsx b/frontend/src/components/visual-editor/v2/Diagrams.tsx index 370337c..43957fd 100644 --- a/frontend/src/components/visual-editor/v2/Diagrams.tsx +++ b/frontend/src/components/visual-editor/v2/Diagrams.tsx @@ -11,6 +11,7 @@ import DeleteIcon from "@mui/icons-material/Delete"; import EditIcon from "@mui/icons-material/Edit"; import FitScreenIcon from "@mui/icons-material/FitScreen"; import RestartAltIcon from "@mui/icons-material/RestartAlt"; +import MoveIcon from "@mui/icons-material/Swipe"; import ZoomInIcon from "@mui/icons-material/ZoomIn"; import ZoomOutIcon from "@mui/icons-material/ZoomOut"; import { @@ -38,6 +39,7 @@ import { } from "react"; import { DeleteDialog } from "@/app-components/dialogs"; +import { MoveDialog } from "@/app-components/dialogs/MoveDialog"; import { CategoryDialog } from "@/components/categories/CategoryDialog"; import { useDelete, useDeleteFromCache } from "@/hooks/crud/useDelete"; import { useFind } from "@/hooks/crud/useFind"; @@ -67,6 +69,13 @@ const Diagrams = () => { const [canvas, setCanvas] = useState(); const [selectedBlockId, setSelectedBlockId] = useState(); const deleteDialogCtl = useDialog(false); + const moveDialogCtl = useDialog(false); + const { refetch: refetchBlocks } = useFind( + { entity: EntityType.BLOCK, format: Format.FULL }, + { + hasCount: false, + }, + ); const addCategoryDialogCtl = useDialog(false); const { buildDiagram, @@ -174,10 +183,13 @@ const Diagrams = () => { }, []); useEffect(() => { + const filteredBlocks = blocks.filter( + (block) => block.category === selectedCategoryId, + ); const { canvas, model, engine } = buildDiagram({ zoom: currentCategory?.zoom || 100, offset: currentCategory?.offset || [0, 0], - data: blocks, + data: filteredBlocks, setter: setSelectedBlockId, updateFn: updateBlock, onRemoveNode: (ids, next) => { @@ -291,11 +303,15 @@ const Diagrams = () => { zoomUpdated: debouncedZoomEvent, offsetUpdated: debouncedOffsetEvent, }); + refetchBlocks(); }, [ + selectedCategoryId, JSON.stringify( - blocks.map((b) => { - return { ...b, position: undefined, updatedAt: undefined }; - }), + blocks + .filter((b) => b.category === selectedCategoryId) + .map((b) => { + return { ...b, position: undefined, updatedAt: undefined }; + }), ), ]); @@ -316,6 +332,14 @@ const Diagrams = () => { deleteDialogCtl.openDialog(ids); } }; + const handleMoveButton = () => { + const selectedEntities = engine?.getModel().getSelectedEntities(); + const ids = selectedEntities?.map((model) => model.getID()).join(","); + + if (ids && selectedEntities) { + moveDialogCtl.openDialog(ids); + } + }; const onDelete = async () => { const id = deleteDialogCtl?.data; @@ -429,6 +453,45 @@ const Diagrams = () => { deleteDialogCtl.closeDialog(); } }; + const onMove = async (newCategoryId?: string) => { + if (!newCategoryId) { + return; + } + + const id = moveDialogCtl?.data; + + if (id) { + const ids = id.includes(",") ? id.split(",") : [id]; + + for (const blockId of ids) { + const block = getBlockFromCache(blockId); + + await updateBlock( + { + id: blockId, + params: { + category: newCategoryId, + }, + }, + { + onSuccess() { + updateCachedBlock({ + id: blockId, + payload: { + ...block, + category: newCategoryId, + }, + strategy: "overwrite", + }); + }, + }, + ); + } + setSelectedCategoryId(newCategoryId); + setSelectedBlockId(undefined); + moveDialogCtl.closeDialog(); + } + }; return (
{ + { > {t("button.remove")} + Date: Mon, 28 Oct 2024 08:29:53 +0100 Subject: [PATCH 02/15] fix: remove join/split --- .../src/components/visual-editor/v2/Diagrams.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/visual-editor/v2/Diagrams.tsx b/frontend/src/components/visual-editor/v2/Diagrams.tsx index 43957fd..e8f83ae 100644 --- a/frontend/src/components/visual-editor/v2/Diagrams.tsx +++ b/frontend/src/components/visual-editor/v2/Diagrams.tsx @@ -69,7 +69,7 @@ const Diagrams = () => { const [canvas, setCanvas] = useState(); const [selectedBlockId, setSelectedBlockId] = useState(); const deleteDialogCtl = useDialog(false); - const moveDialogCtl = useDialog(false); + const moveDialogCtl = useDialog(false); const { refetch: refetchBlocks } = useFind( { entity: EntityType.BLOCK, format: Format.FULL }, { @@ -334,7 +334,7 @@ const Diagrams = () => { }; const handleMoveButton = () => { const selectedEntities = engine?.getModel().getSelectedEntities(); - const ids = selectedEntities?.map((model) => model.getID()).join(","); + const ids = selectedEntities?.map((model) => model.getID()); if (ids && selectedEntities) { moveDialogCtl.openDialog(ids); @@ -458,11 +458,9 @@ const Diagrams = () => { return; } - const id = moveDialogCtl?.data; - - if (id) { - const ids = id.includes(",") ? id.split(",") : [id]; + const ids = moveDialogCtl?.data; + if (ids) { for (const blockId of ids) { const block = getBlockFromCache(blockId); @@ -487,6 +485,8 @@ const Diagrams = () => { }, ); } + refetchBlocks(); + setSelectedCategoryId(newCategoryId); setSelectedBlockId(undefined); moveDialogCtl.closeDialog(); From 3cef49d4de58c3f8e6247f49f08bc8f977ceadbf Mon Sep 17 00:00:00 2001 From: hexastack Date: Mon, 28 Oct 2024 14:17:07 +0100 Subject: [PATCH 03/15] fix: reorder buttons --- .../components/visual-editor/v2/Diagrams.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/visual-editor/v2/Diagrams.tsx b/frontend/src/components/visual-editor/v2/Diagrams.tsx index e8f83ae..0d1fe18 100644 --- a/frontend/src/components/visual-editor/v2/Diagrams.tsx +++ b/frontend/src/components/visual-editor/v2/Diagrams.tsx @@ -639,6 +639,15 @@ const Diagrams = () => { > {t("button.edit")} + - Date: Tue, 29 Oct 2024 14:54:14 +0100 Subject: [PATCH 04/15] fix: remove refetch --- .../components/visual-editor/v2/Diagrams.tsx | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/visual-editor/v2/Diagrams.tsx b/frontend/src/components/visual-editor/v2/Diagrams.tsx index 0d1fe18..34ae954 100644 --- a/frontend/src/components/visual-editor/v2/Diagrams.tsx +++ b/frontend/src/components/visual-editor/v2/Diagrams.tsx @@ -37,10 +37,12 @@ import { useRef, useState, } from "react"; +import { useQueryClient } from "react-query"; import { DeleteDialog } from "@/app-components/dialogs"; import { MoveDialog } from "@/app-components/dialogs/MoveDialog"; import { CategoryDialog } from "@/components/categories/CategoryDialog"; +import { isSameEntity } from "@/hooks/crud/helpers"; import { useDelete, useDeleteFromCache } from "@/hooks/crud/useDelete"; import { useFind } from "@/hooks/crud/useFind"; import { useGetFromCache } from "@/hooks/crud/useGet"; @@ -49,7 +51,7 @@ import useDebouncedUpdate from "@/hooks/useDebouncedUpdate"; import { getDisplayDialogs, useDialog } from "@/hooks/useDialog"; import { useSearch } from "@/hooks/useSearch"; import { useTranslate } from "@/hooks/useTranslate"; -import { EntityType, Format } from "@/services/types"; +import { EntityType, Format, QueryType } from "@/services/types"; import { IBlock } from "@/types/block.types"; import { ICategory } from "@/types/category.types"; import { BlockPorts } from "@/types/visual-editor.types"; @@ -70,12 +72,6 @@ const Diagrams = () => { const [selectedBlockId, setSelectedBlockId] = useState(); const deleteDialogCtl = useDialog(false); const moveDialogCtl = useDialog(false); - const { refetch: refetchBlocks } = useFind( - { entity: EntityType.BLOCK, format: Format.FULL }, - { - hasCount: false, - }, - ); const addCategoryDialogCtl = useDialog(false); const { buildDiagram, @@ -153,6 +149,7 @@ const Diagrams = () => { }, [selectedCategoryId, debouncedUpdateCategory], ); + const queryClient = useQueryClient(); const getBlockFromCache = useGetFromCache(EntityType.BLOCK); const updateCachedBlock = useUpdateCache(EntityType.BLOCK); const deleteCachedBlock = useDeleteFromCache(EntityType.BLOCK); @@ -183,13 +180,10 @@ const Diagrams = () => { }, []); useEffect(() => { - const filteredBlocks = blocks.filter( - (block) => block.category === selectedCategoryId, - ); const { canvas, model, engine } = buildDiagram({ zoom: currentCategory?.zoom || 100, offset: currentCategory?.offset || [0, 0], - data: filteredBlocks, + data: blocks, setter: setSelectedBlockId, updateFn: updateBlock, onRemoveNode: (ids, next) => { @@ -303,15 +297,12 @@ const Diagrams = () => { zoomUpdated: debouncedZoomEvent, offsetUpdated: debouncedOffsetEvent, }); - refetchBlocks(); }, [ selectedCategoryId, JSON.stringify( - blocks - .filter((b) => b.category === selectedCategoryId) - .map((b) => { - return { ...b, position: undefined, updatedAt: undefined }; - }), + blocks.map((b) => { + return { ...b, position: undefined, updatedAt: undefined }; + }), ), ]); @@ -485,7 +476,18 @@ const Diagrams = () => { }, ); } - refetchBlocks(); + + queryClient.removeQueries({ + predicate: ({ queryKey }) => { + const [qType, qEntity] = queryKey; + + return ( + (qType === QueryType.collection && + isSameEntity(qEntity, EntityType.BLOCK)) || + isSameEntity(qEntity, EntityType.CATEGORY) + ); + }, + }); setSelectedCategoryId(newCategoryId); setSelectedBlockId(undefined); From ce20bb725acf7150884d884cac054edbe6eafcd0 Mon Sep 17 00:00:00 2001 From: hexastack Date: Thu, 31 Oct 2024 10:39:34 +0100 Subject: [PATCH 05/15] fix: remove unnecessary snippet --- .../components/visual-editor/v2/Diagrams.tsx | 26 ++++--------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/visual-editor/v2/Diagrams.tsx b/frontend/src/components/visual-editor/v2/Diagrams.tsx index 34ae954..3783755 100644 --- a/frontend/src/components/visual-editor/v2/Diagrams.tsx +++ b/frontend/src/components/visual-editor/v2/Diagrams.tsx @@ -453,28 +453,12 @@ const Diagrams = () => { if (ids) { for (const blockId of ids) { - const block = getBlockFromCache(blockId); - - await updateBlock( - { - id: blockId, - params: { - category: newCategoryId, - }, + await updateBlock({ + id: blockId, + params: { + category: newCategoryId, }, - { - onSuccess() { - updateCachedBlock({ - id: blockId, - payload: { - ...block, - category: newCategoryId, - }, - strategy: "overwrite", - }); - }, - }, - ); + }); } queryClient.removeQueries({ From 310d9e47aab6ab2335908d3cf5d4ab7e352e8d04 Mon Sep 17 00:00:00 2001 From: hexastack Date: Thu, 31 Oct 2024 17:06:28 +0100 Subject: [PATCH 06/15] fix: resolve conflicts --- api/src/chat/repositories/block.repository.ts | 16 +++++++++++++++- .../src/components/visual-editor/v2/Diagrams.tsx | 14 +++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/api/src/chat/repositories/block.repository.ts b/api/src/chat/repositories/block.repository.ts index 4ddb9aa..319878d 100644 --- a/api/src/chat/repositories/block.repository.ts +++ b/api/src/chat/repositories/block.repository.ts @@ -89,12 +89,26 @@ export class BlockRepository extends BaseRepository< Block, 'findOneAndUpdate' >, - _criteria: TFilterQuery, + criteria: TFilterQuery, _updates: | UpdateWithAggregationPipeline | UpdateQuery>, ): Promise { const updates: BlockUpdateDto = _updates?.['$set']; + if (updates?.category) { + const movedBlockId = criteria._id; + + // Find and update blocks that reference the moved block + await this.model.updateMany( + { nextBlocks: movedBlockId }, + { $pull: { nextBlocks: movedBlockId } }, + ); + + await this.model.updateMany( + { attachedBlock: movedBlockId }, + { $set: { attachedBlock: null } }, + ); + } this.checkDeprecatedAttachmentUrl(updates); } diff --git a/frontend/src/components/visual-editor/v2/Diagrams.tsx b/frontend/src/components/visual-editor/v2/Diagrams.tsx index 3783755..d8083d4 100644 --- a/frontend/src/components/visual-editor/v2/Diagrams.tsx +++ b/frontend/src/components/visual-editor/v2/Diagrams.tsx @@ -324,7 +324,7 @@ const Diagrams = () => { } }; const handleMoveButton = () => { - const selectedEntities = engine?.getModel().getSelectedEntities(); + const selectedEntities = engine?.getModel().getSelectedEntities().reverse(); const ids = selectedEntities?.map((model) => model.getID()); if (ids && selectedEntities) { @@ -453,10 +453,22 @@ const Diagrams = () => { if (ids) { for (const blockId of ids) { + const block = getBlockFromCache(blockId); + const updatedNextBlocks = block?.nextBlocks?.filter((nextBlockId) => + ids.includes(nextBlockId), + ); + const updatedAttachedBlock = ids.includes( + block?.attachedBlock as string, + ) + ? block?.attachedBlock + : null; + await updateBlock({ id: blockId, params: { category: newCategoryId, + nextBlocks: updatedNextBlocks, + attachedBlock: updatedAttachedBlock, }, }); } From 9a4aa004d2c4491b0af64b69fb5d05213fd43bfb Mon Sep 17 00:00:00 2001 From: hexastack Date: Fri, 1 Nov 2024 10:35:06 +0100 Subject: [PATCH 07/15] fix: predicate --- frontend/src/components/visual-editor/v2/Diagrams.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/visual-editor/v2/Diagrams.tsx b/frontend/src/components/visual-editor/v2/Diagrams.tsx index d8083d4..bdb0e8a 100644 --- a/frontend/src/components/visual-editor/v2/Diagrams.tsx +++ b/frontend/src/components/visual-editor/v2/Diagrams.tsx @@ -475,12 +475,14 @@ const Diagrams = () => { queryClient.removeQueries({ predicate: ({ queryKey }) => { - const [qType, qEntity] = queryKey; + const [qType, qEntity, qId] = queryKey; return ( (qType === QueryType.collection && - isSameEntity(qEntity, EntityType.BLOCK)) || - isSameEntity(qEntity, EntityType.CATEGORY) + isSameEntity(qEntity, EntityType.BLOCK) && + qId === selectedCategoryId) || + (isSameEntity(qEntity, EntityType.CATEGORY) && + qId === selectedCategoryId) ); }, }); From e0a7a783f90bc16c10876b0265c6a090a25bcc8a Mon Sep 17 00:00:00 2001 From: hexastack Date: Fri, 15 Nov 2024 15:53:35 +0100 Subject: [PATCH 08/15] fix: move blocks logic --- api/src/chat/controllers/block.controller.ts | 22 +++++ api/src/chat/repositories/block.repository.ts | 84 ++++++++++++++++++- api/src/utils/generics/base-repository.ts | 23 +++++ .../utils/generics/lifecycle-hook-manager.ts | 4 +- .../components/visual-editor/v2/Diagrams.tsx | 36 ++------ frontend/src/hooks/crud/useUpdateMany.tsx | 83 ++++++++++++++++++ frontend/src/services/api.class.ts | 17 ++++ 7 files changed, 238 insertions(+), 31 deletions(-) create mode 100644 frontend/src/hooks/crud/useUpdateMany.tsx diff --git a/api/src/chat/controllers/block.controller.ts b/api/src/chat/controllers/block.controller.ts index a5fbe5d..3d4d82e 100644 --- a/api/src/chat/controllers/block.controller.ts +++ b/api/src/chat/controllers/block.controller.ts @@ -255,6 +255,28 @@ export class BlockController extends BaseController< return await this.blockService.create(block); } + /** + * Updates multiple blocks by their IDs. + * @param ids - IDs of blocks to be updated. + * @param payload - The data to update blocks with. + * @returns A Promise that resolves to the updates if successful. + */ + @CsrfCheck(true) + @Patch('bulk') + async updateMany(@Body() body: { ids: string[]; payload: BlockUpdateDto }) { + if (!body.ids || body.ids.length === 0) { + throw new BadRequestException('No IDs provided for ...'); + } + const updates = await this.blockService.updateMany( + { + _id: { $in: body.ids }, + }, + body.payload, + ); + + return updates; + } + /** * Updates a specific block by ID. * diff --git a/api/src/chat/repositories/block.repository.ts b/api/src/chat/repositories/block.repository.ts index 319878d..804b7bd 100644 --- a/api/src/chat/repositories/block.repository.ts +++ b/api/src/chat/repositories/block.repository.ts @@ -9,7 +9,7 @@ import { Injectable, Optional } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { InjectModel } from '@nestjs/mongoose'; -import { +import mongoose, { Document, Model, Query, @@ -113,6 +113,88 @@ export class BlockRepository extends BaseRepository< this.checkDeprecatedAttachmentUrl(updates); } + /** + * Pre-processing logic for updating blocks. + * + * @param query - The query to update blocks. + * @param criteria - The filter criteria for the update query. + * @param updates - The update data. + */ + async preUpdateMany( + _query: Query< + Document, + Document, + unknown, + Block, + 'updateMany', + Record + >, + _criteria: TFilterQuery, + _updates: UpdateQuery>, + ): Promise { + const ids: string[] = _criteria._id?.$in || []; + const objIds = ids.map((b) => { + return new mongoose.Types.ObjectId(b); + }); + const category: string = _updates.$set.category; + const objCategory = new mongoose.Types.ObjectId(category); + const otherBlocks = await this.model.find({ + _id: { $nin: objIds }, + category: { $ne: objCategory }, + $or: [ + { attachedBlock: { $in: objIds } }, + { nextBlocks: { $in: objIds } }, + ], + }); + + for (const id of ids) { + const oldState = await this.model.findOne({ + _id: new mongoose.Types.ObjectId(id), + }); + if (oldState.category.toString() !== category) { + const updatedNextBlocks = oldState.nextBlocks.filter((nextBlock) => + ids.includes(nextBlock.toString()), + ); + + const updatedAttachedBlock = ids.includes( + oldState.attachedBlock?.toString() || '', + ) + ? oldState.attachedBlock + : null; + + await this.model.updateOne( + { _id: new mongoose.Types.ObjectId(id) }, + { + nextBlocks: updatedNextBlocks, + attachedBlock: updatedAttachedBlock, + }, + ); + } + } + + for (const block of otherBlocks) { + if (ids.includes(block.attachedBlock?.toString())) { + await this.model.updateOne( + { _id: block.id }, + { + attachedBlock: null, + }, + ); + } + if (block.nextBlocks.some((item) => ids.includes(item.toString()))) { + const updatedNextBlocks = block.nextBlocks.filter( + (nextBlock) => !ids.includes(nextBlock.toString()), + ); + await this.model.updateOne( + { _id: block.id }, + { + nextBlocks: updatedNextBlocks, + }, + ); + } + } + } + /** * Post-processing logic after deleting a block. * diff --git a/api/src/utils/generics/base-repository.ts b/api/src/utils/generics/base-repository.ts index 021b3fd..af2499a 100644 --- a/api/src/utils/generics/base-repository.ts +++ b/api/src/utils/generics/base-repository.ts @@ -38,10 +38,12 @@ export type DeleteResult = { export enum EHook { preCreate = 'preCreate', preUpdate = 'preUpdate', + preUpdateMany = 'preUpdateMany', preDelete = 'preDelete', preValidate = 'preValidate', postCreate = 'postCreate', postUpdate = 'postUpdate', + postUpdateMany = 'postUpdateMany', postDelete = 'postDelete', postValidate = 'postValidate', } @@ -157,6 +159,19 @@ export abstract class BaseRepository< ); }); + hooks?.updateMany.pre.execute(async function () { + const query = this as Query; + const criteria = query.getFilter(); + const updates = query.getUpdate(); + + await repository.preUpdateMany(query, criteria, updates); + repository.emitter.emit( + repository.getEventName(EHook.preUpdateMany), + criteria, + updates?.['$set'], + ); + }); + hooks?.findOneAndUpdate.post.execute(async function ( updated: HydratedDocument, ) { @@ -375,6 +390,14 @@ export abstract class BaseRepository< // Nothing ... } + async preUpdateMany( + _query: Query, + _criteria: TFilterQuery, + _updates: UpdateWithAggregationPipeline | UpdateQuery, + ) { + // Nothing ... + } + async postUpdate( _query: Query, _updated: T, diff --git a/api/src/utils/generics/lifecycle-hook-manager.ts b/api/src/utils/generics/lifecycle-hook-manager.ts index f518537..983b2c0 100644 --- a/api/src/utils/generics/lifecycle-hook-manager.ts +++ b/api/src/utils/generics/lifecycle-hook-manager.ts @@ -17,7 +17,7 @@ enum LifecycleOperation { // InsertMany = 'insertMany', // Update = 'update', // UpdateOne = 'updateOne', - // UpdateMany = 'updateMany', + UpdateMany = 'updateMany', } type PreHook = (...args: any[]) => void; @@ -69,7 +69,7 @@ export class LifecycleHookManager { // insertMany: ['pre'], // update: ['pre', 'post'], // updateOne: ['pre', 'post'], - // updateMany: ['pre', 'post'], + updateMany: ['pre', 'post'], }; const lifecycleHooks: LifecycleHooks = {} as LifecycleHooks; diff --git a/frontend/src/components/visual-editor/v2/Diagrams.tsx b/frontend/src/components/visual-editor/v2/Diagrams.tsx index bdb0e8a..381ce48 100644 --- a/frontend/src/components/visual-editor/v2/Diagrams.tsx +++ b/frontend/src/components/visual-editor/v2/Diagrams.tsx @@ -47,6 +47,7 @@ import { useDelete, useDeleteFromCache } from "@/hooks/crud/useDelete"; import { useFind } from "@/hooks/crud/useFind"; import { useGetFromCache } from "@/hooks/crud/useGet"; import { useUpdate, useUpdateCache } from "@/hooks/crud/useUpdate"; +import { useUpdateMany } from "@/hooks/crud/useUpdateMany"; import useDebouncedUpdate from "@/hooks/useDebouncedUpdate"; import { getDisplayDialogs, useDialog } from "@/hooks/useDialog"; import { useSearch } from "@/hooks/useSearch"; @@ -73,6 +74,7 @@ const Diagrams = () => { const deleteDialogCtl = useDialog(false); const moveDialogCtl = useDialog(false); const addCategoryDialogCtl = useDialog(false); + const { mutateAsync: updateBlocks } = useUpdateMany(EntityType.BLOCK); const { buildDiagram, setViewerZoom, @@ -451,38 +453,16 @@ const Diagrams = () => { const ids = moveDialogCtl?.data; - if (ids) { - for (const blockId of ids) { - const block = getBlockFromCache(blockId); - const updatedNextBlocks = block?.nextBlocks?.filter((nextBlockId) => - ids.includes(nextBlockId), - ); - const updatedAttachedBlock = ids.includes( - block?.attachedBlock as string, - ) - ? block?.attachedBlock - : null; + if (ids?.length && Array.isArray(ids)) { + await updateBlocks({ ids, payload: { category: newCategoryId } }); - await updateBlock({ - id: blockId, - params: { - category: newCategoryId, - nextBlocks: updatedNextBlocks, - attachedBlock: updatedAttachedBlock, - }, - }); - } - - queryClient.removeQueries({ + queryClient.invalidateQueries({ predicate: ({ queryKey }) => { - const [qType, qEntity, qId] = queryKey; + const [qType, qEntity] = queryKey; return ( - (qType === QueryType.collection && - isSameEntity(qEntity, EntityType.BLOCK) && - qId === selectedCategoryId) || - (isSameEntity(qEntity, EntityType.CATEGORY) && - qId === selectedCategoryId) + qType === QueryType.collection && + isSameEntity(qEntity, EntityType.BLOCK) ); }, }); diff --git a/frontend/src/hooks/crud/useUpdateMany.tsx b/frontend/src/hooks/crud/useUpdateMany.tsx new file mode 100644 index 0000000..d50a359 --- /dev/null +++ b/frontend/src/hooks/crud/useUpdateMany.tsx @@ -0,0 +1,83 @@ +/* + * 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 { useEntityApiClient } from "../useApiClient"; + +import { isSameEntity } from "./helpers"; + +export const useUpdateMany = < + TEntity extends IDynamicProps["entity"], + TAttr = TType["attributes"], + TBasic extends IBaseSchema = TType["basic"], + TFull extends IBaseSchema = TType["full"], +>( + entity: TEntity, + options?: Omit< + TMutationOptions< + string, + Error, + { + ids: string[]; + payload: Partial; + }, + TBasic + >, + "mutationFn" | "mutationKey" + > & { + invalidate?: boolean; + }, +) => { + const api = useEntityApiClient(entity); + const queryClient = useQueryClient(); + const { invalidate = true, ...otherOptions } = options || {}; + + return useMutation({ + mutationFn: async ({ + ids, + payload, + }: { + ids: string[]; + payload: Partial; + }) => { + const result = await api.UpdateMany(ids, payload); + + 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 4d7509d..033f6e6 100644 --- a/frontend/src/services/api.class.ts +++ b/frontend/src/services/api.class.ts @@ -342,6 +342,23 @@ export class EntityApiClient extends ApiClient { return data; } + /** + * Bulk Update entries. + */ + async UpdateMany(ids: string[], payload: Partial) { + const { _csrf } = await this.getCsrf(); + const { data } = await this.request.patch( + `${ROUTES[this.type]}/bulk`, + { + _csrf, + ids, + payload, + }, + ); + + return data; + } + /** * Delete an entry. */ From 82e47f23dd64fcbef910432ff31df5c017e68911 Mon Sep 17 00:00:00 2001 From: hexastack Date: Mon, 18 Nov 2024 15:32:34 +0100 Subject: [PATCH 09/15] fix: address review --- api/src/chat/repositories/block.repository.ts | 131 +++++++++--------- api/src/utils/generics/base-repository.ts | 16 +++ frontend/src/hooks/crud/useUpdateMany.tsx | 2 +- frontend/src/services/api.class.ts | 2 +- 4 files changed, 83 insertions(+), 68 deletions(-) diff --git a/api/src/chat/repositories/block.repository.ts b/api/src/chat/repositories/block.repository.ts index 804b7bd..5eb3cd3 100644 --- a/api/src/chat/repositories/block.repository.ts +++ b/api/src/chat/repositories/block.repository.ts @@ -90,27 +90,24 @@ export class BlockRepository extends BaseRepository< 'findOneAndUpdate' >, criteria: TFilterQuery, - _updates: + updates: | UpdateWithAggregationPipeline | UpdateQuery>, ): Promise { - const updates: BlockUpdateDto = _updates?.['$set']; - if (updates?.category) { + const update: BlockUpdateDto = updates?.['$set']; + if (update?.category && criteria._id) { const movedBlockId = criteria._id; // Find and update blocks that reference the moved block - await this.model.updateMany( - { nextBlocks: movedBlockId }, - { $pull: { nextBlocks: movedBlockId } }, - ); - await this.model.updateMany( { attachedBlock: movedBlockId }, - { $set: { attachedBlock: null } }, + { $set: { attachedBlock: null }, $pull: { nextBlocks: movedBlockId } }, ); + } else if (update?.category && !criteria._id) { + throw new Error('Criteria must include a valid id to update category.'); } - this.checkDeprecatedAttachmentUrl(updates); + this.checkDeprecatedAttachmentUrl(update); } /** @@ -129,68 +126,70 @@ export class BlockRepository extends BaseRepository< 'updateMany', Record >, - _criteria: TFilterQuery, - _updates: UpdateQuery>, + criteria: TFilterQuery, + updates: UpdateQuery>, ): Promise { - const ids: string[] = _criteria._id?.$in || []; - const objIds = ids.map((b) => { - return new mongoose.Types.ObjectId(b); - }); - const category: string = _updates.$set.category; - const objCategory = new mongoose.Types.ObjectId(category); - const otherBlocks = await this.model.find({ - _id: { $nin: objIds }, - category: { $ne: objCategory }, - $or: [ - { attachedBlock: { $in: objIds } }, - { nextBlocks: { $in: objIds } }, - ], - }); - - for (const id of ids) { - const oldState = await this.model.findOne({ - _id: new mongoose.Types.ObjectId(id), + if (criteria._id?.$in && updates?.$set?.category) { + const ids: string[] = criteria._id?.$in || []; + const objIds = ids.map((b) => { + return new mongoose.Types.ObjectId(b); + }); + const category: string = updates.$set.category; + const objCategory = new mongoose.Types.ObjectId(category); + const otherBlocks = await this.model.find({ + _id: { $nin: objIds }, + category: { $ne: objCategory }, + $or: [ + { attachedBlock: { $in: objIds } }, + { nextBlocks: { $in: objIds } }, + ], }); - if (oldState.category.toString() !== category) { - const updatedNextBlocks = oldState.nextBlocks.filter((nextBlock) => - ids.includes(nextBlock.toString()), - ); - const updatedAttachedBlock = ids.includes( - oldState.attachedBlock?.toString() || '', - ) - ? oldState.attachedBlock - : null; + for (const id of ids) { + const oldState = await this.model.findOne({ + _id: new mongoose.Types.ObjectId(id), + }); + if (oldState.category.toString() !== category) { + const updatedNextBlocks = oldState.nextBlocks.filter((nextBlock) => + ids.includes(nextBlock.toString()), + ); - await this.model.updateOne( - { _id: new mongoose.Types.ObjectId(id) }, - { - nextBlocks: updatedNextBlocks, - attachedBlock: updatedAttachedBlock, - }, - ); + const updatedAttachedBlock = ids.includes( + oldState.attachedBlock?.toString() || '', + ) + ? oldState.attachedBlock + : null; + + await this.model.updateOne( + { _id: new mongoose.Types.ObjectId(id) }, + { + nextBlocks: updatedNextBlocks, + attachedBlock: updatedAttachedBlock, + }, + ); + } } - } - for (const block of otherBlocks) { - if (ids.includes(block.attachedBlock?.toString())) { - await this.model.updateOne( - { _id: block.id }, - { - attachedBlock: null, - }, - ); - } - if (block.nextBlocks.some((item) => ids.includes(item.toString()))) { - const updatedNextBlocks = block.nextBlocks.filter( - (nextBlock) => !ids.includes(nextBlock.toString()), - ); - await this.model.updateOne( - { _id: block.id }, - { - nextBlocks: updatedNextBlocks, - }, - ); + for (const block of otherBlocks) { + if (ids.includes(block.attachedBlock?.toString())) { + await this.model.updateOne( + { _id: block.id }, + { + attachedBlock: null, + }, + ); + } + if (block.nextBlocks.some((item) => ids.includes(item.toString()))) { + const updatedNextBlocks = block.nextBlocks.filter( + (nextBlock) => !ids.includes(nextBlock.toString()), + ); + await this.model.updateOne( + { _id: block.id }, + { + nextBlocks: updatedNextBlocks, + }, + ); + } } } } diff --git a/api/src/utils/generics/base-repository.ts b/api/src/utils/generics/base-repository.ts index af2499a..78750c1 100644 --- a/api/src/utils/generics/base-repository.ts +++ b/api/src/utils/generics/base-repository.ts @@ -172,6 +172,15 @@ export abstract class BaseRepository< ); }); + hooks?.updateMany.post.execute(async function (updated: any) { + const query = this as Query; + await repository.postUpdateMany(query, updated); + repository.emitter.emit( + repository.getEventName(EHook.postUpdateMany), + updated, + ); + }); + hooks?.findOneAndUpdate.post.execute(async function ( updated: HydratedDocument, ) { @@ -398,6 +407,13 @@ export abstract class BaseRepository< // Nothing ... } + async postUpdateMany( + _query: Query, + _updated: any, + ) { + // Nothing ... + } + async postUpdate( _query: Query, _updated: T, diff --git a/frontend/src/hooks/crud/useUpdateMany.tsx b/frontend/src/hooks/crud/useUpdateMany.tsx index d50a359..4a28348 100644 --- a/frontend/src/hooks/crud/useUpdateMany.tsx +++ b/frontend/src/hooks/crud/useUpdateMany.tsx @@ -49,7 +49,7 @@ export const useUpdateMany = < ids: string[]; payload: Partial; }) => { - const result = await api.UpdateMany(ids, payload); + const result = await api.updateMany(ids, payload); queryClient.removeQueries({ predicate: ({ queryKey }) => { diff --git a/frontend/src/services/api.class.ts b/frontend/src/services/api.class.ts index 033f6e6..a29e824 100644 --- a/frontend/src/services/api.class.ts +++ b/frontend/src/services/api.class.ts @@ -345,7 +345,7 @@ export class EntityApiClient extends ApiClient { /** * Bulk Update entries. */ - async UpdateMany(ids: string[], payload: Partial) { + async updateMany(ids: string[], payload: Partial) { const { _csrf } = await this.getCsrf(); const { data } = await this.request.patch( `${ROUTES[this.type]}/bulk`, From 8f815d468c6bfac4877b9a6955ca4a3bec52c00f Mon Sep 17 00:00:00 2001 From: hexastack Date: Wed, 20 Nov 2024 09:27:15 +0100 Subject: [PATCH 10/15] fix: address feedback --- api/src/chat/repositories/block.repository.ts | 117 +++++++++++------- .../components/visual-editor/v2/Diagrams.tsx | 5 +- 2 files changed, 74 insertions(+), 48 deletions(-) diff --git a/api/src/chat/repositories/block.repository.ts b/api/src/chat/repositories/block.repository.ts index 5eb3cd3..69e575c 100644 --- a/api/src/chat/repositories/block.repository.ts +++ b/api/src/chat/repositories/block.repository.ts @@ -131,11 +131,12 @@ export class BlockRepository extends BaseRepository< ): Promise { if (criteria._id?.$in && updates?.$set?.category) { const ids: string[] = criteria._id?.$in || []; - const objIds = ids.map((b) => { - return new mongoose.Types.ObjectId(b); - }); const category: string = updates.$set.category; - const objCategory = new mongoose.Types.ObjectId(category); + + // Step 1: Map IDs and Category + const { objIds, objCategory } = this.mapIdsAndCategory(ids, category); + + // Step 2: Find other blocks const otherBlocks = await this.model.find({ _id: { $nin: objIds }, category: { $ne: objCategory }, @@ -145,51 +146,77 @@ export class BlockRepository extends BaseRepository< ], }); - for (const id of ids) { - const oldState = await this.model.findOne({ - _id: new mongoose.Types.ObjectId(id), - }); - if (oldState.category.toString() !== category) { - const updatedNextBlocks = oldState.nextBlocks.filter((nextBlock) => - ids.includes(nextBlock.toString()), - ); + // Step 3: Update blocks in the provided scope + await this.updateBlocksInScope(objCategory, ids); - const updatedAttachedBlock = ids.includes( - oldState.attachedBlock?.toString() || '', - ) - ? oldState.attachedBlock - : null; + // Step 4: Update external blocks + await this.updateExternalBlocks(otherBlocks, objIds); + } + } - await this.model.updateOne( - { _id: new mongoose.Types.ObjectId(id) }, - { - nextBlocks: updatedNextBlocks, - attachedBlock: updatedAttachedBlock, - }, - ); - } + private mapIdsAndCategory( + ids: string[], + category: string, + ): { + objIds: mongoose.Types.ObjectId[]; + objCategory: mongoose.Types.ObjectId; + } { + const objIds = ids.map((id) => new mongoose.Types.ObjectId(id)); + const objCategory = new mongoose.Types.ObjectId(category); + return { objIds, objCategory }; + } + + private async updateBlocksInScope( + objCategory: mongoose.Types.ObjectId, + ids: string[], + ): Promise { + for (const id of ids) { + const oldState = await this.model.findOne({ + _id: new mongoose.Types.ObjectId(id), + }); + if (oldState.category.toString() !== objCategory.toString()) { + const updatedNextBlocks = oldState.nextBlocks.filter((nextBlock) => + ids.includes(nextBlock.toString()), + ); + + const updatedAttachedBlock = ids.includes( + oldState.attachedBlock?.toString() || '', + ) + ? oldState.attachedBlock + : null; + + await this.model.updateOne( + { _id: new mongoose.Types.ObjectId(id) }, + { + nextBlocks: updatedNextBlocks, + attachedBlock: updatedAttachedBlock, + }, + ); + } + } + } + + private async updateExternalBlocks( + otherBlocks, + objIds: Types.ObjectId[], + ): Promise { + for (const block of otherBlocks) { + if ( + objIds.some((id) => id.toString() === block.attachedBlock?.toString()) + ) { + await this.model.updateOne({ _id: block.id }, { attachedBlock: null }); } - for (const block of otherBlocks) { - if (ids.includes(block.attachedBlock?.toString())) { - await this.model.updateOne( - { _id: block.id }, - { - attachedBlock: null, - }, - ); - } - if (block.nextBlocks.some((item) => ids.includes(item.toString()))) { - const updatedNextBlocks = block.nextBlocks.filter( - (nextBlock) => !ids.includes(nextBlock.toString()), - ); - await this.model.updateOne( - { _id: block.id }, - { - nextBlocks: updatedNextBlocks, - }, - ); - } + const updatedNextBlocks = block.nextBlocks.filter( + (nextBlock) => + !objIds.some((id) => id.toString() === nextBlock.toString()), + ); + + if (updatedNextBlocks.length !== block.nextBlocks.length) { + await this.model.updateOne( + { _id: block.id }, + { nextBlocks: updatedNextBlocks }, + ); } } } diff --git a/frontend/src/components/visual-editor/v2/Diagrams.tsx b/frontend/src/components/visual-editor/v2/Diagrams.tsx index 381ce48..52481a6 100644 --- a/frontend/src/components/visual-editor/v2/Diagrams.tsx +++ b/frontend/src/components/visual-editor/v2/Diagrams.tsx @@ -6,12 +6,11 @@ * 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 { Add } from "@mui/icons-material"; +import { Add, MoveUp } from "@mui/icons-material"; import DeleteIcon from "@mui/icons-material/Delete"; import EditIcon from "@mui/icons-material/Edit"; import FitScreenIcon from "@mui/icons-material/FitScreen"; import RestartAltIcon from "@mui/icons-material/RestartAlt"; -import MoveIcon from "@mui/icons-material/Swipe"; import ZoomInIcon from "@mui/icons-material/ZoomIn"; import ZoomOutIcon from "@mui/icons-material/ZoomOut"; import { @@ -622,7 +621,7 @@ const Diagrams = () => {