chore: use react-query for project data, cleanup

pull/64/head
Artem Golub 3 years ago
parent c5f55354d8
commit 1c3431c84d

@ -1,31 +1,32 @@
import { useReducer, useEffect } from "react" import { useReducer, useEffect } from "react";
import { Routes, Route } from "react-router-dom" import { Routes, Route } from "react-router-dom";
import { Toaster } from "react-hot-toast" import { Toaster } from "react-hot-toast";
import { QueryClient, QueryClientProvider } from "react-query" import { QueryClient, QueryClientProvider } from "react-query";
import { ReactQueryDevtools } from "react-query/devtools";
import { LOCAL_STORAGE } from "./constants" import { LOCAL_STORAGE } from "./constants";
import { reducer, initialState } from "./reducers" import { reducer, initialState } from "./reducers";
import { useLocalStorageAuth } from "./hooks/auth" import { useLocalStorageAuth } from "./hooks/auth";
import { checkHttpStatus } from "./services/helpers" import { checkHttpStatus } from "./services/helpers";
import { authSelf } from "./reducers" import { authSelf } from "./reducers";
import { refresh, self } from "./services/auth" import { refresh, self } from "./services/auth";
import Project from "./components/Project" import Project from "./components/Project";
import Profile from "./components/Profile" import Profile from "./components/Profile";
import Signup from "./components/Auth/Signup" import Signup from "./components/Auth/Signup";
import Login from "./components/Auth/Login" import Login from "./components/Auth/Login";
import { ProtectedRouteProps } from "./partials/ProtectedRoute" import { ProtectedRouteProps } from "./partials/ProtectedRoute";
import ProtectedRoute from "./partials/ProtectedRoute" import ProtectedRoute from "./partials/ProtectedRoute";
import "./index.css" import "./index.css";
const queryClient = new QueryClient() const queryClient = new QueryClient();
export default function App() { export default function App() {
const [state, dispatch] = useReducer(reducer, initialState) const [state, dispatch] = useReducer(reducer, initialState);
const auth = useLocalStorageAuth() const auth = useLocalStorageAuth();
const isAuthenticated = !!(auth && Object.keys(auth).length) const isAuthenticated = !!(auth && Object.keys(auth).length);
const defaultProtectedRouteProps: Omit<ProtectedRouteProps, "outlet"> = { const defaultProtectedRouteProps: Omit<ProtectedRouteProps, "outlet"> = {
isAuthenticated: isAuthenticated, isAuthenticated: isAuthenticated,
@ -37,7 +38,7 @@ export default function App() {
self() self()
.then(checkHttpStatus) .then(checkHttpStatus)
.then(data => { .then(data => {
dispatch(authSelf(data)) dispatch(authSelf(data));
}) })
.catch(err => { .catch(err => {
// since auth is set in localstorage, // since auth is set in localstorage,
@ -45,35 +46,35 @@ export default function App() {
// on error clear localstorage // on error clear localstorage
if (err.status === 401) { if (err.status === 401) {
err.text().then((text: string) => { err.text().then((text: string) => {
const textObj = JSON.parse(text) const textObj = JSON.parse(text);
if (textObj.code === "user_not_found") { if (textObj.code === "user_not_found") {
localStorage.removeItem(LOCAL_STORAGE) localStorage.removeItem(LOCAL_STORAGE);
} }
}) });
refresh() refresh()
.then(checkHttpStatus) .then(checkHttpStatus)
.then(data => { .then(data => {
const localData = localStorage.getItem(LOCAL_STORAGE) const localData = localStorage.getItem(LOCAL_STORAGE);
if (localData) { if (localData) {
const localDataParsed = JSON.parse(localData) const localDataParsed = JSON.parse(localData);
if (localDataParsed && Object.keys(localDataParsed).length) { if (localDataParsed && Object.keys(localDataParsed).length) {
localDataParsed.access_token = data.access localDataParsed.access_token = data.access;
localStorage.setItem( localStorage.setItem(
LOCAL_STORAGE, LOCAL_STORAGE,
JSON.stringify(localDataParsed) JSON.stringify(localDataParsed)
) );
} }
} }
}) })
.catch(err => { .catch(err => {
localStorage.removeItem(LOCAL_STORAGE) localStorage.removeItem(LOCAL_STORAGE);
}) })
} }
}) })
} }
}, [dispatch, isAuthenticated]) }, [dispatch, isAuthenticated]);
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@ -81,12 +82,13 @@ export default function App() {
<Toaster /> <Toaster />
<Routes> <Routes>
<Route <Route
path="/" path="/projects/:uuid"
element={<Project dispatch={dispatch} state={state} />} element={<Project />}
/> />
<Route <Route
path="/projects/:uuid" path="/projects/new"
element={<Project dispatch={dispatch} state={state} />} element={<Project />}
/> />
<Route <Route
@ -102,6 +104,8 @@ export default function App() {
<Route path="/login" element={<Login dispatch={dispatch} />} /> <Route path="/login" element={<Login dispatch={dispatch} />} />
</Routes> </Routes>
</div> </div>
<ReactQueryDevtools initialIsOpen={true} />
</QueryClientProvider> </QueryClientProvider>
) )
} }

@ -1,184 +1,70 @@
import { FC, useState, useEffect, createRef, useRef } from "react"; import { FC, useState, useEffect } from "react";
import { useMemo } from 'react'; import { values } from "lodash";
import { debounce } from 'lodash';
import { Dictionary, values } from "lodash";
import { v4 as uuidv4 } from "uuid"; 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 eventBus from "../../events/eventBus";
import { Popover } from "./Popover"; import { Popover } from "./Popover";
import ModalConfirmDelete from "../Modal/Service/ConfirmDelete"; import { IGraphData } from "../../types";
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 { useJsPlumb } from "../useJsPlumb"; import { useJsPlumb } from "../useJsPlumb";
import CodeEditor from "../CodeEditor";
const CANVAS_ID: string = "canvas-container-" + uuidv4(); const CANVAS_ID: string = "canvas-container-" + uuidv4();
interface ICanvasProps { interface ICanvasProps {
state: any; nodes: any;
dispatch: any; connections: any;
height: number; canvasPosition: any;
onNodeUpdate: any;
onGraphUpdate: any;
onCanvasUpdate: any;
onConnectionAttached: any;
onConnectionDetached: any;
setNodeForEdit: any;
setNodeForDelete: any;
} }
export const Canvas: FC<ICanvasProps> = (props) => { export const Canvas: FC<ICanvasProps> = (props) => {
const { state, dispatch, height } = props; const {
nodes,
const [language, setLanguage] = useState("yaml"); connections,
const [scale, setScale] = useState(1); canvasPosition,
const [generatedCode, setGeneratedCode] = useState<string>(); onNodeUpdate,
const [formattedCode, setFormattedCode] = useState<string>(""); onGraphUpdate,
const [instanceNodes, setInstanceNodes] = useState(state.nodes); onCanvasUpdate,
const [instanceConnections, setInstanceConnections] = useState(state.connections); onConnectionAttached,
const [copyText, setCopyText] = useState("Copy"); onConnectionDetached,
const [selectedNode, setSelectedNode] = useState<IClientNodeItem | null>(null); setNodeForEdit,
const [showModalCreateService, setShowModalCreateService] = useState(false); setNodeForDelete
const [showModalEditService, setShowModalEditService] = useState(false); } = props;
const [showModalConfirmDeleteService, setShowModalConfirmDeleteService] = useState(false);
const [showVolumesModal, setShowVolumesModal] = useState(false);
const [showNetworksModal, setShowNetworksModal] = useState(false);
const [nodeDragging, setNodeDragging] = useState<string | null>(); const [nodeDragging, setNodeDragging] = useState<string | null>();
const [nodeHovering, setNodeHovering] = useState<string | null>(); const [nodeHovering, setNodeHovering] = useState<string | null>();
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const [scale, setScale] = useState(1);
const [_scale, _setScale] = useState(1); const [_scale, _setScale] = useState(1);
const [_left, _setLeft] = useState(0); const [_left, _setLeft] = useState(0);
const [_top, _setTop] = useState(0); const [_top, _setTop] = useState(0);
const [_initX, _setInitX] = useState(0); const [_initX, _setInitX] = useState(0);
const [_initY, _setInitY] = 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( const [containerCallbackRef, setZoom, setStyle, removeEndpoint] = useJsPlumb(
instanceNodes, nodes,
instanceConnections, connections,
((graphData: IGraphData) => onGraphUpdate(graphData)), ((graphData: IGraphData) => onGraphUpdate(graphData)),
((positionData: any) => onEndpointPositionUpdate(positionData)), ((positionData: any) => onNodeUpdate(positionData)),
((connectionData: any) => onConnectionAttached(connectionData)), ((connectionData: any) => onConnectionAttached(connectionData)),
((connectionData: any) => onConnectionDetached(connectionData)) ((connectionData: any) => onConnectionDetached(connectionData))
); );
const drop = createRef<HTMLDivElement>();
const stateRef = useRef<Dictionary<IClientNodeItem>>();
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) => { const onCanvasMousewheel = (e: any) => {
if (e.deltaY < 0) { if (e.deltaY < 0) {
_setScale(_scale + _scale * 0.25); _setScale(_scale + _scale * 0.25);
setScale(_scale + _scale * 0.25);
} }
if (e.deltaY > 0) { if (e.deltaY > 0) {
_setScale(_scale - _scale * 0.25); _setScale(_scale - _scale * 0.25);
setScale(_scale - _scale * 0.25);
} }
} }
@ -190,10 +76,10 @@ export const Canvas: FC<ICanvasProps> = (props) => {
_setLeft(left); _setLeft(left);
_setTop(top); _setTop(top);
setDragging(false); setDragging(false);
dispatch(position({ onCanvasUpdate({
left: left, left: left,
top: top top: top
})); });
} }
} }
@ -217,33 +103,14 @@ export const Canvas: FC<ICanvasProps> = (props) => {
} }
useEffect(() => { useEffect(() => {
if (!generatedCode) { setZoom(_scale);
return; }, [_scale]);
}
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]);
useEffect(() => { useEffect(() => {
setZoom(scale); onCanvasUpdate({
dispatch(position({
scale: scale scale: scale
})); });
}, [dispatch, scale, setZoom]); }, [scale]);
useEffect(() => { useEffect(() => {
const styles = { const styles = {
@ -255,10 +122,10 @@ export const Canvas: FC<ICanvasProps> = (props) => {
}, [_left, _top, setStyle]); }, [_left, _top, setStyle]);
useEffect(() => { useEffect(() => {
_setTop(state.canvasPosition.top); _setTop(canvasPosition.top);
_setLeft(state.canvasPosition.left); _setLeft(canvasPosition.left);
_setScale(state.canvasPosition.scale); _setScale(canvasPosition.scale);
}, [state.canvasPosition]); }, [canvasPosition]);
useEffect(() => { useEffect(() => {
eventBus.on("EVENT_DRAG_START", (data: any) => { eventBus.on("EVENT_DRAG_START", (data: any) => {
@ -269,7 +136,12 @@ export const Canvas: FC<ICanvasProps> = (props) => {
setNodeDragging(null); setNodeDragging(null);
}); });
eventBus.on("NODE_DELETED", (data: any) => {
removeEndpoint(data.detail.message.node);
});
return () => { return () => {
eventBus.remove("NODE_DELETED", () => { });
eventBus.remove("EVENT_DRAG_START", () => {}); eventBus.remove("EVENT_DRAG_START", () => {});
eventBus.remove("EVENT_DRAG_STOP", () => { }); eventBus.remove("EVENT_DRAG_STOP", () => { });
}; };
@ -277,57 +149,7 @@ export const Canvas: FC<ICanvasProps> = (props) => {
return ( return (
<> <>
{showModalCreateService {nodes &&
? <ModalServiceCreate
onHide={() => setShowModalCreateService(false)}
onAddEndpoint={(values: any) => onAddEndpoint(values)}
/>
: null
}
{showModalEditService
? <ModalServiceEdit
node={selectedNode}
onHide={() => setShowModalEditService(false)}
onUpdateEndpoint={(values: any) => onUpdateEndpoint(values)}
/>
: null
}
{showModalConfirmDeleteService
? <ModalConfirmDelete
onHide={() => setShowModalConfirmDeleteService(false)}
onConfirm={() => {
setShowModalEditService(false);
if (selectedNode) {
onRemoveEndpoint(selectedNode.key);
}
}}
/>
: null
}
{instanceNodes &&
<>
<div className="w-full overflow-hidden md:w-2/3 z-40" style={{height: height}}>
<div className="relative h-full">
<div className="absolute top-0 right-0 z-40">
<div className="flex space-x-2 p-2">
<button className="hidden btn-util" type="button" onClick={zoomOut} disabled={scale <= 0.5}>-</button>
<button className="hidden btn-util" type="button" onClick={zoomIn} disabled={scale >= 1}>+</button>
<button className="flex space-x-1 btn-util" type="button" onClick={() => setShowModalCreateService(true)}>
<PlusIcon className="w-3"/>
<span>Service</span>
</button>
<button className="btn-util" type="button" onClick={() => setShowVolumesModal(true)}>
Volumes
</button>
<button className="btn-util" type="button" onClick={() => setShowNetworksModal(true)}>
Networks
</button>
</div>
</div>
<div key={CANVAS_ID} className="jsplumb-box" <div key={CANVAS_ID} className="jsplumb-box"
onWheel={onCanvasMousewheel} onWheel={onCanvasMousewheel}
onMouseMove={onCanvasMouseMove} onMouseMove={onCanvasMouseMove}
@ -342,12 +164,10 @@ export const Canvas: FC<ICanvasProps> = (props) => {
className="canvas h-full w-full" className="canvas h-full w-full"
style={{ style={{
transformOrigin: '0px 0px 0px', transformOrigin: '0px 0px 0px',
transform: `translate(${translateWidth}px, ${translateHeight}px) scale(${scale})` transform: `translate(${translateWidth}px, ${translateHeight}px) scale(${_scale})`
}} }}
> >
{(values(instanceNodes).length > 0) && ( {values(nodes).map((x) => (
<>
{values(instanceNodes).map((x) => (
<div <div
key={x.key} key={x.key}
className={"node-item cursor-pointer shadow flex flex-col group"} className={"node-item cursor-pointer shadow flex flex-col group"}
@ -363,12 +183,10 @@ export const Canvas: FC<ICanvasProps> = (props) => {
{((nodeHovering === x.key) && (nodeDragging !== x.key)) && {((nodeHovering === x.key) && (nodeDragging !== x.key)) &&
<Popover <Popover
onEditClick={() => { onEditClick={() => {
setSelectedNode(x); setNodeForEdit(x);
setShowModalEditService(true);
}} }}
onDeleteClick={() => { onDeleteClick={() => {
setSelectedNode(x); setNodeForDelete(x);
setShowModalConfirmDeleteService(true);
}} }}
></Popover> ></Popover>
} }
@ -382,30 +200,8 @@ export const Canvas: FC<ICanvasProps> = (props) => {
</div> </div>
</div> </div>
))} ))}
</>
)}
</div>
</div>
</div> </div>
</div> </div>
<div className="relative group code-column w-full md:w-1/3">
<div className={`absolute top-0 left-0 right-0 z-10 flex justify-end p-1 space-x-2 group-hover:visible invisible`}>
<button className={`btn-util ${language === "json" ? `btn-util-selected` : ``}`} onClick={() => setLanguage('json')}>json</button>
<button className={`btn-util ${language === "yaml" ? `btn-util-selected` : ``}`} onClick={() => setLanguage('yaml')}>yaml</button>
<button className="btn-util" type="button" onClick={copy}>{copyText}</button>
</div>
<CodeEditor
data={formattedCode}
language={language}
onChange={(e: any) => {onCodeUpdate(e)}}
disabled={false}
lineWrapping={false}
height={height}
/>
</div>
</>
} }
</> </>
); );

@ -1,136 +1,281 @@
import { useEffect, useState } from "react" import { useEffect, useState, useRef, useMemo } from "react";
import { useParams, useNavigate } from "react-router-dom" import { useParams, useNavigate } from "react-router-dom";
import { IProjectPayload } from "../../types" import { debounce, Dictionary, omit } from 'lodash';
import { nodes, connections, position, updateProjectName } from "../../reducers" import YAML from "yaml";
import Spinner from "../Spinner" import { PlusIcon } from "@heroicons/react/solid";
import { Canvas } from "../Canvas" import { IProjectPayload, IClientNodeItem, IServiceNodePosition } from "../../types";
import useWindowDimensions from "../../hooks/useWindowDimensions" import eventBus from "../../events/eventBus";
import { getClientNodesAndConnections } from "../../utils" import { useProject, useUpdateProject } from "../../hooks/useProject";
import useWindowDimensions from "../../hooks/useWindowDimensions";
import { projectHttpCreate } from "../../services/project";
import { flattenGraphData } from "../../utils/generators";
import { nodeLibraries } from "../../utils/data/libraries";
import { import {
projectHttpGet, getClientNodeItem,
projectHttpUpdate, flattenLibraries,
projectHttpCreate ensure,
} from "../../services/project" getClientNodesAndConnections,
import { checkHttpStatus } from "../../services/helpers" getMatchingSetIndex
import { nodeLibraries } from "../../utils/data/libraries" } 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 { interface IProjectProps {}
dispatch: any
state: any
}
export default function Project(props: IProjectProps) { export default function Project(props: IProjectProps) {
const { uuid } = useParams<{ uuid?: string }>() const navigate = useNavigate();
const { dispatch, state } = props const { uuid } = useParams<{ uuid: string }>();
const [saving, setSaving] = useState(false) const { height } = useWindowDimensions();
const [projectName, setProjectName] = useState("") const { data, error, isFetching } = useProject(uuid);
const { height, width } = useWindowDimensions() const mutation = useUpdateProject(uuid);
const navigate = useNavigate() const stateNodesRef = useRef<Dictionary<IClientNodeItem>>();
const stateConnectionsRef = useRef<[[string, string]] | []>();
const handleNameChange = (e: any) => { const [generatedCode, setGeneratedCode] = useState<string>();
setProjectName(e.target.value) const [formattedCode, setFormattedCode] = useState<string>("");
dispatch(updateProjectName(e.target.value)) const [showModalCreateService, setShowModalCreateService] = useState(false);
} const [showVolumesModal, setShowVolumesModal] = useState(false);
const [showNetworksModal, setShowNetworksModal] = useState(false);
const [nodeForEdit, setNodeForEdit] = useState<IClientNodeItem | null>(null);
const [nodeForDelete, setNodeForDelete] = useState<IClientNodeItem | null>(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) => { stateNodesRef.current = nodes;
projectHttpUpdate(uuid, JSON.stringify(payload)) stateConnectionsRef.current = connections;
.then(checkHttpStatus)
.then(data => {}) const handleNameChange = (e: any) => {
.catch(err => {}) setProjectName(e.target.value);
.finally(() => {
setSaving(false)
})
} }
const createProject = (payload: IProjectPayload) => { const createProject = (payload: IProjectPayload) => {
projectHttpCreate(JSON.stringify(payload)) projectHttpCreate(JSON.stringify(payload))
.then(checkHttpStatus) .then(checkHttpStatus)
.then(data => { .then(data => {
navigate(`/projects/${data.uuid}`) navigate(`/projects/${data.uuid}`);
}) })
.catch(err => {}) .catch(err => {})
.finally(() => { .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 = () => { const onSave = () => {
setSaving(true)
const payload: IProjectPayload = { const payload: IProjectPayload = {
name: state.projectName, name: projectName,
data: { data: {
canvas: { canvas: {
position: state.canvasPosition, position: canvasPosition,
nodes: state.nodes, nodes: nodes,
connections: state.connections connections: connections
}, },
configs: [], configs: [],
networks: [], networks: [],
secrets: [], secrets: [],
services: state.nodes, services: nodes,
version: 3, version: 3,
volumes: [] volumes: []
} }
} }
if (uuid) { if (uuid) {
updateProject(uuid, payload) mutation.mutate(payload);
} else { } else {
createProject(payload) createProject(payload);
} }
} }
const setViewHeight = () => { const setViewHeight = () => {
let vh = window.innerHeight * 0.01 let vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty("--vh", `${vh}px`) document.documentElement.style.setProperty("--vh", `${vh}px`);
} }
const { data, error, isFetching } = useProject(uuid) const copy = () => {
navigator.clipboard.writeText(formattedCode);
setCopyText("Copied");
setTimeout(() => {
setCopyText("Copy");
}, 300);
}
useEffect(() => { useEffect(() => {
if (!data) { if (!data) {
return return;
} }
const nodesAsList = Object.keys(data.canvas.nodes).map(k => data.canvas.nodes[k]) console.log(data);
const clientNodeItems = getClientNodesAndConnections( const canvasData = JSON.parse(data.data);
nodesAsList, const nodesAsList = Object.keys(canvasData.canvas.nodes).map(k => canvasData.canvas.nodes[k]);
nodeLibraries const clientNodeItems = getClientNodesAndConnections(nodesAsList, nodeLibraries);
)
/* TODO: Remove these dispatch calls as we migrate other components setProjectName(data.name);
* to React Query. setNodes(clientNodeItems);
*/ setConnections(canvasData.canvas.connections);
dispatch(updateProjectName(data.name)); setCanvasPosition(canvasData.canvas.position);
dispatch(nodes(clientNodeItems)); }, [data]);
dispatch(connections(data.canvas.connections));
dispatch(position(data.canvas.position));
return { nodesAsList, clientNodeItems } const debouncedOnCodeChange = useMemo(() => debounce((code: string) => {
}, [dispatch, data]) //formik.setFieldValue("code", e, false);
}, 700), []);
useEffect(() => { const debouncedOnGraphUpdate = useMemo(() => debounce((graphData) => {
if (uuid && !isFetching && data) { const flatData = flattenGraphData(graphData);
setProjectName(data.name) 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, "");
} }
}, [uuid, isFetching, data?.name])
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(() => { useEffect(() => {
const handler = () => { const handler = () => {
setViewHeight() setViewHeight();
} }
window.addEventListener("resize", handler) window.addEventListener("resize", handler);
setViewHeight() setViewHeight();
return () => {
window.removeEventListener("resize", handler);
}
}, []);
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 };
setNodes({ ...nodes, [clientNodeItem.key]: clientNodeItem });
}
() => { const onUpdateEndpoint = (nodeItem: IClientNodeItem) => {
window.removeEventListener("resize", handler) 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 ( return (
<> <>
{showModalCreateService
? <ModalServiceCreate
onHide={() => setShowModalCreateService(false)}
onAddEndpoint={(values: any) => onAddEndpoint(values)}
/>
: null
}
{nodeForEdit
? <ModalServiceEdit
node={nodeForEdit}
onHide={() => setNodeForEdit(null)}
onUpdateEndpoint={(values: any) => onUpdateEndpoint(values)}
/>
: null
}
{nodeForDelete
? <ModalConfirmDelete
onHide={() => setNodeForDelete(null)}
onConfirm={() => {
onRemoveEndpoint(nodeForDelete);
setNodeForDelete(null);
}}
/>
: null
}
<div className="px-4 py-3 border-b border-gray-200"> <div className="px-4 py-3 border-b border-gray-200">
<form <form
className="flex flex-col space-y-2 md:flex-row md:justify-between items-center" className="flex flex-col space-y-2 md:flex-row md:justify-between items-center"
@ -170,7 +315,7 @@ export default function Project(props: IProjectProps) {
<div className="flex flex-col space-y-2 w-full justify-end mb-4 md:flex-row md:space-y-0 md:space-x-2 md:mb-0"> <div className="flex flex-col space-y-2 w-full justify-end mb-4 md:flex-row md:space-y-0 md:space-x-2 md:mb-0">
<button <button
onClick={() => { onClick={() => {
window.location.replace("/") window.location.replace("/projects/new")
}} }}
type="button" type="button"
className="btn-util text-black bg-gray-200 hover:bg-gray-300 sm:w-auto" className="btn-util text-black bg-gray-200 hover:bg-gray-300 sm:w-auto"
@ -186,7 +331,7 @@ export default function Project(props: IProjectProps) {
className="btn-util text-white bg-green-600 hover:bg-green-700 sm:w-auto" className="btn-util text-white bg-green-600 hover:bg-green-700 sm:w-auto"
> >
<div className="flex justify-center items-center space-x-2 mx-auto"> <div className="flex justify-center items-center space-x-2 mx-auto">
{saving && <Spinner className="w-4 h-4 text-green-300" />} {mutation.isLoading && <Spinner className="w-4 h-4 text-green-300" />}
<span>Save</span> <span>Save</span>
</div> </div>
</button> </button>
@ -195,8 +340,61 @@ export default function Project(props: IProjectProps) {
</div> </div>
<div className="flex flex-grow relative flex-col md:flex-row"> <div className="flex flex-grow relative flex-col md:flex-row">
<Canvas state={state} dispatch={dispatch} height={height - 64} /> <div className="w-full overflow-hidden md:w-2/3 z-40" style={{ height: height }}>
<div className="relative h-full">
<div className="absolute top-0 right-0 z-40">
<div className="flex space-x-2 p-2">
<button className="flex space-x-1 btn-util" type="button" onClick={() => setShowModalCreateService(true)}>
<PlusIcon className="w-3" />
<span>Service</span>
</button>
<button className="btn-util" type="button" onClick={() => setShowVolumesModal(true)}>
Volumes
</button>
<button className="btn-util" type="button" onClick={() => setShowNetworksModal(true)}>
Networks
</button>
</div>
</div>
<Canvas
nodes={nodes}
connections={connections}
canvasPosition={canvasPosition}
onNodeUpdate={(node: IServiceNodePosition) => 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)}
/>
</div>
</div>
<div className="relative group code-column w-full md:w-1/3">
<div className={`absolute top-0 left-0 right-0 z-10 flex justify-end p-1 space-x-2 group-hover:visible invisible`}>
<button className={`btn-util ${language === "json" ? `btn-util-selected` : ``}`} onClick={() => setLanguage('json')}>json</button>
<button className={`btn-util ${language === "yaml" ? `btn-util-selected` : ``}`} onClick={() => setLanguage('yaml')}>yaml</button>
<button className="btn-util" type="button" onClick={copy}>{copyText}</button>
</div>
<CodeEditor
data={formattedCode}
language={language}
onChange={(e: any) => { onCodeUpdate(e) }}
disabled={false}
lineWrapping={false}
height={height - 64}
/>
</div>
</div> </div>
</> </>
) )
} }
return (
<div className="flex items-center justify-center items-stretch min-h-screen align-middle">
<Spinner className="w-4 h-4 m-auto dark:text-blue-400 text-blue-600"></Spinner>
</div>
);
}

@ -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<HTMLDivElement>) => {
event.preventDefault();
if (onClose) {
onClose(id, source, target);
}
}
return (
<div className='absolute -top-4 left-0' onClick={handleClose} title={id || 'UNKNOWN'}>
<TrashIcon className="w-3.5 text-gray-500" />
</div>
);
}
export default Close

@ -16,7 +16,8 @@ import {
EVENT_DRAG_START, EVENT_DRAG_START,
EVENT_DRAG_STOP, EVENT_DRAG_STOP,
EVENT_CONNECTION_DBL_CLICK, EVENT_CONNECTION_DBL_CLICK,
DragStartPayload DragStartPayload,
DragStopPayload
} from "@jsplumb/browser-ui"; } from "@jsplumb/browser-ui";
import { import {
defaultOptions, defaultOptions,
@ -35,7 +36,7 @@ export const useJsPlumb = (
nodes: Dictionary<IClientNodeItem>, nodes: Dictionary<IClientNodeItem>,
connections: Array<[string, string]>, connections: Array<[string, string]>,
onGraphUpdate: Function, onGraphUpdate: Function,
onEndpointPositionUpdate: Function, onNodeUpdate: Function,
onConnectionAttached: Function, onConnectionAttached: Function,
onConnectionDetached: Function onConnectionDetached: Function
): [(containerElement: HTMLDivElement) => void, ): [(containerElement: HTMLDivElement) => void,
@ -45,7 +46,9 @@ export const useJsPlumb = (
const [instance, setInstance] = useState<BrowserJsPlumbInstance>(null as any); const [instance, setInstance] = useState<BrowserJsPlumbInstance>(null as any);
const containerRef = useRef<HTMLDivElement>(); const containerRef = useRef<HTMLDivElement>();
const stateRef = useRef<Dictionary<IClientNodeItem>>(); const stateRef = useRef<Dictionary<IClientNodeItem>>();
const instanceRef = useRef<BrowserJsPlumbInstance>();
stateRef.current = nodes; stateRef.current = nodes;
instanceRef.current = instance;
const containerCallbackRef = useCallback((containerElement: HTMLDivElement) => { const containerCallbackRef = useCallback((containerElement: HTMLDivElement) => {
containerRef.current = containerElement; containerRef.current = containerElement;
}, []); }, []);
@ -88,7 +91,10 @@ export const useJsPlumb = (
}); });
}, [instance]); }, [instance]);
const removeEndpoint = useCallback((node) => { const removeEndpoint = (node: any) => {
if (!instanceRef.current) return;
const instance = instanceRef.current;
const nodeConnections = instance.getConnections({ target: node.key }); const nodeConnections = instance.getConnections({ target: node.key });
if (nodeConnections) { if (nodeConnections) {
@ -100,7 +106,7 @@ export const useJsPlumb = (
instance.removeAllEndpoints(document.getElementById(node.key) as Element); instance.removeAllEndpoints(document.getElementById(node.key) as Element);
instance.repaintEverything(); instance.repaintEverything();
}, [instance]); };
const getAnchors = (port: string[], anchorIds: AnchorId[]): IAnchor[] => { const getAnchors = (port: string[], anchorIds: AnchorId[]): IAnchor[] => {
return port.map( return port.map(
@ -119,9 +125,8 @@ export const useJsPlumb = (
location: .5, location: .5,
id: "remove-conn", id: "remove-conn",
cssClass: ` cssClass: `
block jtk-overlay remove-conn-btn text-xs leading-normal block jtk-overlay remove-conn-btn text-xs leading-normal cursor-pointer
cursor-pointer text-white font-bold rounded-full w-5 h-5 text-white font-bold rounded-full w-5 h-5 z-20 flex justify-center
z-20 flex justify-center
`, `,
events: { events: {
click: (e: any) => { click: (e: any) => {
@ -301,15 +306,17 @@ export const useJsPlumb = (
}); });
}); });
jsPlumbInstance.bind(EVENT_DRAG_STOP, (p: any) => { jsPlumbInstance.bind(EVENT_DRAG_STOP, (params: DragStopPayload) => {
onEndpointPositionUpdate({ params.elements.forEach((el) => {
key: p.el.id, onNodeUpdate({
key: el.id,
position: { position: {
top: p.el.offsetTop, top: el.pos.y,
left: p.el.offsetLeft left: el.pos.x
} }
}); });
}); });
});
jsPlumbInstance.bind(EVENT_CONNECTION_DBL_CLICK, (connection: Connection) => { jsPlumbInstance.bind(EVENT_CONNECTION_DBL_CLICK, (connection: Connection) => {
jsPlumbInstance.deleteConnection(connection); jsPlumbInstance.deleteConnection(connection);

@ -2,7 +2,7 @@ const eventBus = {
on(event: string, callback: { (data: any): void; (data: any): void; (arg: any): any; }) { on(event: string, callback: { (data: any): void; (data: any): void; (arg: any): any; }) {
document.addEventListener(event, (e) => callback(e)); 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 })); document.dispatchEvent(new CustomEvent(event, { detail: data }));
}, },
remove(event: string, callback: { (): void; (this: Document, ev: any): any; }) { remove(event: string, callback: { (): void; (this: Document, ev: any): any; }) {

@ -1 +0,0 @@
export { default as useProject } from "./useProject";

@ -1,17 +1,64 @@
import axios from "axios" import axios from "axios"
import { useQuery, useMutation, useQueryClient, QueryClient } from "react-query";
import { API_SERVER_URL } from "../constants";
import { IProjectPayload } from "../types";
const useProject = (uuid?: string) => { const fetchProjectByUuid = async (uuid: string) => {
return useQuery(["project", uuid], async () => { const response = await axios.get(`${API_SERVER_URL}/projects/${uuid}/`);
if (!uuid) { return response.data;
return;
} }
const { data } = await axios.get(`${API_SERVER_URL}/projects/${uuid}/`, {
const updateProjectByUuid = async (uuid: string, data: string) => {
const response = await axios({
method: 'put',
url: `${API_SERVER_URL}/projects/${uuid}/`,
headers: { headers: {
"Content-Type": "application/json" "Content-Type": "application/json"
},
data: data
});
return response.data;
} }
})
return 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();
export default useProject; 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);
},
}
)
}

@ -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_LOGIN_SUCCESS = "auth-login-success";
const AUTH_LOGOUT_SUCCESS = "auth-logout-success"; const AUTH_LOGOUT_SUCCESS = "auth-logout-success";
const AUTH_SELF = "auth-self" 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 = { export const initialState = {
projectName: "", user: {}
nodes: {},
connections: [],
canvasPosition: {
top: 0,
left: 0,
scale: 1
}
} }
export const reducer = (state: any, action: any) => { export const reducer = (state: any, action: any) => {
let existingIndex;
let _connections;
switch (action.type) { 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: case AUTH_LOGIN_SUCCESS:
return { return {
...state, ...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) => ({ export const authLoginSuccess = (data: any) => ({
type: AUTH_LOGIN_SUCCESS, type: AUTH_LOGIN_SUCCESS,
payload: data payload: data
@ -180,7 +42,3 @@ export const authSelf = (data: any) => ({
type: AUTH_SELF, type: AUTH_SELF,
payload: data payload: data
}); });
export const resetState = () => ({
type: RESET
})

@ -2,6 +2,14 @@ import { AnchorId } from "@jsplumb/common";
import { Dictionary } from "lodash"; import { Dictionary } from "lodash";
import { NodeGroupType } from "./enums"; import { NodeGroupType } from "./enums";
export interface IServiceNodePosition {
key: string;
position: {
left: number;
top: number;
}
}
export interface IContainer { export interface IContainer {
name: string; name: string;
args?: string[]; args?: string[];
@ -79,14 +87,18 @@ export interface IProjectPayload {
name: string; name: string;
data: { data: {
canvas: { canvas: {
position: any; position: {
nodes: any; top: number;
left: number;
scale: number;
};
nodes: {};
connections: any; connections: any;
}; };
configs: []; configs: [];
networks: []; networks: [];
secrets: []; secrets: [];
services: IService[]; services: {};
version: number; version: number;
volumes: []; volumes: [];
} }

@ -1,6 +1,19 @@
import { useEffect } from "react"; import { useEffect } from "react";
/**
* Use it from the component that needs outside clicks.
*
* import { useClickOutside } from "../../utils/clickOutside";
*
* const drop = createRef<HTMLDivElement>();
* useClickOutside(drop, () => {
* // do stuff...
* });
*
* @param ref
* @param onClickOutside
*/
export const useClickOutside = (ref: any, onClickOutside: any) => { export const useClickOutside = (ref: any, onClickOutside: any) => {
useEffect( useEffect(
() => { () => {

@ -236,3 +236,7 @@ export const truncateStr = (str: string, length: number) => {
return str return str
} }
export const getMatchingSetIndex = (setOfSets: [[string, string]], findSet: [string, string]): number => {
return setOfSets.findIndex((set) => set.toString() === findSet.toString());
}

Loading…
Cancel
Save