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