Canvas: build application infrastructure with drag and drop

Change-Id: I5cfd12e67794f3376c5c025af29470d52d77cf16
diff --git a/apps/canvas/src/components/node-gateway-https.tsx b/apps/canvas/src/components/node-gateway-https.tsx
new file mode 100644
index 0000000..cd9e06c
--- /dev/null
+++ b/apps/canvas/src/components/node-gateway-https.tsx
@@ -0,0 +1,220 @@
+import { useStateStore, AppNode, GatewayHttpsNode, ServiceNode, nodeLabel, useEnv, nodeIsConnectable } from '@/lib/state';
+import { Handle, Position, useNodes } from '@xyflow/react';
+import { NodeRect } from './node-rect';
+import { useEffect, useMemo } 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';
+
+const schema = z.object({
+  network: z.string().min(1, "reqired"),
+  subdomain: z.string().min(1, "required"),
+});
+
+const connectedToSchema = z.object({
+  id: z.string(),
+  portId: z.string(),
+});
+
+export function NodeGatewayHttps(node: GatewayHttpsNode) {
+  const { id, selected } = node;
+  const isConnectable = useMemo(() => nodeIsConnectable(node, "https"), [node]);
+  return (
+    <NodeRect id={id} selected={selected} type={node.type}>
+      {nodeLabel(node)}
+      <Handle
+        type={"target"}
+        id="https" 
+        position={Position.Bottom}
+        isConnectable={isConnectable}
+        isConnectableStart={isConnectable} 
+        isConnectableEnd={isConnectable} 
+      />
+    </NodeRect>
+  );
+}
+
+export function NodeGatewayHttpsDetails({ id, data }: GatewayHttpsNode) {
+  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-https">(id, { network: value.network });
+        } else {
+          
+        }
+      } else if (name === "subdomain") {
+        store.updateNodeData<"gateway-https">(id, { subdomain: value.subdomain });
+      }
+    });
+    return () => sub.unsubscribe();
+  }, [form, store]);
+  const connectedToForm = useForm<z.infer<typeof connectedToSchema>>({
+    resolver: zodResolver(connectedToSchema),
+    mode: "onChange",
+    defaultValues: {
+      id: data.https?.serviceId,
+      portId: data.https?.portId,
+    },
+  });
+  useEffect(() => {
+    connectedToForm.reset({
+      id: data.https?.serviceId,
+      portId: data.https?.portId,
+    });
+  }, [connectedToForm, data]);
+  const nodes = useNodes<AppNode>();
+  const selected = useMemo(() => {
+    if (data !== undefined && data.https !== undefined) {
+      const https = data.https;
+      return nodes.find((n) => n.id === https.serviceId)! as ServiceNode;
+    }
+    return null;
+  }, [data]);
+  const selectable = useMemo(() => {
+    return nodes.filter((n) => {
+      if (n.id === id) {
+        return false;
+      }
+      if (selected !== null && selected.id === id) {
+        return true;
+      }
+      if (n.type !== "app") {
+        return false;
+      }
+      return n.data && n.data.ports && n.data.ports.length > 0;
+    })
+  }, [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 }) => {
+      console.log({ name, type });
+      if (type !== "change") {
+        return;
+      }
+      switch (name) {
+        case "id":
+          if (!value.id) {
+            break;
+          }
+          const current = store.edges.filter((e) => e.target === id);
+          const cid = current[0] ? current[0].id : undefined;
+          store.replaceEdge({
+            source: value.id,
+            sourceHandle: "ports",
+            target: id,
+            targetHandle: "https",
+          }, cid);
+          break;
+        case "portId":
+          store.updateNodeData<"gateway-https">(id, {
+            https: {
+              serviceId: value.id,
+              portId: value.portId,
+            }
+          });
+          break;
+      }
+    });
+    return () => sub.unsubscribe();
+  }, [connectedToForm, store, selectable]);
+  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>
+      <Form {...connectedToForm}>
+        <form className="space-y-2">
+        <FormField
+            control={connectedToForm.control}
+            name="id"
+            render={({ field }) => (
+              <FormItem>
+                <Select onValueChange={field.onChange} defaultValue={field.value}>
+                  <FormControl>
+                    <SelectTrigger>
+                      <SelectValue placeholder="Service" />
+                    </SelectTrigger>
+                  </FormControl>
+                  <SelectContent>
+                    {selectable.map((n) => (
+                      <SelectItem key={n.id} 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>           
+            )}
+          />
+        </form>
+      </Form>
+    </>
+  );
+}
\ No newline at end of file