Canvas: build application infrastructure with drag and drop
Change-Id: I5cfd12e67794f3376c5c025af29470d52d77cf16
diff --git a/apps/canvas/src/components/node-gateway-tcp.tsx b/apps/canvas/src/components/node-gateway-tcp.tsx
new file mode 100644
index 0000000..8cfebff
--- /dev/null
+++ b/apps/canvas/src/components/node-gateway-tcp.tsx
@@ -0,0 +1,258 @@
+import { v4 as uuidv4 } from "uuid";
+import { useStateStore, AppNode, nodeLabel, useEnv, GatewayTCPNode, nodeIsConnectable } from '@/lib/state';
+import { Edge, Handle, Position, useNodes } from '@xyflow/react';
+import { NodeRect } from './node-rect';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm, EventType, DeepPartial } from 'react-hook-form';
+import { Form, FormControl, FormField, FormItem, FormMessage } from './ui/form';
+import { Input } from './ui/input';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
+import { Button } from "./ui/button";
+
+const schema = z.object({
+ network: z.string().min(1, "reqired"),
+ subdomain: z.string().min(1, "required"),
+});
+
+const connectedToSchema = z.object({
+ serviceId: z.string(),
+ portId: z.string(),
+});
+
+export function NodeGatewayTCP(node: GatewayTCPNode) {
+ const { id, selected } = node;
+ const isConnectable = useMemo(() => nodeIsConnectable(node, "tcp"), [node]);
+ return (
+ <NodeRect id={id} selected={selected} type={node.type}>
+ {nodeLabel(node)}
+ <Handle
+ type={"target"}
+ id="tcp"
+ position={Position.Bottom}
+ isConnectable={isConnectable}
+ isConnectableStart={isConnectable}
+ isConnectableEnd={isConnectable}
+ />
+ </NodeRect>
+ );
+}
+
+export function NodeGatewayTCPDetails({ id, data }: GatewayTCPNode) {
+ const store = useStateStore();
+ const env = useEnv();
+ const form = useForm<z.infer<typeof schema>>({
+ resolver: zodResolver(schema),
+ mode: "onChange",
+ defaultValues: {
+ network: "",
+ subdomain: "",
+ },
+ });
+ useEffect(() => {
+ const sub = form.watch((value: DeepPartial<z.infer<typeof schema>>, { name }: { name?: keyof z.infer<typeof schema> | undefined, type?: EventType | undefined }) => {
+ if (name === "network") {
+ if (value.network !== undefined) {
+ store.updateNodeData<"gateway-tcp">(id, { network: value.network });
+ } else {
+
+ }
+ } else if (name === "subdomain") {
+ store.updateNodeData<"gateway-tcp">(id, { subdomain: value.subdomain });
+ }
+ });
+ return () => sub.unsubscribe();
+ }, [form, store]);
+ const connectedToForm = useForm<z.infer<typeof connectedToSchema>>({
+ resolver: zodResolver(connectedToSchema),
+ mode: "onSubmit",
+ defaultValues: {
+ serviceId: data.selected?.serviceId,
+ portId: data.selected?.portId,
+ },
+ });
+ useEffect(() => {
+ connectedToForm.reset({
+ serviceId: data.selected?.serviceId,
+ portId: data.selected?.portId,
+ });
+ console.log(connectedToForm.getValues());
+ }, [connectedToForm, data]);
+ const nodes = useNodes<AppNode>();
+ const [selected, setSelected] = useState<AppNode | undefined>(undefined);
+ useEffect(() => {
+ if (data.selected?.serviceId == null) {
+ setSelected(undefined);
+ } else {
+ const serviceId = data.selected.serviceId;
+ setSelected(nodes.find((n) => n.id === serviceId));
+ }
+ }, [data, setSelected]);
+ const selectable = useMemo(() => {
+ console.log(selected);
+ return nodes.filter((n) => {
+ if (n.id === id) {
+ return false;
+ }
+ if (selected != null && selected.id === id) {
+ return true;
+ }
+ if ("ports" in n.data && (n.data.ports || []).length > 0) {
+ return true;
+ }
+ return false;
+ })
+ }, [nodes, selected]);
+ useEffect(() => {
+ const sub = connectedToForm.watch((value: DeepPartial<z.infer<typeof connectedToSchema>>, { name, type }: { name?: keyof z.infer<typeof connectedToSchema> | undefined, type?: EventType | undefined }) => {
+ if (type !== "change") {
+ return;
+ }
+ switch (name) {
+ case "serviceId":
+ if (!value.serviceId) {
+ break;
+ }
+ store.updateNodeData<"gateway-tcp">(id, {
+ selected: {
+ serviceId: value.serviceId,
+ },
+ });
+ break;
+ case "portId":
+ if (!value.portId) {
+ break;
+ }
+ store.updateNodeData<"gateway-tcp">(id, {
+ selected: {
+ serviceId: value.serviceId,
+ portId: value.portId,
+ },
+ });
+ break;
+ }
+ });
+ return () => sub.unsubscribe();
+ }, [connectedToForm, store]);
+ const [nodeLabels, setNodeLabels] = useState(new Map<string, string>());
+ const [portLabels, setPortLabels] = useState(new Map<string, string>());
+ useEffect(() => {
+ setNodeLabels(new Map((data.exposed || []).map((e) => [e.serviceId, nodeLabel(nodes.find((n) => n.id === e.serviceId)!)])));
+ setPortLabels(new Map((data.exposed || []).map((e) => [`${e.serviceId} - ${e.portId}`, (nodes.find((n) => n.id === e.serviceId)!.data.ports || []).find((p) => p.id === e.portId)!.name])));
+ }, [nodes, data, setNodeLabels, setPortLabels]);
+ const onSubmit = useCallback((values: z.infer<typeof connectedToSchema>) => {
+ store.setEdges(store.edges.filter((e) => e.target !== id));
+ const exp = (data.exposed || []).concat({
+ serviceId: values.serviceId,
+ portId: values.portId,
+ });
+ store.updateNodeData<"gateway-tcp">(id, {
+ exposed: exp,
+ selected: undefined,
+ });
+ store.setEdges(store.edges.concat(exp.map((e): Edge => ({
+ id: uuidv4(),
+ source: e.serviceId,
+ sourceHandle: "ports",
+ target: id,
+ targetHandle: "tcp",
+ }))));
+ }, [id, data, connectedToForm, store, setNodeLabels, setPortLabels]);
+ return (
+ <>
+ <Form {...form}>
+ <form className="space-y-2">
+ <FormField
+ control={form.control}
+ name="network"
+ render={({ field }) => (
+ <FormItem>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="Network" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {env.networks.map((n) => (
+ <SelectItem key={n.name} value={n.domain}>{`${n.name} - ${n.domain}`}</SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="subdomain"
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <Input placeholder="subdomain" className="border border-black" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </form>
+ </Form>
+ Exposed Services
+ <ul>
+ {(data.exposed || []).map((e, i) => (
+ <li key={i}>
+ {nodeLabels.get(e.serviceId)} - {portLabels.get(`${e.serviceId} - ${e.portId}`)}
+ </li>
+ ))}
+ </ul>
+ <Form {...connectedToForm}>
+ <form className="space-y-2" onSubmit={connectedToForm.handleSubmit(onSubmit)}>
+ <FormField
+ control={connectedToForm.control}
+ name="serviceId"
+ render={({ field }) => (
+ <FormItem>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="Service" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {selectable.map((n) => (
+ <SelectItem value={n.id}>{nodeLabel(n)}</SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={connectedToForm.control}
+ name="portId"
+ render={({ field }) => (
+ <FormItem>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="Port" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {selected && (selected.data.ports || []).map((p) => (
+ <SelectItem key={p.id} value={p.id}>{p.name} - {p.value}</SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <Button type="submit">Expose</Button>
+ </form>
+ </Form>
+ </>
+ );
+}
\ No newline at end of file