Merge pull request #63 from nuxxapp/feat/react-query

Replace Redux with React Query
remotes/origin/refactor/jwt-ls
Artem Golub 3 years ago committed by GitHub
commit 74b90ae4ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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",

@ -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<ProtectedRouteProps, 'outlet'> = {
const defaultProtectedRouteProps: Omit<ProtectedRouteProps, "outlet"> = {
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 (
<div>
<Toaster />
<Routes>
<Route path="/" element={<Project dispatch={dispatch} state={state} />} />
<Route path="/projects/:uuid" element={<Project dispatch={dispatch} state={state} />} />
<QueryClientProvider client={queryClient}>
<div>
<Toaster />
<Routes>
<Route
path="/projects/:uuid"
element={<Project />}
/>
<Route
path="/projects/new"
element={<Project />}
/>
<Route
path="/profile"
element={
<ProtectedRoute
{...defaultProtectedRouteProps}
outlet={<Profile dispatch={dispatch} state={state} />}
/>
}
/>
<Route path="/signup" element={<Signup dispatch={dispatch} />} />
<Route path="/login" element={<Login dispatch={dispatch} />} />
</Routes>
</div>
<Route path="/profile" element={<ProtectedRoute {...defaultProtectedRouteProps} outlet={<Profile dispatch={dispatch} state={state} />} />} />
<Route path="/signup" element={<Signup dispatch={dispatch} />} />
<Route path="/login" element={<Login dispatch={dispatch} />} />
</Routes>
</div>
);
<ReactQueryDevtools initialIsOpen={true} />
</QueryClientProvider>
)
}

@ -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<ICanvasProps> = (props) => {
const { state, dispatch, height } = props;
const [language, setLanguage] = useState("yaml");
const [scale, setScale] = useState(1);
const [generatedCode, setGeneratedCode] = useState<string>();
const [formattedCode, setFormattedCode] = useState<string>("");
const [instanceNodes, setInstanceNodes] = useState(state.nodes);
const [instanceConnections, setInstanceConnections] = useState(state.connections);
const [copyText, setCopyText] = useState("Copy");
const [selectedNode, setSelectedNode] = useState<IClientNodeItem | null>(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<string | null>();
const [nodeHovering, setNodeHovering] = useState<string | null>();
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<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) => {
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<ICanvasProps> = (props) => {
_setLeft(left);
_setTop(top);
setDragging(false);
dispatch(position({
onCanvasUpdate({
left: left,
top: top
}));
});
}
}
@ -217,33 +103,14 @@ export const Canvas: FC<ICanvasProps> = (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<ICanvasProps> = (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<ICanvasProps> = (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<ICanvasProps> = (props) => {
return (
<>
{showModalCreateService
? <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"
onWheel={onCanvasMousewheel}
onMouseMove={onCanvasMouseMove}
onMouseDown={onCanvasMouseDown}
onMouseUp={onCanvasMouseUpLeave}
onMouseLeave={onCanvasMouseUpLeave}
onContextMenu={(event) => { event.stopPropagation(); event.preventDefault(); }}
{nodes &&
<div key={CANVAS_ID} className="jsplumb-box"
onWheel={onCanvasMousewheel}
onMouseMove={onCanvasMouseMove}
onMouseDown={onCanvasMouseDown}
onMouseUp={onCanvasMouseUpLeave}
onMouseLeave={onCanvasMouseUpLeave}
onContextMenu={(event) => { event.stopPropagation(); event.preventDefault(); }}
>
<div
id={CANVAS_ID}
ref={containerCallbackRef}
className="canvas h-full w-full"
style={{
transformOrigin: '0px 0px 0px',
transform: `translate(${translateWidth}px, ${translateHeight}px) scale(${_scale})`
}}
>
{values(nodes).map((x) => (
<div
key={x.key}
className={"node-item cursor-pointer shadow flex flex-col group"}
id={x.key}
style={{ top: x.position.top, left: x.position.left }}
onMouseEnter={() => setNodeHovering(x.key)}
onMouseLeave={() => {
if (nodeHovering === x.key) {
setNodeHovering(null);
}
}}
>
<div
id={CANVAS_ID}
ref={containerCallbackRef}
className="canvas h-full w-full"
style={{
transformOrigin: '0px 0px 0px',
transform: `translate(${translateWidth}px, ${translateHeight}px) scale(${scale})`
}}
>
{(values(instanceNodes).length > 0) && (
<>
{values(instanceNodes).map((x) => (
<div
key={x.key}
className={"node-item cursor-pointer shadow flex flex-col group"}
id={x.key}
style={{ top: x.position.top, left: x.position.left }}
onMouseEnter={() => setNodeHovering(x.key)}
onMouseLeave={() => {
if (nodeHovering === x.key) {
setNodeHovering(null);
}
}}
>
{((nodeHovering === x.key) && (nodeDragging !== x.key)) &&
<Popover
onEditClick={() => {
setSelectedNode(x);
setShowModalEditService(true);
}}
onDeleteClick={() => {
setSelectedNode(x);
setShowModalConfirmDeleteService(true);
}}
></Popover>
}
<div className="node-label w-full py-2 px-4">
<div className="text-sm font-semibold">
{x.configuration.prettyName}
</div>
<div className="text-xs text-gray-500">
{x.configuration.prettyName}
</div>
</div>
</div>
))}
</>
)}
{((nodeHovering === x.key) && (nodeDragging !== x.key)) &&
<Popover
onEditClick={() => {
setNodeForEdit(x);
}}
onDeleteClick={() => {
setNodeForDelete(x);
}}
></Popover>
}
<div className="node-label w-full py-2 px-4">
<div className="text-sm font-semibold">
{x.configuration.prettyName}
</div>
<div className="text-xs text-gray-500">
{x.configuration.prettyName}
</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>
</>
</div>
}
</>
);

@ -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<Dictionary<IClientNodeItem>>();
const stateConnectionsRef = useRef<[[string, string]] | []>();
const handleNameChange = (e: any) => {
setProjectName(e.target.value);
dispatch(updateProjectName(e.target.value));
}
const [generatedCode, setGeneratedCode] = useState<string>();
const [formattedCode, setFormattedCode] = useState<string>("");
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) => {
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 (
<>
<div className="px-4 py-3 border-b border-gray-200">
<form className="flex flex-col space-y-2 md:flex-row md:justify-between items-center" autoComplete="off">
<input
className={`
bg-gray-100
appearance-none
w-full
md:w-1/2
lg:w-1/3
block
text-gray-700
border
border-gray-100
dark:bg-gray-900
dark:text-white
dark:border-gray-900
rounded
py-2
px-3
leading-tight
focus:outline-none
focus:border-indigo-400
focus:ring-0
`}
type="text"
placeholder="Untitled"
autoComplete="off"
id="name"
name="name"
onChange={handleNameChange}
value={projectName}
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) => {
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
? <ModalServiceCreate
onHide={() => setShowModalCreateService(false)}
onAddEndpoint={(values: any) => onAddEndpoint(values)}
/>
: null
}
<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
onClick={() => {
window.location.replace("/");
}}
type="button"
className="btn-util text-black bg-gray-200 hover:bg-gray-300 sm:w-auto"
>
<div className="flex justify-center items-center space-x-2 mx-auto">
<span>New</span>
</div>
</button>
<button
onClick={() => onSave()}
type="button"
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">
{saving &&
<Spinner className="w-4 h-4 text-green-300" />
}
<span>Save</span>
{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">
<form
className="flex flex-col space-y-2 md:flex-row md:justify-between items-center"
autoComplete="off"
>
<input
className={`
bg-gray-100
appearance-none
w-full
md:w-1/2
lg:w-1/3
block
text-gray-700
border
border-gray-100
dark:bg-gray-900
dark:text-white
dark:border-gray-900
rounded
py-2
px-3
leading-tight
focus:outline-none
focus:border-indigo-400
focus:ring-0
`}
type="text"
placeholder="Untitled"
autoComplete="off"
id="name"
name="name"
onChange={handleNameChange}
value={projectName}
/>
<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
onClick={() => {
window.location.replace("/projects/new")
}}
type="button"
className="btn-util text-black bg-gray-200 hover:bg-gray-300 sm:w-auto"
>
<div className="flex justify-center items-center space-x-2 mx-auto">
<span>New</span>
</div>
</button>
<button
onClick={() => onSave()}
type="button"
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">
{mutation.isLoading && <Spinner className="w-4 h-4 text-green-300" />}
<span>Save</span>
</div>
</button>
</div>
</form>
</div>
<div className="flex flex-grow relative flex-col md:flex-row">
<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>
</button>
<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>
</form>
</div>
<div className="flex flex-grow relative flex-col md:flex-row">
<Canvas
state={state}
dispatch={dispatch}
height={(height - 64)}
/>
</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_STOP,
EVENT_CONNECTION_DBL_CLICK,
DragStartPayload
DragStartPayload,
DragStopPayload
} from "@jsplumb/browser-ui";
import {
defaultOptions,
@ -35,7 +36,7 @@ export const useJsPlumb = (
nodes: Dictionary<IClientNodeItem>,
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<BrowserJsPlumbInstance>(null as any);
const containerRef = useRef<HTMLDivElement>();
const stateRef = useRef<Dictionary<IClientNodeItem>>();
const instanceRef = useRef<BrowserJsPlumbInstance>();
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
}
});
});
});

@ -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; }) {

@ -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);
},
}
)
}

@ -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
})

@ -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: [];
}

@ -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<HTMLDivElement>();
* useClickOutside(drop, () => {
* // do stuff...
* });
*
* @param ref
* @param onClickOutside
*/
export const useClickOutside = (ref: any, onClickOutside: any) => {
useEffect(
() => {

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

Loading…
Cancel
Save