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