diff --git a/api/src/chat/controllers/block.controller.ts b/api/src/chat/controllers/block.controller.ts index a5fbe5d..47ef62f 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 to perform the update'); + } + 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.spec.ts b/api/src/chat/repositories/block.repository.spec.ts index 1558fac..0904e8a 100644 --- a/api/src/chat/repositories/block.repository.spec.ts +++ b/api/src/chat/repositories/block.repository.spec.ts @@ -20,8 +20,8 @@ import { rootMongooseTestModule, } from '@/utils/test/test'; -import { BlockModel, Block } from '../schemas/block.schema'; -import { CategoryModel, Category } from '../schemas/category.schema'; +import { Block, BlockModel } from '../schemas/block.schema'; +import { Category, CategoryModel } from '../schemas/category.schema'; import { LabelModel } from '../schemas/label.schema'; import { BlockRepository } from './block.repository'; @@ -34,6 +34,9 @@ describe('BlockRepository', () => { let category: Category; let hasPreviousBlocks: Block; let hasNextBlocks: Block; + let validIds: string[]; + let validCategory: string; + beforeAll(async () => { const module = await Test.createTestingModule({ imports: [ @@ -45,6 +48,9 @@ describe('BlockRepository', () => { blockRepository = module.get(BlockRepository); categoryRepository = module.get(CategoryRepository); blockModel = module.get>(getModelToken('Block')); + validIds = ['64abc1234def567890fedcba', '64abc1234def567890fedcbc']; + validCategory = '64def5678abc123490fedcba'; + category = await categoryRepository.findOne({ label: 'default' }); hasPreviousBlocks = await blockRepository.findOne({ name: 'hasPreviousBlocks', @@ -107,4 +113,195 @@ describe('BlockRepository', () => { expect(result).toEqualPayload(blocksWithCategory); }); }); + + describe('preUpdate', () => { + it('should remove references to a moved block when updating category', async () => { + const mockUpdateMany = jest.spyOn(blockRepository, 'updateMany'); + const criteria = { _id: validIds[0] }; + const updates = { $set: { category: validCategory } }; + + const mockFindOne = jest + .spyOn(blockRepository, 'findOne') + .mockResolvedValue({ + id: validIds[0], + category: 'oldCategory', + } as Block); + + await blockRepository.preUpdate({} as any, criteria, updates); + + expect(mockFindOne).toHaveBeenCalledWith(criteria); + expect(mockUpdateMany).toHaveBeenCalledTimes(2); + expect(mockUpdateMany).toHaveBeenNthCalledWith( + 1, + { nextBlocks: validIds[0] }, + { $pull: { nextBlocks: validIds[0] } }, + ); + expect(mockUpdateMany).toHaveBeenNthCalledWith( + 2, + { attachedBlock: validIds[0] }, + { $set: { attachedBlock: null } }, + ); + }); + + it('should do nothing if no block is found for the criteria', async () => { + const mockFindOne = jest + .spyOn(blockRepository, 'findOne') + .mockResolvedValue(null); + const mockUpdateMany = jest.spyOn(blockRepository, 'updateMany'); + + await blockRepository.preUpdate( + {} as any, + { _id: 'nonexistent' }, + { $set: { category: 'newCategory' } }, + ); + + expect(mockFindOne).toHaveBeenCalledWith({ _id: 'nonexistent' }); + expect(mockUpdateMany).not.toHaveBeenCalled(); + }); + }); + + describe('prepareBlocksInCategoryUpdateScope', () => { + it('should update blocks within the scope based on category and ids', async () => { + jest.spyOn(blockRepository, 'findOne').mockResolvedValue({ + id: validIds[0], + category: 'oldCategory', + nextBlocks: [validIds[1]], + attachedBlock: validIds[1], + } as Block); + + const mockUpdateOne = jest.spyOn(blockRepository, 'updateOne'); + + await blockRepository.prepareBlocksInCategoryUpdateScope( + validCategory, + validIds, + ); + + expect(mockUpdateOne).toHaveBeenCalledWith(validIds[0], { + nextBlocks: [validIds[1]], + attachedBlock: validIds[1], + }); + }); + + it('should not update blocks if the category already matches', async () => { + jest.spyOn(blockRepository, 'findOne').mockResolvedValue({ + id: validIds[0], + category: validCategory, + nextBlocks: [], + attachedBlock: null, + } as Block); + + const mockUpdateOne = jest.spyOn(blockRepository, 'updateOne'); + + await blockRepository.prepareBlocksInCategoryUpdateScope( + validCategory, + validIds, + ); + + expect(mockUpdateOne).not.toHaveBeenCalled(); + }); + }); + + describe('prepareBlocksOutOfCategoryUpdateScope', () => { + it('should update blocks outside the scope by removing references from attachedBlock', async () => { + const otherBlocks = [ + { + id: '64abc1234def567890fedcab', + attachedBlock: validIds[0], + nextBlocks: [validIds[0]], + }, + ] as Block[]; + + const mockUpdateOne = jest.spyOn(blockRepository, 'updateOne'); + + await blockRepository.prepareBlocksOutOfCategoryUpdateScope( + otherBlocks, + validIds, + ); + + expect(mockUpdateOne).toHaveBeenCalledWith('64abc1234def567890fedcab', { + attachedBlock: null, + }); + }); + + it('should update blocks outside the scope by removing references from nextBlocks', async () => { + const otherBlocks = [ + { + id: '64abc1234def567890fedcab', + attachedBlock: null, + nextBlocks: [validIds[0], validIds[1]], + }, + ] as Block[]; + + const mockUpdateOne = jest.spyOn(blockRepository, 'updateOne'); + + await blockRepository.prepareBlocksOutOfCategoryUpdateScope(otherBlocks, [ + validIds[0], + ]); + + expect(mockUpdateOne).toHaveBeenCalledWith('64abc1234def567890fedcab', { + nextBlocks: [validIds[1]], + }); + }); + }); + + describe('preUpdateMany', () => { + it('should update blocks in and out of the scope', async () => { + const mockFind = jest.spyOn(blockRepository, 'find').mockResolvedValue([ + { + id: '64abc1234def567890fedcab', + attachedBlock: validIds[0], + nextBlocks: [validIds[0]], + }, + ] as Block[]); + + const prepareBlocksInCategoryUpdateScope = jest.spyOn( + blockRepository, + 'prepareBlocksInCategoryUpdateScope', + ); + const prepareBlocksOutOfCategoryUpdateScope = jest.spyOn( + blockRepository, + 'prepareBlocksOutOfCategoryUpdateScope', + ); + + await blockRepository.preUpdateMany( + {} as any, + { _id: { $in: validIds } }, + { $set: { category: validCategory } }, + ); + + expect(mockFind).toHaveBeenCalled(); + expect(prepareBlocksInCategoryUpdateScope).toHaveBeenCalledWith( + validCategory, + ['64abc1234def567890fedcab'], + ); + expect(prepareBlocksOutOfCategoryUpdateScope).toHaveBeenCalledWith( + [ + { + id: '64abc1234def567890fedcab', + attachedBlock: validIds[0], + nextBlocks: [validIds[0]], + }, + ], + ['64abc1234def567890fedcab'], + ); + }); + + it('should not perform updates if no category is provided', async () => { + const mockFind = jest.spyOn(blockRepository, 'find'); + const prepareBlocksInCategoryUpdateScope = jest.spyOn( + blockRepository, + 'prepareBlocksInCategoryUpdateScope', + ); + const prepareBlocksOutOfCategoryUpdateScope = jest.spyOn( + blockRepository, + 'prepareBlocksOutOfCategoryUpdateScope', + ); + + await blockRepository.preUpdateMany({} as any, {}, { $set: {} }); + + expect(mockFind).not.toHaveBeenCalled(); + expect(prepareBlocksInCategoryUpdateScope).not.toHaveBeenCalled(); + expect(prepareBlocksOutOfCategoryUpdateScope).not.toHaveBeenCalled(); + }); + }); }); diff --git a/api/src/chat/repositories/block.repository.ts b/api/src/chat/repositories/block.repository.ts index 4ddb9aa..1198794 100644 --- a/api/src/chat/repositories/block.repository.ts +++ b/api/src/chat/repositories/block.repository.ts @@ -89,14 +89,138 @@ export class BlockRepository extends BaseRepository< Block, 'findOneAndUpdate' >, - _criteria: TFilterQuery, - _updates: + criteria: TFilterQuery, + updates: | UpdateWithAggregationPipeline | UpdateQuery>, ): Promise { - const updates: BlockUpdateDto = _updates?.['$set']; + const update: BlockUpdateDto = updates?.['$set']; - this.checkDeprecatedAttachmentUrl(updates); + if (update?.category) { + const movedBlock: Block = await this.findOne(criteria); + + if (!movedBlock) { + return; + } + + // Find and update blocks that reference the moved block + await this.updateMany( + { nextBlocks: movedBlock.id }, + { $pull: { nextBlocks: movedBlock.id } }, + ); + + await this.updateMany( + { attachedBlock: movedBlock.id }, + { $set: { attachedBlock: null } }, + ); + } + this.checkDeprecatedAttachmentUrl(update); + } + + /** + * 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 categoryId: string = updates.$set.category; + if (categoryId) { + const movedBlocks = await this.find(criteria); + + if (movedBlocks.length) { + const ids: string[] = movedBlocks.map(({ id }) => id); + + // Step 1: Map IDs and Category + const objIds = ids.map((id) => new Types.ObjectId(id)); + const objCategoryId = new Types.ObjectId(categoryId); + + // Step 2: Find other blocks + const otherBlocks = await this.find({ + _id: { $nin: objIds }, + category: { $ne: objCategoryId }, + $or: [ + { attachedBlock: { $in: objIds } }, + { nextBlocks: { $in: objIds } }, + ], + }); + // Step 3: Update blocks in the provided scope + await this.prepareBlocksInCategoryUpdateScope(categoryId, ids); + + // Step 4: Update external blocks + await this.prepareBlocksOutOfCategoryUpdateScope(otherBlocks, ids); + } + } + } + + /** + * Updates blocks within a specified category scope. + * Ensures nextBlocks and attachedBlock are consistent with the provided IDs and category. + * + * @param category - The category + * @param ids - IDs representing the blocks to update. + * @returns A promise that resolves once all updates within the scope are complete. + */ + async prepareBlocksInCategoryUpdateScope( + category: string, + ids: string[], + ): Promise { + for (const id of ids) { + const oldState: Block = await this.findOne(id); + if (oldState.category !== category) { + const updatedNextBlocks = oldState.nextBlocks.filter((nextBlock) => + ids.includes(nextBlock), + ); + + const updatedAttachedBlock = ids.includes(oldState.attachedBlock || '') + ? oldState.attachedBlock + : null; + + await this.updateOne(id, { + nextBlocks: updatedNextBlocks, + attachedBlock: updatedAttachedBlock, + }); + } + } + } + + /** + * Updates blocks outside the specified category scope by removing references to the provided IDs. + * Handles updates to both attachedBlock and nextBlocks. + * + * @param otherBlocks - An array of blocks outside the provided category scope. + * @param ids - An array of the Ids to disassociate. + * @returns A promise that resolves once all external block updates are complete. + */ + async prepareBlocksOutOfCategoryUpdateScope( + otherBlocks: Block[], + ids: string[], + ): Promise { + for (const block of otherBlocks) { + if (ids.includes(block.attachedBlock)) { + await this.updateOne(block.id, { attachedBlock: null }); + } + + const nextBlocks = block.nextBlocks.filter( + (nextBlock) => !ids.includes(nextBlock), + ); + + if (nextBlocks.length > 0) { + await this.updateOne(block.id, { nextBlocks }); + } + } } /** diff --git a/api/src/utils/generics/base-repository.spec.ts b/api/src/utils/generics/base-repository.spec.ts index 3dd28ad..3f729bd 100644 --- a/api/src/utils/generics/base-repository.spec.ts +++ b/api/src/utils/generics/base-repository.spec.ts @@ -8,7 +8,7 @@ import { getModelToken } from '@nestjs/mongoose'; import { Test, TestingModule } from '@nestjs/testing'; -import mongoose, { Model } from 'mongoose'; +import { Model, Types } from 'mongoose'; import { DummyRepository } from '@/utils/test/dummy/repositories/dummy.repository'; import { closeInMongodConnection } from '@/utils/test/test'; @@ -150,7 +150,7 @@ describe('BaseRepository', () => { expect(spyBeforeUpdate).toHaveBeenCalledWith( expect.objectContaining({ $useProjection: true }), { - _id: new mongoose.Types.ObjectId(created.id), + _id: new Types.ObjectId(created.id), }, expect.objectContaining({ $set: expect.objectContaining(mockUpdate) }), ); @@ -202,7 +202,7 @@ describe('BaseRepository', () => { expect(spyBeforeDelete).toHaveBeenCalledWith( expect.objectContaining({ $useProjection: true }), { - _id: new mongoose.Types.ObjectId(createdId), + _id: new Types.ObjectId(createdId), }, ); expect(spyAfterDelete).toHaveBeenCalledWith( diff --git a/api/src/utils/generics/base-repository.ts b/api/src/utils/generics/base-repository.ts index 021b3fd..00f205c 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,28 @@ 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?.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, ) { @@ -319,7 +343,7 @@ export abstract class BaseRepository< async updateOne>( criteria: string | TFilterQuery, - dto: D, + dto: UpdateQuery, ): Promise { const query = this.model.findOneAndUpdate( { @@ -335,7 +359,10 @@ export abstract class BaseRepository< return await this.executeOne(query, this.cls); } - async updateMany>(filter: TFilterQuery, dto: D) { + async updateMany>( + filter: TFilterQuery, + dto: UpdateQuery, + ) { return await this.model.updateMany(filter, { $set: dto, }); @@ -375,6 +402,21 @@ export abstract class BaseRepository< // Nothing ... } + async preUpdateMany( + _query: Query, + _criteria: TFilterQuery, + _updates: UpdateWithAggregationPipeline | UpdateQuery, + ) { + // Nothing ... + } + + async postUpdateMany( + _query: Query, + _updated: any, + ) { + // 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/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..52481a6 100644 --- a/frontend/src/components/visual-editor/v2/Diagrams.tsx +++ b/frontend/src/components/visual-editor/v2/Diagrams.tsx @@ -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 { 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"; @@ -36,18 +36,22 @@ 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"; 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"; 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"; @@ -67,7 +71,9 @@ const Diagrams = () => { const [canvas, setCanvas] = useState(); const [selectedBlockId, setSelectedBlockId] = useState(); const deleteDialogCtl = useDialog(false); + const moveDialogCtl = useDialog(false); const addCategoryDialogCtl = useDialog(false); + const { mutateAsync: updateBlocks } = useUpdateMany(EntityType.BLOCK); const { buildDiagram, setViewerZoom, @@ -144,6 +150,7 @@ const Diagrams = () => { }, [selectedCategoryId, debouncedUpdateCategory], ); + const queryClient = useQueryClient(); const getBlockFromCache = useGetFromCache(EntityType.BLOCK); const updateCachedBlock = useUpdateCache(EntityType.BLOCK); const deleteCachedBlock = useDeleteFromCache(EntityType.BLOCK); @@ -292,6 +299,7 @@ const Diagrams = () => { offsetUpdated: debouncedOffsetEvent, }); }, [ + selectedCategoryId, JSON.stringify( blocks.map((b) => { return { ...b, position: undefined, updatedAt: undefined }; @@ -316,6 +324,14 @@ const Diagrams = () => { deleteDialogCtl.openDialog(ids); } }; + const handleMoveButton = () => { + const selectedEntities = engine?.getModel().getSelectedEntities().reverse(); + const ids = selectedEntities?.map((model) => model.getID()); + + if (ids && selectedEntities) { + moveDialogCtl.openDialog(ids); + } + }; const onDelete = async () => { const id = deleteDialogCtl?.data; @@ -429,6 +445,32 @@ const Diagrams = () => { deleteDialogCtl.closeDialog(); } }; + const onMove = async (newCategoryId?: string) => { + if (!newCategoryId) { + return; + } + + const ids = moveDialogCtl?.data; + + if (ids?.length && Array.isArray(ids)) { + await updateBlocks({ ids, payload: { category: newCategoryId } }); + + queryClient.invalidateQueries({ + predicate: ({ queryKey }) => { + const [qType, qEntity] = queryKey; + + return ( + qType === QueryType.collection && + isSameEntity(qEntity, EntityType.BLOCK) + ); + }, + }); + + setSelectedCategoryId(newCategoryId); + setSelectedBlockId(undefined); + moveDialogCtl.closeDialog(); + } + }; return (
{ + { > {t("button.edit")} +