/* * 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 { 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 ZoomInIcon from "@mui/icons-material/ZoomIn"; import ZoomOutIcon from "@mui/icons-material/ZoomOut"; import { Box, Button, ButtonGroup, Grid, Tab, Tabs, Tooltip, tabsClasses, } from "@mui/material"; import { DefaultPortModel, DiagramEngine, DiagramModel, DiagramModelGenerics, } from "@projectstorm/react-diagrams"; import { useRouter } from "next/router"; import { SyntheticEvent, useCallback, useEffect, 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 { useDeleteFromCache } from "@/hooks/crud/useDelete"; import { useDeleteMany } from "@/hooks/crud/useDeleteMany"; 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, QueryType, RouterType } from "@/services/types"; import { IBlock } from "@/types/block.types"; import { ICategory } from "@/types/category.types"; import { BlockPorts } from "@/types/visual-editor.types"; import BlockDialog from "../BlockDialog"; import { ZOOM_LEVEL } from "../constants"; import { useVisualEditor } from "../hooks/useVisualEditor"; import { AdvancedLinkModel } from "./AdvancedLink/AdvancedLinkModel"; const Diagrams = () => { const { t } = useTranslate(); const router = useRouter(); const flowId = router.query.id?.toString(); const [model, setModel] = useState< DiagramModel | undefined >(); const [engine, setEngine] = useState(); 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, setViewerOffset, setSelectedCategoryId, selectedCategoryId, createNode, } = useVisualEditor(); const editDialogCtl = useDialog(false); const { searchPayload } = useSearch({ $eq: [{ category: selectedCategoryId }], }); const { data: categories } = useFind( { entity: EntityType.CATEGORY }, { hasCount: false, initialSortState: [{ field: "createdAt", sort: "asc" }], }, { onSuccess([{ id, zoom, offset }]) { if (flowId) { setSelectedCategoryId?.(flowId); } else if (id) { setSelectedCategoryId?.(id); if (engine?.getModel()) { setViewerOffset(offset || [0, 0]); setViewerZoom(zoom || 100); } } }, }, ); const currentCategory = categories.find( ({ id }) => id === selectedCategoryId, ); const { mutateAsync: updateCategory } = useUpdate(EntityType.CATEGORY, { invalidate: false, }); const { mutateAsync: deleteBlocks } = useDeleteMany(EntityType.BLOCK, { onSuccess: () => { deleteDialogCtl.closeDialog(); setSelectedBlockId(undefined); }, }); const { mutateAsync: updateBlock } = useUpdate(EntityType.BLOCK, { invalidate: false, }); const debouncedUpdateCategory = useDebouncedUpdate(updateCategory, 300); const debouncedZoomEvent = useCallback( (event: any) => { if (selectedCategoryId) { engine?.repaintCanvas(); debouncedUpdateCategory({ id: selectedCategoryId, params: { zoom: event.zoom, }, }); } event.stopPropagation(); }, [selectedCategoryId, engine, debouncedUpdateCategory], ); const debouncedOffsetEvent = useCallback( (event: any) => { if (selectedCategoryId) { debouncedUpdateCategory({ id: selectedCategoryId, params: { offset: [event.offsetX, event.offsetY], }, }); } event.stopPropagation(); }, [selectedCategoryId, debouncedUpdateCategory], ); const queryClient = useQueryClient(); const getBlockFromCache = useGetFromCache(EntityType.BLOCK); const updateCachedBlock = useUpdateCache(EntityType.BLOCK); const deleteCachedBlock = useDeleteFromCache(EntityType.BLOCK); const handleChange = (_event: SyntheticEvent, newValue: number) => { if (categories) { const { id } = categories[newValue]; if (id) { setSelectedCategoryId?.(id); setSelectedBlockId(undefined); // Reset selected block when switching categories, resetting edit & remove buttons router.push(`/${RouterType.VISUAL_EDITOR}/flows/${id}`); } } }; const { data: blocks } = useFind( { entity: EntityType.BLOCK, format: Format.FULL }, { hasCount: false, params: searchPayload }, { enabled: !!selectedCategoryId, }, ); const deleteCallbackRef = useRef<() => void | null>(() => {}); useEffect(() => { // Case when categories are already cached if (categories?.length > 0 && !selectedCategoryId) { setSelectedCategoryId(categories[0].id); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { if (flowId) setSelectedCategoryId(flowId); else if (categories?.length) setSelectedCategoryId(categories[0].id); // eslint-disable-next-line react-hooks/exhaustive-deps }, [flowId]); useEffect(() => { const { canvas, model, engine } = buildDiagram({ zoom: currentCategory?.zoom || 100, offset: currentCategory?.offset || [0, 0], data: blocks, setter: setSelectedBlockId, updateFn: updateBlock, onRemoveNode: (ids, next) => { deleteDialogCtl.openDialog(ids); deleteCallbackRef.current = next; }, onDbClickNode: (event, id) => { if (id) { const block = getBlockFromCache(id); editDialogCtl.openDialog(block); } }, targetPortChanged: ({ entity, port, }: { entity: AdvancedLinkModel; port: DefaultPortModel; }) => { const link = model.getLink(entity.getOptions().id as string); if (!link) return; if ( !port.getOptions().in || [BlockPorts.nextBlocksOutPort, BlockPorts.attachmentOutPort].includes( // @ts-expect-error protected attr entity.targetPort.getOptions().label, ) || (link.getSourcePort().getType() === "attached" && link.getSourcePort().getParent().getOptions().id === link.getTargetPort().getParent().getOptions().id) ) { model.removeLink(link); return; } link.setLocked(true); link.registerListener({ selectionChanged(event: any) { const { entity, isSelected } = event; setSelectedBlockId(isSelected === true && entity.options.id); }, }); Object.entries(port.links).map(([, val]) => { // @ts-expect-error protected attr if (val.targetPort?.options?.locked) { model.removeLink(val); } }); const sourceId = entity.getSourcePort().getParent().getOptions() .id as string; const targetId = entity.getTargetPort().getParent().getOptions() .id as string; const previousData = getBlockFromCache(sourceId!); if ( // @ts-expect-error undefined attr entity.getSourcePort().getOptions()?.label === BlockPorts.nextBlocksOutPort ) { const nextBlocks = [ ...(previousData?.nextBlocks || []), ...(targetId ? [targetId] : []), ]; updateBlock( { id: sourceId, params: { nextBlocks, }, }, { onSuccess(data) { if (data.id) updateCachedBlock({ id: targetId, payload: { previousBlocks: [data.id as any], }, }); }, }, ); } else if ( // @ts-expect-error undefined attr entity.getSourcePort().getOptions().label === BlockPorts.attachmentOutPort ) { updateBlock({ id: sourceId, params: { attachedBlock: targetId, }, }); } }, }); setModel(model); setEngine(engine); setCanvas(canvas); model.registerListener({ zoomUpdated: debouncedZoomEvent, offsetUpdated: debouncedOffsetEvent, }); }, [ selectedCategoryId, JSON.stringify( blocks.map((b) => { return { ...b, position: undefined, updatedAt: undefined }; }), ), ]); const handleLinkDeletion = async (linkId: string) => { const link = model?.getLink(linkId) as any; const sourceId = link?.sourcePort.parent.options.id; const targetId = link?.targetPort.parent.options.id; if (link?.sourcePort.options.label === BlockPorts.nextBlocksOutPort) { await removeNextBlockLink(sourceId, targetId); } else if ( link?.sourcePort.options.label === BlockPorts.attachmentOutPort ) { await removeAttachmentLink(sourceId, targetId); } }; const removeNextBlockLink = async (sourceId: string, targetId: string) => { const previousData = getBlockFromCache(sourceId); const nextBlocks = [...(previousData?.nextBlocks || [])]; await updateBlock( { id: sourceId, params: { nextBlocks: nextBlocks.filter((block) => block !== targetId), }, }, { onSuccess() { updateCachedBlock({ id: targetId, preprocess: ({ previousBlocks = [], ...rest }) => ({ ...rest, previousBlocks: previousBlocks.filter( (block) => block !== sourceId, ), }), }); }, }, ); }; const removeAttachmentLink = async (sourceId: string, targetId: string) => { await updateBlock( { id: sourceId, params: { attachedBlock: null }, }, { onSuccess() { updateCachedBlock({ id: targetId, preprocess: (oldData) => ({ ...oldData, attachedToBlock: null }), }); }, }, ); }; const handleBlocksDeletion = async (blockIds: string[]) => { await deleteBlocks(blockIds, { onSuccess: () => { blockIds.forEach((blockId) => { const block = getBlockFromCache(blockId); if (block) { updateLinkedBlocks(block, blockIds); deleteCachedBlock(blockId); } }); }, }); }; const getLinkedBlockIds = (block: IBlock): string[] => [ ...(block?.nextBlocks || []), ...(block?.previousBlocks || []), ...(block?.attachedBlock ? [block.attachedBlock] : []), ...(block?.attachedToBlock ? [block.attachedToBlock] : []), ]; const updateLinkedBlocks = (block: IBlock, deletedIds: string[]) => { const linkedBlockIds = getLinkedBlockIds(block); linkedBlockIds.forEach((linkedBlockId) => { const linkedBlock = getBlockFromCache(linkedBlockId); if (linkedBlock) { updateCachedBlock({ id: linkedBlock.id, payload: { ...linkedBlock, nextBlocks: linkedBlock.nextBlocks?.filter( (nextBlockId) => !deletedIds.includes(nextBlockId), ), previousBlocks: linkedBlock.previousBlocks?.filter( (previousBlockId) => !deletedIds.includes(previousBlockId), ), attachedBlock: deletedIds.includes(linkedBlock.attachedBlock || "") ? undefined : linkedBlock.attachedBlock, attachedToBlock: deletedIds.includes( linkedBlock.attachedToBlock || "", ) ? undefined : linkedBlock.attachedToBlock, }, strategy: "overwrite", }); } }); }; const cleanupAfterDeletion = () => { deleteCallbackRef.current?.(); deleteCallbackRef.current = () => {}; deleteDialogCtl.closeDialog(); }; const handleDeleteButton = () => { const selectedEntities = engine?.getModel().getSelectedEntities(); const ids = selectedEntities?.map((model) => model.getID()); if (ids && selectedEntities && ids.length > 0) { deleteCallbackRef.current = () => { selectedEntities.forEach((model) => { model.setLocked(false); model.remove(); }); engine?.repaintCanvas(); }; 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 ids = deleteDialogCtl?.data; if (!ids || ids?.length === 0) { return; } const isLink = ids[0].length === 36; if (isLink) { await handleLinkDeletion(ids[0]); } else { await handleBlocksDeletion(ids); } cleanupAfterDeletion(); }; 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 (
{ const data = JSON.parse( event.dataTransfer.getData("storm-diagram-node"), ); if (!data) { // eslint-disable-next-line no-console console.warn("Unable to handle the drop event"); return; } const payload = { ...data, category: selectedCategoryId || "", position: engine?.getRelativeMousePoint(event), }; createNode(payload); }} onDragOver={(event) => { event.preventDefault(); }} > {...deleteDialogCtl} callback={onDelete} /> id === selectedCategoryId) : 0 } onChange={handleChange} sx={{ backgroundColor: "#fff", [`& .${tabsClasses.indicator}`]: { display: "none", }, "& .MuiTabs-scrollButtons": { opacity: 0.8, backgroundColor: "#1ca089", borderTop: "1px solid #137261", marginTop: "7px", color: "#FFF", overflow: "visible", boxShadow: "-20px 0px 20px -20px rgba(0, 0, 0, 0.5), 0px 2px 9px 0px rgba(0, 0, 0, 0.5)", zIndex: 10, "&:hover": { opacity: 1, }, }, }} variant="scrollable" allowScrollButtonsMobile > {categories?.map(({ id, label }) => ( {label} } /> ))} {canvas}
); }; export default Diagrams;