You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ctk/services/frontend/src/components/Project/index.tsx

401 lines
13 KiB
TypeScript

import { useEffect, useState, useRef, useMemo } from "react";
import { useParams, useNavigate } from "react-router-dom";
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 { 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 {}
export default function Project(props: IProjectProps) {
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 [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});
stateNodesRef.current = nodes;
stateConnectionsRef.current = connections;
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 => {})
.finally(() => {
})
}
const onNodeUpdate = (positionData: IServiceNodePosition) => {
if (stateNodesRef.current) {
const node = { ...stateNodesRef.current[positionData.key], ...positionData };
setNodes({ ...stateNodesRef.current, [positionData.key]: node });
}
}
const onSave = () => {
const payload: IProjectPayload = {
name: projectName,
data: {
canvas: {
position: canvasPosition,
nodes: nodes,
connections: connections
},
configs: [],
networks: [],
secrets: [],
services: nodes,
version: 3,
volumes: []
}
}
if (uuid) {
mutation.mutate(payload);
} else {
createProject(payload);
}
}
const setViewHeight = () => {
let vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty("--vh", `${vh}px`);
}
const copy = () => {
navigator.clipboard.writeText(formattedCode);
setCopyText("Copied");
setTimeout(() => {
setCopyText("Copy");
}, 300);
}
useEffect(() => {
if (!data) {
return;
}
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(() => {
const handler = () => {
setViewHeight();
}
window.addEventListener("resize", handler);
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) => {
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
}
{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>
<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>
</>
)
}
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>
);
}