Canvas: build application infrastructure with drag and drop

Change-Id: I5cfd12e67794f3376c5c025af29470d52d77cf16
diff --git a/apps/canvas/src/components/node-app.tsx b/apps/canvas/src/components/node-app.tsx
new file mode 100644
index 0000000..74f0e75
--- /dev/null
+++ b/apps/canvas/src/components/node-app.tsx
@@ -0,0 +1,298 @@
+import { v4 as uuidv4 } from "uuid";
+import { NodeRect } from './node-rect';
+import { useStateStore, ServiceNode, ServiceTypes, nodeLabel, BoundEnvVar, AppState, nodeIsConnectable } from '@/lib/state';
+import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState } from 'react';
+import { z } from "zod";
+import { DeepPartial, EventType, useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Form, FormControl, FormField, FormItem, FormMessage } from './ui/form';
+import { Input } from './ui/input';
+import { Button } from './ui/button';
+import { Handle, Position } from "@xyflow/react";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
+import { EditIcon } from "lucide-react";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
+
+export function NodeApp(node: ServiceNode) {
+  const { id, selected } = node;
+  const isConnectablePorts = useMemo(() => nodeIsConnectable(node, "ports"), [node]);
+  const isConnectableRepository = useMemo(() => nodeIsConnectable(node, "repository"), [node]);
+  return (
+    <NodeRect id={id} selected={selected} type={node.type}>
+      <div style={{ padding: '10px 20px' }}>
+        {nodeLabel(node)}
+        <Handle
+          id="repository"
+          type={"target"}
+          position={Position.Left}
+          isConnectableStart={isConnectableRepository}
+          isConnectableEnd={isConnectableRepository}
+          isConnectable={isConnectableRepository}
+        />
+        <Handle
+          id="ports"
+          type={"source"}
+          position={Position.Top}
+          isConnectableStart={isConnectablePorts}
+          isConnectableEnd={isConnectablePorts}
+          isConnectable={isConnectablePorts}
+        />
+        <Handle
+          id="env_var"
+          type={"target"}
+          position={Position.Bottom}
+          isConnectableStart={true}
+          isConnectableEnd={true}
+          isConnectable={true}
+        />
+      </div>
+    </NodeRect>
+  );
+}
+
+const schema = z.object({
+  name: z.string().min(1, "requried"),
+  type: z.enum(ServiceTypes),
+});
+
+const portSchema = z.object({
+  name: z.string().min(1, "required"),
+  value: z.coerce.number().gt(0, "can not be negative"),
+});
+
+export function NodeAppDetails({ id, data }: ServiceNode) {
+  const store = useStateStore();
+  const form = useForm<z.infer<typeof schema>>({
+    resolver: zodResolver(schema),
+    mode: "onChange",
+    defaultValues: {
+      name: data.label,
+      type: data.type,
+    }
+  });
+  const portForm = useForm<z.infer<typeof portSchema>>({
+    resolver: zodResolver(portSchema),
+    mode: "onSubmit",
+    defaultValues: {
+      name: "",
+      value: 0,
+    }
+  });
+  const onSubmit = useCallback((values: z.infer<typeof portSchema>) => {
+    store.updateNodeData<"app">(id, {
+      ports: (data.ports || []).concat({
+        id: uuidv4(),
+        name: values.name,
+        value: values.value,
+      })
+    });
+    portForm.reset();
+  }, [data, portForm]);
+  useEffect(() => {
+    const sub = form.watch((value: DeepPartial<z.infer<typeof schema>>, { name, type }: { name?: keyof z.infer<typeof schema> | undefined, type?: EventType | undefined }) => {
+      console.log({ name, type });
+      if (type !== "change") {
+        return;
+      }
+      switch (name) {
+        case "name":
+          if (!value.name) {
+            break;
+          }
+          store.updateNodeData<"app">(id, {
+            label: value.name,
+          });
+          break;
+        case "type":
+          if (!value.type) {
+            break;
+          }
+          store.updateNodeData<"app">(id, {
+            type: value.type,
+          })
+          break;
+      }
+    });
+    return () => sub.unsubscribe();
+  }, [form, store]);
+  const focus = useCallback((field: any, name: string) => {
+    return (e: HTMLElement | null) => {
+      field.ref(e);
+      if (e != null && name === data.activeField) {
+        console.log(e);
+        e.focus();
+        store.updateNodeData(id, {
+          activeField: undefined,
+        });
+      }
+    }
+  }, [data, store]);
+  const [typeProps, setTypeProps] = useState({});
+  useEffect(() => {
+    if (data.activeField === "type") {
+      setTypeProps({
+        open: true,
+        onOpenChange: () => store.updateNodeData(id, { activeField: undefined }),
+      });
+    } else {
+      setTypeProps({});
+    }
+  }, [store, data, setTypeProps]);
+  const editAlias = useCallback((e: BoundEnvVar) => {
+    return () => {
+      store.updateNodeData(id, {
+        ...data,
+        envVars: data.envVars!.map((o) => {
+          if (o.id !== e.id) {
+            return o;
+          } else return {
+            ...o,
+            isEditting: true,
+          }
+        }),
+      });
+    };
+  }, [id, data, store]);
+  const saveAlias = (e: BoundEnvVar, value: string, store: AppState) => {
+    store.updateNodeData(id, {
+      ...data,
+      envVars: data.envVars!.map((o) => {
+        if (o.id !== e.id) {
+          return o;
+        }
+        if (value) {
+          return {
+            ...o,
+            isEditting: false,
+            alias: value.toUpperCase(),
+          }
+        }
+        console.log(o);
+        if ("alias" in o) {
+          const { alias: tmp, ...rest } = o;
+          console.log(rest);
+          return {
+            ...rest,
+            isEditting: false,
+          };
+        }
+        return {
+          ...o,
+          isEditting: false,
+        };
+      }),
+    });
+  };
+  const saveAliasOnEnter = useCallback((e: BoundEnvVar) => {
+    return (event: KeyboardEvent<HTMLInputElement>) => {
+      if (event.key === "Enter") {
+        event.preventDefault();
+        saveAlias(e, event.currentTarget.value, store);
+      }
+    }
+  }, [id, data, store]);
+  const saveAliasOnBlur = useCallback((e: BoundEnvVar) => {
+    return (event: FocusEvent<HTMLInputElement>) => {
+      saveAlias(e, event.currentTarget.value, store);
+    }
+  }, [id, data, store]);
+  return (
+    <>
+      <Form {...form}>
+        <form>
+          <FormField
+            control={form.control}
+            name="name"
+            render={({ field }) => (
+              <FormItem>
+                <FormControl>
+                  <Input placeholder="name" className="border border-black" {...field} ref={focus(field, "name")} />
+                </FormControl>
+                <FormMessage />
+              </FormItem>
+            )}
+          />
+          <FormField
+            control={form.control}
+            name="type"
+            render={({ field }) => (
+              <FormItem>
+                <Select onValueChange={field.onChange} defaultValue={field.value} {...typeProps}>
+                  <FormControl>
+                    <SelectTrigger>
+                      <SelectValue placeholder="Runtime" />
+                    </SelectTrigger>
+                  </FormControl>
+                  <SelectContent>
+                    {ServiceTypes.map((t) => (
+                      <SelectItem key={t} value={t}>{t}</SelectItem>
+                    ))}
+                  </SelectContent>
+                </Select>
+                <FormMessage />
+              </FormItem>
+            )}
+          />
+        </form>
+      </Form>
+      Ports
+      <ul>
+        {data && data.ports && data.ports.map((p) => (<li key={p.id}>{p.name} - {p.value}</li>))}
+      </ul>
+      <Form {...portForm}>
+        <form className="flex flex-row space-x-1" onSubmit={portForm.handleSubmit(onSubmit)}>
+          <FormField
+            control={portForm.control}
+            name="name"
+            render={({ field }) => (
+              <FormItem>
+                <FormControl>
+                  <Input placeholder="name" className="border border-black" {...field} />
+                </FormControl>
+                <FormMessage />
+              </FormItem>
+            )}
+          />
+          <FormField
+            control={portForm.control}
+            name="value"
+            render={({ field }) => (
+              <FormItem>
+                <FormControl>
+                  <Input placeholder="value" className="border border-black" {...field} />
+                </FormControl>
+                <FormMessage />
+              </FormItem>
+            )}
+          />
+          <Button type="submit">Add Port</Button>
+        </form>
+      </Form>
+      Env Vars
+      <ul>
+        {data && data.envVars && data.envVars.map((v) => {
+          if ("name" in v) {
+            const value = "alias" in v ? v.alias : v.name;
+            if (v.isEditting) {
+              return (<li key={v.id}><Input type="text" className="border border-black" defaultValue={value} onKeyUp={saveAliasOnEnter(v)} onBlur={saveAliasOnBlur(v)} autoFocus={true} /></li>);
+            }
+            return (
+              <li key={v.id} onClick={editAlias(v)}>
+                <TooltipProvider>
+                  <Tooltip>
+                    <TooltipTrigger>
+                      <Button size={"icon"} variant={"ghost"}><EditIcon /></Button>
+                      {value}
+                    </TooltipTrigger>
+                    <TooltipContent>
+                      {v.name}
+                    </TooltipContent>
+                  </Tooltip>
+                </TooltipProvider>
+              </li>
+            );
+          }
+        })}
+      </ul>
+    </>);
+}
\ No newline at end of file