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/jest-dom": "^5.16.2",
"@testing-library/react": "^12.1.2", "@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"axios": "^0.27.2",
"codemirror": "^5.65.5", "codemirror": "^5.65.5",
"d3": "^7.3.0", "d3": "^7.3.0",
"formik": "^2.2.9", "formik": "^2.2.9",
@ -37,6 +38,7 @@
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-hot-toast": "^2.2.0", "react-hot-toast": "^2.2.0",
"react-query": "^3.39.1",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.3.0",
"react-scripts": "5.0.0", "react-scripts": "5.0.0",
"tailwindcss": "^3.0.19", "tailwindcss": "^3.0.19",

@ -1,6 +1,9 @@
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 { 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";
@ -18,15 +21,17 @@ import ProtectedRoute from "./partials/ProtectedRoute";
import "./index.css"; import "./index.css";
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,
authenticationPath: '/login', authenticationPath: "/login"
}; }
useEffect(() => { useEffect(() => {
if (isAuthenticated) { if (isAuthenticated) {
@ -45,7 +50,7 @@ export default function App() {
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)
@ -56,7 +61,10 @@ export default function App() {
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(LOCAL_STORAGE, JSON.stringify(localDataParsed)) localStorage.setItem(
LOCAL_STORAGE,
JSON.stringify(localDataParsed)
);
} }
} }
}) })
@ -69,16 +77,35 @@ export default function App() {
}, [dispatch, isAuthenticated]); }, [dispatch, isAuthenticated]);
return ( return (
<div> <QueryClientProvider client={queryClient}>
<Toaster /> <div>
<Routes> <Toaster />
<Route path="/" element={<Project dispatch={dispatch} state={state} />} /> <Routes>
<Route path="/projects/:uuid" element={<Project dispatch={dispatch} state={state} />} /> <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} />} />} /> <ReactQueryDevtools initialIsOpen={true} />
<Route path="/signup" element={<Signup dispatch={dispatch} />} /> </QueryClientProvider>
<Route path="/login" element={<Login dispatch={dispatch} />} /> )
</Routes>
</div>
);
} }

@ -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,135 +149,59 @@ export const Canvas: FC<ICanvasProps> = (props) => {
return ( return (
<> <>
{showModalCreateService {nodes &&
? <ModalServiceCreate <div key={CANVAS_ID} className="jsplumb-box"
onHide={() => setShowModalCreateService(false)} onWheel={onCanvasMousewheel}
onAddEndpoint={(values: any) => onAddEndpoint(values)} onMouseMove={onCanvasMouseMove}
/> onMouseDown={onCanvasMouseDown}
: null onMouseUp={onCanvasMouseUpLeave}
} onMouseLeave={onCanvasMouseUpLeave}
onContextMenu={(event) => { event.stopPropagation(); event.preventDefault(); }}
{showModalEditService >
? <ModalServiceEdit <div
node={selectedNode} id={CANVAS_ID}
onHide={() => setShowModalEditService(false)} ref={containerCallbackRef}
onUpdateEndpoint={(values: any) => onUpdateEndpoint(values)} className="canvas h-full w-full"
/> style={{
: null transformOrigin: '0px 0px 0px',
} transform: `translate(${translateWidth}px, ${translateHeight}px) scale(${_scale})`
}}
{showModalConfirmDeleteService >
? <ModalConfirmDelete {values(nodes).map((x) => (
onHide={() => setShowModalConfirmDeleteService(false)} <div
onConfirm={() => { key={x.key}
setShowModalEditService(false); className={"node-item cursor-pointer shadow flex flex-col group"}
if (selectedNode) { id={x.key}
onRemoveEndpoint(selectedNode.key); style={{ top: x.position.top, left: x.position.left }}
} onMouseEnter={() => setNodeHovering(x.key)}
}} onMouseLeave={() => {
/> if (nodeHovering === x.key) {
: null setNodeHovering(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(); }}
> >
<div {((nodeHovering === x.key) && (nodeDragging !== x.key)) &&
id={CANVAS_ID} <Popover
ref={containerCallbackRef} onEditClick={() => {
className="canvas h-full w-full" setNodeForEdit(x);
style={{ }}
transformOrigin: '0px 0px 0px', onDeleteClick={() => {
transform: `translate(${translateWidth}px, ${translateHeight}px) scale(${scale})` setNodeForDelete(x);
}} }}
> ></Popover>
{(values(instanceNodes).length > 0) && ( }
<> <div className="node-label w-full py-2 px-4">
{values(instanceNodes).map((x) => ( <div className="text-sm font-semibold">
<div {x.configuration.prettyName}
key={x.key} </div>
className={"node-item cursor-pointer shadow flex flex-col group"} <div className="text-xs text-gray-500">
id={x.key} {x.configuration.prettyName}
style={{ top: x.position.top, left: x.position.left }} </div>
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>
))}
</>
)}
</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> </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 { 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 eventBus from "../../events/eventBus";
import { useProject, useUpdateProject } from "../../hooks/useProject";
import useWindowDimensions from "../../hooks/useWindowDimensions"; import useWindowDimensions from "../../hooks/useWindowDimensions";
import { getClientNodesAndConnections } from "../../utils"; import { projectHttpCreate } from "../../services/project";
import { projectHttpGet, projectHttpUpdate, projectHttpCreate } from "../../services/project"; import { flattenGraphData } from "../../utils/generators";
import { checkHttpStatus } from "../../services/helpers";
import { nodeLibraries } from "../../utils/data/libraries"; 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 { interface IProjectProps {}
dispatch: any;
state: any;
}
export default function Project(props: 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 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) => { 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 copy = () => {
navigator.clipboard.writeText(formattedCode);
setCopyText("Copied");
setTimeout(() => {
setCopyText("Copy");
}, 300);
}
useEffect(() => { useEffect(() => {
if (uuid) { if (!data) {
projectHttpGet(uuid) return;
.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);
})
} }
}, [uuid, dispatch]);
useEffect(() => { console.log(data);
//setProjectName(state.projectName);
}, [state.projectName]); 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(() => { useEffect(() => {
window.addEventListener("resize", () => { const handler = () => {
setViewHeight(); setViewHeight();
}); }
window.addEventListener("resize", handler);
setViewHeight(); setViewHeight();
return () => {
window.removeEventListener("resize", handler);
}
}, []); }, []);
return ( const onAddEndpoint = (values: any) => {
<> let sections = flattenLibraries(nodeLibraries);
<div className="px-4 py-3 border-b border-gray-200"> let clientNodeItem = getClientNodeItem(values, ensure(sections.find((l) => l.Type === values.type)));
<form className="flex flex-col space-y-2 md:flex-row md:justify-between items-center" autoComplete="off"> clientNodeItem.position = { left: 60, top: 30 };
<input setNodes({ ...nodes, [clientNodeItem.key]: clientNodeItem });
className={` }
bg-gray-100
appearance-none const onUpdateEndpoint = (nodeItem: IClientNodeItem) => {
w-full setNodes({ ...nodes, [nodeItem.key]: nodeItem });
md:w-1/2 }
lg:w-1/3
block const onConnectionDetached = (data: any) => {
text-gray-700 if (!stateConnectionsRef.current || stateConnectionsRef.current.length <= 0) {
border return;
border-gray-100 }
dark:bg-gray-900
dark:text-white const _connections: [[string, string]] = [...stateConnectionsRef.current] as any;
dark:border-gray-900 const existingIndex = getMatchingSetIndex(_connections, data);
rounded
py-2 if (existingIndex !== -1) {
px-3 _connections.splice(existingIndex, 1);
leading-tight }
focus:outline-none
focus:border-indigo-400 setConnections(_connections);
focus:ring-0 }
`}
type="text" const onConnectionAttached = (data: any) => {
placeholder="Untitled" if (stateConnectionsRef.current && stateConnectionsRef.current.length > 0) {
autoComplete="off" const _connections: [[string, string]] = [...stateConnectionsRef.current] as any;
id="name" const existingIndex = getMatchingSetIndex(_connections, data);
name="name" if (existingIndex === -1) {
onChange={handleNameChange} _connections.push(data);
value={projectName} }
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"> {nodeForEdit
<button ? <ModalServiceEdit
onClick={() => { node={nodeForEdit}
window.location.replace("/"); onHide={() => setNodeForEdit(null)}
}} onUpdateEndpoint={(values: any) => onUpdateEndpoint(values)}
type="button" />
className="btn-util text-black bg-gray-200 hover:bg-gray-300 sm:w-auto" : null
> }
<div className="flex justify-center items-center space-x-2 mx-auto">
<span>New</span> {nodeForDelete
</div> ? <ModalConfirmDelete
</button> onHide={() => setNodeForDelete(null)}
onConfirm={() => {
<button onRemoveEndpoint(nodeForDelete);
onClick={() => onSave()} setNodeForDelete(null);
type="button" }}
className="btn-util text-white bg-green-600 hover:bg-green-700 sm:w-auto" />
> : null
<div className="flex justify-center items-center space-x-2 mx-auto"> }
{saving &&
<Spinner className="w-4 h-4 text-green-300" /> <div className="px-4 py-3 border-b border-gray-200">
} <form
<span>Save</span> 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> </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> </div>
</form> </div>
</div> </>
)
<div className="flex flex-grow relative flex-col md:flex-row"> }
<Canvas
state={state} return (
dispatch={dispatch} <div className="flex items-center justify-center items-stretch min-h-screen align-middle">
height={(height - 64)} <Spinner className="w-4 h-4 m-auto dark:text-blue-400 text-blue-600"></Spinner>
/> </div>
</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,13 +306,15 @@ 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({
position: { key: el.id,
top: p.el.offsetTop, position: {
left: p.el.offsetLeft 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; }) { 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; }) {

@ -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_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(
() => { () => {

@ -235,4 +235,8 @@ 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