diff --git a/services/frontend/package.json b/services/frontend/package.json index 0561f6e..ce67fd1 100644 --- a/services/frontend/package.json +++ b/services/frontend/package.json @@ -30,6 +30,7 @@ "@testing-library/jest-dom": "^5.16.2", "@testing-library/react": "^12.1.2", "@testing-library/user-event": "^13.5.0", + "axios": "^0.27.2", "codemirror": "^5.65.5", "d3": "^7.3.0", "formik": "^2.2.9", @@ -37,6 +38,7 @@ "react": "^17.0.2", "react-dom": "^17.0.2", "react-hot-toast": "^2.2.0", + "react-query": "^3.39.1", "react-router-dom": "^6.3.0", "react-scripts": "5.0.0", "tailwindcss": "^3.0.19", diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx index bbd0745..6b00da6 100644 --- a/services/frontend/src/App.tsx +++ b/services/frontend/src/App.tsx @@ -1,6 +1,9 @@ import { useReducer, useEffect } from "react"; import { Routes, Route } from "react-router-dom"; import { Toaster } from "react-hot-toast"; +import { QueryClient, QueryClientProvider } from "react-query"; +import { ReactQueryDevtools } from "react-query/devtools"; + import { LOCAL_STORAGE } from "./constants"; import { reducer, initialState } from "./reducers"; import { useLocalStorageAuth } from "./hooks/auth"; @@ -18,15 +21,17 @@ import ProtectedRoute from "./partials/ProtectedRoute"; import "./index.css"; +const queryClient = new QueryClient(); + export default function App() { const [state, dispatch] = useReducer(reducer, initialState); const auth = useLocalStorageAuth(); const isAuthenticated = !!(auth && Object.keys(auth).length); - const defaultProtectedRouteProps: Omit = { + const defaultProtectedRouteProps: Omit = { isAuthenticated: isAuthenticated, - authenticationPath: '/login', - }; + authenticationPath: "/login" + } useEffect(() => { if (isAuthenticated) { @@ -45,7 +50,7 @@ export default function App() { if (textObj.code === "user_not_found") { localStorage.removeItem(LOCAL_STORAGE); } - }) + }); refresh() .then(checkHttpStatus) @@ -56,7 +61,10 @@ export default function App() { const localDataParsed = JSON.parse(localData); if (localDataParsed && Object.keys(localDataParsed).length) { localDataParsed.access_token = data.access; - localStorage.setItem(LOCAL_STORAGE, JSON.stringify(localDataParsed)) + localStorage.setItem( + LOCAL_STORAGE, + JSON.stringify(localDataParsed) + ); } } }) @@ -69,16 +77,35 @@ export default function App() { }, [dispatch, isAuthenticated]); return ( -
- - - } /> - } /> + +
+ + + } + /> + + } + /> + + } + /> + } + /> + } /> + } /> + +
- } />} /> - } /> - } /> -
-
- ); + + + ) } diff --git a/services/frontend/src/components/Canvas/index.tsx b/services/frontend/src/components/Canvas/index.tsx index 0e0538c..6bbe1f5 100644 --- a/services/frontend/src/components/Canvas/index.tsx +++ b/services/frontend/src/components/Canvas/index.tsx @@ -1,184 +1,70 @@ -import { FC, useState, useEffect, createRef, useRef } from "react"; -import { useMemo } from 'react'; -import { debounce } from 'lodash'; -import { Dictionary, values } from "lodash"; +import { FC, useState, useEffect } from "react"; +import { values } from "lodash"; import { v4 as uuidv4 } from "uuid"; -import YAML from "yaml"; -import { PlusIcon } from "@heroicons/react/solid"; -import { - nodeCreated, - nodeDeleted, - nodeUpdated, - connectionDetached, - connectionAttached, - position -} from "../../reducers"; -import Remove from "../Remove"; import eventBus from "../../events/eventBus"; import { Popover } from "./Popover"; -import ModalConfirmDelete from "../Modal/Service/ConfirmDelete"; -import ModalServiceCreate from "../Modal/Service/Create"; -import ModalServiceEdit from "../Modal/Service/Edit"; -import { useClickOutside } from "../../utils/clickOutside"; -import { IClientNodeItem, IGraphData } from "../../types"; -import { nodeLibraries } from "../../utils/data/libraries"; -import { getClientNodeItem, flattenLibraries, ensure } from "../../utils"; -import { flattenGraphData } from "../../utils/generators"; -import { generateHttp } from "../../services/generate"; -import { checkHttpStatus } from "../../services/helpers"; +import { IGraphData } from "../../types"; import { useJsPlumb } from "../useJsPlumb"; -import CodeEditor from "../CodeEditor"; const CANVAS_ID: string = "canvas-container-" + uuidv4(); interface ICanvasProps { - state: any; - dispatch: any; - height: number; + nodes: any; + connections: any; + canvasPosition: any; + onNodeUpdate: any; + onGraphUpdate: any; + onCanvasUpdate: any; + onConnectionAttached: any; + onConnectionDetached: any; + setNodeForEdit: any; + setNodeForDelete: any; } export const Canvas: FC = (props) => { - const { state, dispatch, height } = props; - - const [language, setLanguage] = useState("yaml"); - const [scale, setScale] = useState(1); - const [generatedCode, setGeneratedCode] = useState(); - const [formattedCode, setFormattedCode] = useState(""); - const [instanceNodes, setInstanceNodes] = useState(state.nodes); - const [instanceConnections, setInstanceConnections] = useState(state.connections); - const [copyText, setCopyText] = useState("Copy"); - const [selectedNode, setSelectedNode] = useState(null); - const [showModalCreateService, setShowModalCreateService] = useState(false); - const [showModalEditService, setShowModalEditService] = useState(false); - const [showModalConfirmDeleteService, setShowModalConfirmDeleteService] = useState(false); - const [showVolumesModal, setShowVolumesModal] = useState(false); - const [showNetworksModal, setShowNetworksModal] = useState(false); - + const { + nodes, + connections, + canvasPosition, + onNodeUpdate, + onGraphUpdate, + onCanvasUpdate, + onConnectionAttached, + onConnectionDetached, + setNodeForEdit, + setNodeForDelete + } = props; const [nodeDragging, setNodeDragging] = useState(); const [nodeHovering, setNodeHovering] = useState(); const [dragging, setDragging] = useState(false); + const [scale, setScale] = useState(1); const [_scale, _setScale] = useState(1); const [_left, _setLeft] = useState(0); const [_top, _setTop] = useState(0); const [_initX, _setInitX] = useState(0); const [_initY, _setInitY] = useState(0); + let translateWidth = (document.documentElement.clientWidth * (1 - _scale)) / 2; + let translateHeight = ((document.documentElement.clientHeight - 64) * (1 - _scale)) / 2; + const [containerCallbackRef, setZoom, setStyle, removeEndpoint] = useJsPlumb( - instanceNodes, - instanceConnections, + nodes, + connections, ((graphData: IGraphData) => onGraphUpdate(graphData)), - ((positionData: any) => onEndpointPositionUpdate(positionData)), + ((positionData: any) => onNodeUpdate(positionData)), ((connectionData: any) => onConnectionAttached(connectionData)), ((connectionData: any) => onConnectionDetached(connectionData)) ); - const drop = createRef(); - const stateRef = useRef>(); - - let translateWidth = (document.documentElement.clientWidth * (1 - scale)) / 2; - let translateHeight = ((document.documentElement.clientHeight - 64) * (1 - scale)) / 2; - - stateRef.current = state.nodes; - - useClickOutside(drop, () => { - setShowModalCreateService(false); - }); - - useEffect(() => { - setScale(_scale); - }, [_scale]); - - const debouncedOnGraphUpdate = useMemo(() => debounce((graphData) => { - const flatData = flattenGraphData(graphData); - generateHttp(flatData) - .then(checkHttpStatus) - .then(data => { - if (data['code'].length) { - for (var i = 0; i < data['code'].length; ++i) { - data['code'][i] = data['code'][i].replace(/(\r\n|\n|\r)/gm, ""); - } - - const code = data['code'].join("\n"); - setGeneratedCode(code); - } - }) - .catch(err => { - - }) - .finally(() => { - - }); - }, 450), []); - - const debouncedOnCodeChange = useMemo(() => debounce((code: string) => { - //formik.setFieldValue("code", e, false); - }, 700), []); - - const zoomIn = () => { - setScale((scale) => scale + 0.1); - } - - const zoomOut = () => { - setScale((scale) => scale - 0.1); - } - - const copy = () => { - navigator.clipboard.writeText(formattedCode); - setCopyText("Copied"); - - setTimeout(() => { - setCopyText("Copy"); - }, 300); - } - - const onAddEndpoint = (values: any) => { - let sections = flattenLibraries(nodeLibraries); - let clientNodeItem = getClientNodeItem(values, ensure(sections.find((l) => l.Type === values.type))); - clientNodeItem.position = { left: 60, top: 30 }; - dispatch(nodeCreated(clientNodeItem)); - } - - const onUpdateEndpoint = (nodeItem: IClientNodeItem) => { - dispatch(nodeUpdated(nodeItem)); - } - - const onRemoveEndpoint = (key: string) => { - const nodeToRemove = instanceNodes[key]; - removeEndpoint(nodeToRemove); - dispatch(nodeDeleted(nodeToRemove)); - } - - const onEndpointPositionUpdate = (positionData: any) => { - if (stateRef.current) { - const node = stateRef.current[positionData.key]; - node.position = {...node.position, ...positionData.position}; - dispatch(nodeUpdated(node)); - } - }; - - const onConnectionDetached = (data: any) => { - dispatch(connectionDetached(data)); - } - - const onConnectionAttached = (data: any) => { - dispatch(connectionAttached(data)); - } - - const onGraphUpdate = (graphData: any) => { - debouncedOnGraphUpdate(graphData); - }; - - const onCodeUpdate = (code: string) => { - debouncedOnCodeChange(code); - }; - const onCanvasMousewheel = (e: any) => { if (e.deltaY < 0) { _setScale(_scale + _scale * 0.25); + setScale(_scale + _scale * 0.25); } if (e.deltaY > 0) { _setScale(_scale - _scale * 0.25); + setScale(_scale - _scale * 0.25); } } @@ -190,10 +76,10 @@ export const Canvas: FC = (props) => { _setLeft(left); _setTop(top); setDragging(false); - dispatch(position({ + onCanvasUpdate({ left: left, top: top - })); + }); } } @@ -217,33 +103,14 @@ export const Canvas: FC = (props) => { } useEffect(() => { - if (!generatedCode) { - return; - } - - if (language === "json") { - setFormattedCode(JSON.stringify(YAML.parse(generatedCode), null, 2)); - } - - if (language === "yaml") { - setFormattedCode(generatedCode); - } - }, [language, generatedCode]); - - useEffect(() => { - setInstanceNodes(state.nodes); - }, [state.nodes]); - - useEffect(() => { - setInstanceConnections(state.connections); - }, [state.connections]); + setZoom(_scale); + }, [_scale]); useEffect(() => { - setZoom(scale); - dispatch(position({ + onCanvasUpdate({ scale: scale - })); - }, [dispatch, scale, setZoom]); + }); + }, [scale]); useEffect(() => { const styles = { @@ -255,10 +122,10 @@ export const Canvas: FC = (props) => { }, [_left, _top, setStyle]); useEffect(() => { - _setTop(state.canvasPosition.top); - _setLeft(state.canvasPosition.left); - _setScale(state.canvasPosition.scale); - }, [state.canvasPosition]); + _setTop(canvasPosition.top); + _setLeft(canvasPosition.left); + _setScale(canvasPosition.scale); + }, [canvasPosition]); useEffect(() => { eventBus.on("EVENT_DRAG_START", (data: any) => { @@ -269,7 +136,12 @@ export const Canvas: FC = (props) => { setNodeDragging(null); }); + eventBus.on("NODE_DELETED", (data: any) => { + removeEndpoint(data.detail.message.node); + }); + return () => { + eventBus.remove("NODE_DELETED", () => { }); eventBus.remove("EVENT_DRAG_START", () => {}); eventBus.remove("EVENT_DRAG_STOP", () => { }); }; @@ -277,135 +149,59 @@ export const Canvas: FC = (props) => { return ( <> - {showModalCreateService - ? setShowModalCreateService(false)} - onAddEndpoint={(values: any) => onAddEndpoint(values)} - /> - : null - } - - {showModalEditService - ? setShowModalEditService(false)} - onUpdateEndpoint={(values: any) => onUpdateEndpoint(values)} - /> - : null - } - - {showModalConfirmDeleteService - ? setShowModalConfirmDeleteService(false)} - onConfirm={() => { - setShowModalEditService(false); - if (selectedNode) { - onRemoveEndpoint(selectedNode.key); - } - }} - /> - : null - } - - {instanceNodes && - <> -
-
-
-
- - - - - -
-
- -
{ event.stopPropagation(); event.preventDefault(); }} + {nodes && +
{ event.stopPropagation(); event.preventDefault(); }} + > +
+ {values(nodes).map((x) => ( +
setNodeHovering(x.key)} + onMouseLeave={() => { + if (nodeHovering === x.key) { + setNodeHovering(null); + } + }} > -
- {(values(instanceNodes).length > 0) && ( - <> - {values(instanceNodes).map((x) => ( -
setNodeHovering(x.key)} - onMouseLeave={() => { - if (nodeHovering === x.key) { - setNodeHovering(null); - } - }} - > - {((nodeHovering === x.key) && (nodeDragging !== x.key)) && - { - setSelectedNode(x); - setShowModalEditService(true); - }} - onDeleteClick={() => { - setSelectedNode(x); - setShowModalConfirmDeleteService(true); - }} - > - } -
-
- {x.configuration.prettyName} -
-
- {x.configuration.prettyName} -
-
-
- ))} - - )} + {((nodeHovering === x.key) && (nodeDragging !== x.key)) && + { + setNodeForEdit(x); + }} + onDeleteClick={() => { + setNodeForDelete(x); + }} + > + } +
+
+ {x.configuration.prettyName} +
+
+ {x.configuration.prettyName} +
-
-
- -
-
- - - -
- - {onCodeUpdate(e)}} - disabled={false} - lineWrapping={false} - height={height} - /> + ))}
- +
} ); diff --git a/services/frontend/src/components/Project/index.tsx b/services/frontend/src/components/Project/index.tsx index 47d57bc..eeb4d66 100644 --- a/services/frontend/src/components/Project/index.tsx +++ b/services/frontend/src/components/Project/index.tsx @@ -1,203 +1,400 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef, useMemo } from "react"; import { useParams, useNavigate } from "react-router-dom"; -import { IProjectPayload } from "../../types"; -import { nodes, connections, position, updateProjectName } from "../../reducers"; -import Spinner from "../Spinner"; -import { Canvas } from "../Canvas"; +import { debounce, Dictionary, omit } from 'lodash'; +import YAML from "yaml"; +import { PlusIcon } from "@heroicons/react/solid"; +import { IProjectPayload, IClientNodeItem, IServiceNodePosition } from "../../types"; +import eventBus from "../../events/eventBus"; +import { useProject, useUpdateProject } from "../../hooks/useProject"; import useWindowDimensions from "../../hooks/useWindowDimensions"; -import { getClientNodesAndConnections } from "../../utils"; -import { projectHttpGet, projectHttpUpdate, projectHttpCreate } from "../../services/project"; -import { checkHttpStatus } from "../../services/helpers"; +import { projectHttpCreate } from "../../services/project"; +import { flattenGraphData } from "../../utils/generators"; import { nodeLibraries } from "../../utils/data/libraries"; +import { + getClientNodeItem, + flattenLibraries, + ensure, + getClientNodesAndConnections, + getMatchingSetIndex +} from "../../utils"; +import { checkHttpStatus } from "../../services/helpers"; +import { generateHttp } from "../../services/generate"; +import { Canvas } from "../Canvas"; +import Spinner from "../Spinner"; +import ModalConfirmDelete from "../Modal/Service/ConfirmDelete"; +import ModalServiceCreate from "../Modal/Service/Create"; +import ModalServiceEdit from "../Modal/Service/Edit"; +import CodeEditor from "../CodeEditor"; -interface IProjectProps { - dispatch: any; - state: any; -} +interface IProjectProps {} export default function Project(props: IProjectProps) { - const { uuid } = useParams<{ uuid?: string }>(); - const { dispatch, state } = props; - const [saving, setSaving] = useState(false); - const [projectName, setProjectName] = useState(""); - const { height, width } = useWindowDimensions(); const navigate = useNavigate(); + const { uuid } = useParams<{ uuid: string }>(); + const { height } = useWindowDimensions(); + const { data, error, isFetching } = useProject(uuid); + const mutation = useUpdateProject(uuid); + const stateNodesRef = useRef>(); + const stateConnectionsRef = useRef<[[string, string]] | []>(); - const handleNameChange = (e: any) => { - setProjectName(e.target.value); - dispatch(updateProjectName(e.target.value)); - } + const [generatedCode, setGeneratedCode] = useState(); + const [formattedCode, setFormattedCode] = useState(""); + const [showModalCreateService, setShowModalCreateService] = useState(false); + const [showVolumesModal, setShowVolumesModal] = useState(false); + const [showNetworksModal, setShowNetworksModal] = useState(false); + const [nodeForEdit, setNodeForEdit] = useState(null); + const [nodeForDelete, setNodeForDelete] = useState(null); + const [language, setLanguage] = useState("yaml"); + const [copyText, setCopyText] = useState("Copy"); + const [nodes, setNodes] = useState({}); + const [connections, setConnections] = useState<[[string, string]] | []>([]); + const [projectName, setProjectName] = useState("Untitled"); + const [canvasPosition, setCanvasPosition] = useState({top: 0, left: 0, scale: 1}); - const updateProject = (uuid: string, payload: IProjectPayload) => { - projectHttpUpdate(uuid, JSON.stringify(payload)) - .then(checkHttpStatus) - .then(data => { + stateNodesRef.current = nodes; + stateConnectionsRef.current = connections; - }) - .catch(err => { - - }) - .finally(() => { - setSaving(false); - }); + const handleNameChange = (e: any) => { + setProjectName(e.target.value); } const createProject = (payload: IProjectPayload) => { projectHttpCreate(JSON.stringify(payload)) .then(checkHttpStatus) .then(data => { - navigate(`/projects/${data.uuid}`) - }) - .catch(err => { - + navigate(`/projects/${data.uuid}`); }) + .catch(err => {}) .finally(() => { - setSaving(false); - }); + }) + } + + const onNodeUpdate = (positionData: IServiceNodePosition) => { + if (stateNodesRef.current) { + const node = { ...stateNodesRef.current[positionData.key], ...positionData }; + setNodes({ ...stateNodesRef.current, [positionData.key]: node }); + } } const onSave = () => { - setSaving(true); const payload: IProjectPayload = { - name: state.projectName, + name: projectName, data: { canvas: { - position: state.canvasPosition, - nodes: state.nodes, - connections: state.connections + position: canvasPosition, + nodes: nodes, + connections: connections }, configs: [], networks: [], secrets: [], - services: state.nodes, + services: nodes, version: 3, - volumes: [], + volumes: [] } - }; + } if (uuid) { - updateProject(uuid, payload); + mutation.mutate(payload); } else { createProject(payload); } - }; + } const setViewHeight = () => { let vh = window.innerHeight * 0.01; - document.documentElement.style.setProperty('--vh', `${vh}px`); - }; + document.documentElement.style.setProperty("--vh", `${vh}px`); + } + + const copy = () => { + navigator.clipboard.writeText(formattedCode); + setCopyText("Copied"); + + setTimeout(() => { + setCopyText("Copy"); + }, 300); + } useEffect(() => { - if (uuid) { - projectHttpGet(uuid) - .then(checkHttpStatus) - .then(data => { - const projectData = JSON.parse(data.data); - const nodesAsList = Object.keys(projectData.canvas.nodes).map((k) => { - return projectData.canvas.nodes[k]; - }); - - const clientNodeItems = getClientNodesAndConnections(nodesAsList, nodeLibraries); - setProjectName(data.name); - dispatch(updateProjectName(data.name)); - dispatch(nodes(clientNodeItems)); - dispatch(connections(projectData.canvas.connections)); - dispatch(position(projectData.canvas.position)); - }) - .catch(err => { - if (err.status === 404) { - window.location.replace("/"); - } - }) - .finally(() => { - //setFetching(false); - }) + if (!data) { + return; } - }, [uuid, dispatch]); - useEffect(() => { - //setProjectName(state.projectName); - }, [state.projectName]); + console.log(data); + + const canvasData = JSON.parse(data.data); + const nodesAsList = Object.keys(canvasData.canvas.nodes).map(k => canvasData.canvas.nodes[k]); + const clientNodeItems = getClientNodesAndConnections(nodesAsList, nodeLibraries); + + setProjectName(data.name); + setNodes(clientNodeItems); + setConnections(canvasData.canvas.connections); + setCanvasPosition(canvasData.canvas.position); + }, [data]); + + const debouncedOnCodeChange = useMemo(() => debounce((code: string) => { + //formik.setFieldValue("code", e, false); + }, 700), []); + + const debouncedOnGraphUpdate = useMemo(() => debounce((graphData) => { + const flatData = flattenGraphData(graphData); + generateHttp(flatData) + .then(checkHttpStatus) + .then(data => { + if (data['code'].length) { + for (var i = 0; i < data['code'].length; ++i) { + data['code'][i] = data['code'][i].replace(/(\r\n|\n|\r)/gm, ""); + } + + const code = data['code'].join("\n"); + setGeneratedCode(code); + } + }) + .catch(err => { + + }) + .finally(() => { + + }); + }, 450), []); + + const onCodeUpdate = (code: string) => { + debouncedOnCodeChange(code); + }; + + const onGraphUpdate = (graphData: any) => { + debouncedOnGraphUpdate(graphData); + }; + + const onCanvasUpdate = (updatedCanvasPosition: any) => { + setCanvasPosition({...canvasPosition, ...updatedCanvasPosition}); + }; useEffect(() => { - window.addEventListener("resize", () => { + const handler = () => { setViewHeight(); - }); + } + window.addEventListener("resize", handler); setViewHeight(); + + return () => { + window.removeEventListener("resize", handler); + } }, []); - return ( - <> -
-
- { + let sections = flattenLibraries(nodeLibraries); + let clientNodeItem = getClientNodeItem(values, ensure(sections.find((l) => l.Type === values.type))); + clientNodeItem.position = { left: 60, top: 30 }; + setNodes({ ...nodes, [clientNodeItem.key]: clientNodeItem }); + } + + const onUpdateEndpoint = (nodeItem: IClientNodeItem) => { + setNodes({ ...nodes, [nodeItem.key]: nodeItem }); + } + + const onConnectionDetached = (data: any) => { + if (!stateConnectionsRef.current || stateConnectionsRef.current.length <= 0) { + return; + } + + const _connections: [[string, string]] = [...stateConnectionsRef.current] as any; + const existingIndex = getMatchingSetIndex(_connections, data); + + if (existingIndex !== -1) { + _connections.splice(existingIndex, 1); + } + + setConnections(_connections); + } + + const onConnectionAttached = (data: any) => { + if (stateConnectionsRef.current && stateConnectionsRef.current.length > 0) { + const _connections: [[string, string]] = [...stateConnectionsRef.current] as any; + const existingIndex = getMatchingSetIndex(_connections, data); + if (existingIndex === -1) { + _connections.push(data); + } + setConnections(_connections); + } else { + setConnections([data]); + } + } + + const onRemoveEndpoint = (node: IClientNodeItem) => { + setNodes({ ...omit(nodes, node.key) }); + eventBus.dispatch("NODE_DELETED", { message: { "node": node } }); + } + + useEffect(() => { + if (!generatedCode) { + return; + } + + if (language === "json") { + setFormattedCode(JSON.stringify(YAML.parse(generatedCode), null, 2)); + } + + if (language === "yaml") { + setFormattedCode(generatedCode); + } + }, [language, generatedCode]); + + useEffect(() => { + + }, [nodeForEdit]); + + if (!isFetching) { + return ( + <> + {showModalCreateService + ? setShowModalCreateService(false)} + onAddEndpoint={(values: any) => onAddEndpoint(values)} /> + : null + } -
- - - + + +
+ +
+ +
+
+
+
+
+ + + +
- + + onNodeUpdate(node)} + onGraphUpdate={(graphData: any) => onGraphUpdate(graphData)} + onCanvasUpdate={(canvasData: any) => onCanvasUpdate(canvasData)} + onConnectionAttached={(connectionData: any) => onConnectionAttached(connectionData)} + onConnectionDetached={(connectionData: any) => onConnectionDetached(connectionData)} + setNodeForEdit={(node: IClientNodeItem) => setNodeForEdit(node)} + setNodeForDelete={(node: IClientNodeItem) => setNodeForDelete(node)} + /> +
+
+
+
+ + + +
+ + { onCodeUpdate(e) }} + disabled={false} + lineWrapping={false} + height={height - 64} + />
- -
- -
- -
- +
+ + ) + } + + return ( +
+ +
); } diff --git a/services/frontend/src/components/Remove/index.tsx b/services/frontend/src/components/Remove/index.tsx deleted file mode 100644 index f47851c..0000000 --- a/services/frontend/src/components/Remove/index.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React, { SyntheticEvent } from "react"; -import { TrashIcon } from "@heroicons/react/solid"; - - -export interface ICloseProps { - id: string; - onClose?: (id: string, source?: string, target?: string) => any; - source?: string; - target?: string; -} - -const Close = (props: ICloseProps) => { - const { id, onClose, source, target } = props; - const handleClose = (event: SyntheticEvent) => { - event.preventDefault(); - if (onClose) { - onClose(id, source, target); - } - } - - return ( -
- -
- ); -} - -export default Close diff --git a/services/frontend/src/components/useJsPlumb.ts b/services/frontend/src/components/useJsPlumb.ts index 8fc41b2..bd15b7c 100644 --- a/services/frontend/src/components/useJsPlumb.ts +++ b/services/frontend/src/components/useJsPlumb.ts @@ -16,7 +16,8 @@ import { EVENT_DRAG_START, EVENT_DRAG_STOP, EVENT_CONNECTION_DBL_CLICK, - DragStartPayload + DragStartPayload, + DragStopPayload } from "@jsplumb/browser-ui"; import { defaultOptions, @@ -35,7 +36,7 @@ export const useJsPlumb = ( nodes: Dictionary, connections: Array<[string, string]>, onGraphUpdate: Function, - onEndpointPositionUpdate: Function, + onNodeUpdate: Function, onConnectionAttached: Function, onConnectionDetached: Function ): [(containerElement: HTMLDivElement) => void, @@ -45,7 +46,9 @@ export const useJsPlumb = ( const [instance, setInstance] = useState(null as any); const containerRef = useRef(); const stateRef = useRef>(); + const instanceRef = useRef(); stateRef.current = nodes; + instanceRef.current = instance; const containerCallbackRef = useCallback((containerElement: HTMLDivElement) => { containerRef.current = containerElement; }, []); @@ -88,7 +91,10 @@ export const useJsPlumb = ( }); }, [instance]); - const removeEndpoint = useCallback((node) => { + const removeEndpoint = (node: any) => { + if (!instanceRef.current) return; + + const instance = instanceRef.current; const nodeConnections = instance.getConnections({ target: node.key }); if (nodeConnections) { @@ -100,7 +106,7 @@ export const useJsPlumb = ( instance.removeAllEndpoints(document.getElementById(node.key) as Element); instance.repaintEverything(); - }, [instance]); + }; const getAnchors = (port: string[], anchorIds: AnchorId[]): IAnchor[] => { return port.map( @@ -119,9 +125,8 @@ export const useJsPlumb = ( location: .5, id: "remove-conn", cssClass: ` - block jtk-overlay remove-conn-btn text-xs leading-normal - cursor-pointer text-white font-bold rounded-full w-5 h-5 - z-20 flex justify-center + block jtk-overlay remove-conn-btn text-xs leading-normal cursor-pointer + text-white font-bold rounded-full w-5 h-5 z-20 flex justify-center `, events: { click: (e: any) => { @@ -301,13 +306,15 @@ export const useJsPlumb = ( }); }); - jsPlumbInstance.bind(EVENT_DRAG_STOP, (p: any) => { - onEndpointPositionUpdate({ - key: p.el.id, - position: { - top: p.el.offsetTop, - left: p.el.offsetLeft - } + jsPlumbInstance.bind(EVENT_DRAG_STOP, (params: DragStopPayload) => { + params.elements.forEach((el) => { + onNodeUpdate({ + key: el.id, + position: { + top: el.pos.y, + left: el.pos.x + } + }); }); }); diff --git a/services/frontend/src/events/eventBus.ts b/services/frontend/src/events/eventBus.ts index a326c36..e3509b7 100644 --- a/services/frontend/src/events/eventBus.ts +++ b/services/frontend/src/events/eventBus.ts @@ -2,7 +2,7 @@ const eventBus = { on(event: string, callback: { (data: any): void; (data: any): void; (arg: any): any; }) { document.addEventListener(event, (e) => callback(e)); }, - dispatch(event: string, data: { message: { id: string; } | { id: string; }; }) { + dispatch(event: string, data: { message: { id: string; } | { id: string; } | { node: any }; }) { document.dispatchEvent(new CustomEvent(event, { detail: data })); }, remove(event: string, callback: { (): void; (this: Document, ev: any): any; }) { diff --git a/services/frontend/src/hooks/useProject.ts b/services/frontend/src/hooks/useProject.ts new file mode 100644 index 0000000..ff9d212 --- /dev/null +++ b/services/frontend/src/hooks/useProject.ts @@ -0,0 +1,64 @@ +import axios from "axios" +import { useQuery, useMutation, useQueryClient, QueryClient } from "react-query"; +import { API_SERVER_URL } from "../constants"; +import { IProjectPayload } from "../types"; + +const fetchProjectByUuid = async (uuid: string) => { + const response = await axios.get(`${API_SERVER_URL}/projects/${uuid}/`); + return response.data; +} + +const updateProjectByUuid = async (uuid: string, data: string) => { + const response = await axios({ + method: 'put', + url: `${API_SERVER_URL}/projects/${uuid}/`, + headers: { + "Content-Type": "application/json" + }, + data: data + }); + return response.data; +} + +export const useProject = (uuid: string | undefined) => { + return useQuery( + ["projects", uuid], + async () => { + if (!uuid) { + return; + } + return await fetchProjectByUuid(uuid); + }, + { + staleTime: Infinity + } + ) +} + +export const useUpdateProject = (uuid: string | undefined) => { + const queryClient = useQueryClient(); + + return useMutation( + async (projectData: IProjectPayload) => { + if (!uuid) { + return; + } + + try { + const data = await updateProjectByUuid(uuid, JSON.stringify(projectData)); + return data; + } catch (err: any) { + if (err.response.status === 404) { + console.log('Resource could not be found!'); + } else { + console.log(err.message); + } + } + }, + { + onSuccess: (projectData) => { + queryClient.setQueryData(['projects', uuid], projectData); + }, + } + ) +} diff --git a/services/frontend/src/reducers/index.ts b/services/frontend/src/reducers/index.ts index 0ac8b4b..0758587 100644 --- a/services/frontend/src/reducers/index.ts +++ b/services/frontend/src/reducers/index.ts @@ -1,107 +1,14 @@ -import { omit } from "lodash"; - -const RESET = "reset"; - -const PROJECT_NAME = "project-name"; -const CANVAS_POSITION = "canvas-position"; - -const ENDPOINT_ALL = "endpoints"; -const ENDPOINT_CREATED = "endpoint-created"; -const ENDPOINT_UPDATED = "endpoint-updated"; -const ENDPOINT_DELETED = "endpoint-deleted"; - -const CONNECTIONS_ALL = "connections"; -const CONNECTION_DETACHED = "connection-detached"; -const CONNECTION_ATTACHED = "connection-attached"; - const AUTH_LOGIN_SUCCESS = "auth-login-success"; const AUTH_LOGOUT_SUCCESS = "auth-logout-success"; const AUTH_SELF = "auth-self" -const getMatchingSetIndex = (setOfSets: [[string, string]], findSet: [string, string]): number => { - return setOfSets.findIndex((set) => set.toString() === findSet.toString()); -} - export const initialState = { - projectName: "", - nodes: {}, - connections: [], - canvasPosition: { - top: 0, - left: 0, - scale: 1 - } + user: {} } export const reducer = (state: any, action: any) => { - let existingIndex; - let _connections; switch (action.type) { - case RESET: - return { - ...initialState - } - - case PROJECT_NAME: - return { - ...state, - projectName: action.payload - } - case CANVAS_POSITION: - return { - ...state, - canvasPosition: {...state.canvasPosition, ...action.payload} - } - case ENDPOINT_ALL: - return { - ...state, - nodes: action.payload - } - case ENDPOINT_CREATED: - return { - ...state, - nodes: {...state.nodes, [action.payload.key]: action.payload} - } - case ENDPOINT_DELETED: - return { - ...state, - nodes: {...omit(state.nodes, action.payload.key)} - } - case ENDPOINT_UPDATED: - return { - ...state, - nodes: {...state.nodes, [action.payload.key]: action.payload} - } - case CONNECTIONS_ALL: - return { - ...state, - connections: action.payload.map((x: any) => x) - } - case CONNECTION_DETACHED: - _connections = state.connections; - existingIndex = getMatchingSetIndex(_connections, action.payload); - - if (existingIndex !== -1) { - _connections.splice(existingIndex, 1); - } - - return { - ...state, - connections: [..._connections] - } - case CONNECTION_ATTACHED: - _connections = state.connections; - existingIndex = getMatchingSetIndex(state.connections, action.payload); - - if (existingIndex === -1) { - _connections.push(action.payload); - } - - return { - ...state, - connections: [..._connections] - } case AUTH_LOGIN_SUCCESS: return { ...state, @@ -122,51 +29,6 @@ export const reducer = (state: any, action: any) => { } } -export const updateProjectName = (data: string) => ({ - type: PROJECT_NAME, - payload: data -}); - -export const position = (data: any) => ({ - type: CANVAS_POSITION, - payload: data -}); - -export const connections = (data: any) => ({ - type: CONNECTIONS_ALL, - payload: data || [] -}); - -export const connectionDetached = (data: any) => ({ - type: CONNECTION_DETACHED, - payload: data -}); - -export const connectionAttached = (data: any) => ({ - type: CONNECTION_ATTACHED, - payload: data -}); - -export const nodes = (data: any) => ({ - type: ENDPOINT_ALL, - payload: data || {} -}); - -export const nodeCreated = (data: any) => ({ - type: ENDPOINT_CREATED, - payload: data -}); - -export const nodeUpdated = (data: any) => ({ - type: ENDPOINT_UPDATED, - payload: data -}); - -export const nodeDeleted = (data: any) => ({ - type: ENDPOINT_DELETED, - payload: data -}); - export const authLoginSuccess = (data: any) => ({ type: AUTH_LOGIN_SUCCESS, payload: data @@ -180,7 +42,3 @@ export const authSelf = (data: any) => ({ type: AUTH_SELF, payload: data }); - -export const resetState = () => ({ - type: RESET -}) \ No newline at end of file diff --git a/services/frontend/src/types/index.ts b/services/frontend/src/types/index.ts index f36c935..90a03a1 100644 --- a/services/frontend/src/types/index.ts +++ b/services/frontend/src/types/index.ts @@ -2,6 +2,14 @@ import { AnchorId } from "@jsplumb/common"; import { Dictionary } from "lodash"; import { NodeGroupType } from "./enums"; +export interface IServiceNodePosition { + key: string; + position: { + left: number; + top: number; + } +} + export interface IContainer { name: string; args?: string[]; @@ -79,14 +87,18 @@ export interface IProjectPayload { name: string; data: { canvas: { - position: any; - nodes: any; + position: { + top: number; + left: number; + scale: number; + }; + nodes: {}; connections: any; }; configs: []; networks: []; secrets: []; - services: IService[]; + services: {}; version: number; volumes: []; } diff --git a/services/frontend/src/utils/clickOutside.tsx b/services/frontend/src/utils/clickOutside.tsx index 53da02e..334e5be 100644 --- a/services/frontend/src/utils/clickOutside.tsx +++ b/services/frontend/src/utils/clickOutside.tsx @@ -1,6 +1,19 @@ import { useEffect } from "react"; +/** + * Use it from the component that needs outside clicks. + * + * import { useClickOutside } from "../../utils/clickOutside"; + * + * const drop = createRef(); + * useClickOutside(drop, () => { + * // do stuff... + * }); + * + * @param ref + * @param onClickOutside + */ export const useClickOutside = (ref: any, onClickOutside: any) => { useEffect( () => { diff --git a/services/frontend/src/utils/index.ts b/services/frontend/src/utils/index.ts index 7aad604..8dc9332 100644 --- a/services/frontend/src/utils/index.ts +++ b/services/frontend/src/utils/index.ts @@ -235,4 +235,8 @@ export const truncateStr = (str: string, length: number) => { } return str -} \ No newline at end of file +} + +export const getMatchingSetIndex = (setOfSets: [[string, string]], findSet: [string, string]): number => { + return setOfSets.findIndex((set) => set.toString() === findSet.toString()); +}