blob: d2aa36e25cdf6a56b74ec696a432e60f593845cf [file] [log] [blame]
import { v4 as uuidv4 } from "uuid";
import { NodeRect } from "./node-rect";
import {
useStateStore,
ServiceNode,
ServiceTypes,
nodeLabel,
BoundEnvVar,
AppState,
nodeIsConnectable,
GatewayTCPNode,
GatewayHttpsNode,
AppNode,
GithubNode,
} from "@/lib/state";
import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState } from "react";
import { z } from "zod";
import { DeepPartial, EventType, useForm, ControllerRenderProps, FieldPath } 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, useNodes } from "@xyflow/react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { PencilIcon, XIcon } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
import { Textarea } from "./ui/textarea";
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} state={node.data.state}>
<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, "must be positive").lte(65535, "must be less than 65535"),
});
const sourceSchema = z.object({
id: z.string().min(1, "required"),
branch: z.string(),
rootDir: z.string(),
});
export function NodeAppDetails({ id, data }: ServiceNode) {
const store = useStateStore();
const nodes = useNodes<AppNode>();
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>) => {
const portId = uuidv4();
store.updateNodeData<"app">(id, {
ports: (data.ports || []).concat({
id: portId,
name: values.name.toLowerCase(),
value: values.value,
}),
envVars: (data.envVars || []).concat({
id: uuidv4(),
source: null,
portId,
name: `DODO_PORT_${values.name.toUpperCase()}`,
}),
});
portForm.reset();
},
[id, data, portForm, store],
);
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();
}, [id, form, store]);
const focus = useCallback(
(field: ControllerRenderProps<z.infer<typeof schema>, FieldPath<z.infer<typeof schema>>>, 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,
});
}
};
},
[id, data, store],
);
const [typeProps, setTypeProps] = useState({});
useEffect(() => {
if (data.activeField === "type") {
setTypeProps({
open: true,
onOpenChange: () => store.updateNodeData(id, { activeField: undefined }),
});
} else {
setTypeProps({});
}
}, [id, data, store, 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 = useCallback(
(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: _, ...rest } = o;
console.log(rest);
return {
...rest,
isEditting: false,
};
}
return {
...o,
isEditting: false,
};
}),
});
},
[id, data],
);
const saveAliasOnEnter = useCallback(
(e: BoundEnvVar) => {
return (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
saveAlias(e, event.currentTarget.value, store);
}
};
},
[store, saveAlias],
);
const saveAliasOnBlur = useCallback(
(e: BoundEnvVar) => {
return (event: FocusEvent<HTMLInputElement>) => {
saveAlias(e, event.currentTarget.value, store);
};
},
[store, saveAlias],
);
const removePort = useCallback(
(portId: string) => {
// TODO(gio): this is ugly
const tcpRemoved = new Set<string>();
console.log(store.edges);
store.setEdges(
store.edges.filter((e) => {
if (e.source !== id || e.sourceHandle !== "ports") {
return true;
}
const tn = store.nodes.find((n) => n.id == e.target)!;
if (e.targetHandle === "https") {
const t = tn as GatewayHttpsNode;
if (t.data.https?.serviceId === id && t.data.https.portId === portId) {
return false;
}
}
if (e.targetHandle === "tcp") {
const t = tn as GatewayTCPNode;
if (tcpRemoved.has(t.id)) {
return true;
}
if (t.data.exposed.find((e) => e.serviceId === id && e.portId === portId)) {
tcpRemoved.add(t.id);
return false;
}
}
if (e.targetHandle === "env_var") {
if (
tn &&
(tn.data.envVars || []).find(
(ev) => ev.source === id && "portId" in ev && ev.portId === portId,
)
) {
return false;
}
}
return true;
}),
);
store.nodes
.filter(
(n) =>
n.type === "gateway-https" &&
n.data.https &&
n.data.https.serviceId === id &&
n.data.https.portId === portId,
)
.forEach((n) => {
store.updateNodeData<"gateway-https">(n.id, {
https: undefined,
});
});
store.nodes
.filter((n) => n.type === "gateway-tcp")
.forEach((n) => {
const filtered = n.data.exposed.filter((e) => {
if (e.serviceId === id && e.portId === portId) {
return false;
} else {
return true;
}
});
if (filtered.length != n.data.exposed.length) {
store.updateNodeData<"gateway-tcp">(n.id, {
exposed: filtered,
});
}
});
store.nodes
.filter((n) => n.type === "app" && n.data.envVars)
.forEach((n) => {
store.updateNodeData<"app">(n.id, {
envVars: n.data.envVars.filter((ev) => {
if (ev.source === id && "portId" in ev && ev.portId === portId) {
return false;
}
return true;
}),
});
});
store.updateNodeData<"app">(id, {
ports: (data.ports || []).filter((p) => p.id !== portId),
envVars: (data.envVars || []).filter(
(ev) => !(ev.source === null && "portId" in ev && ev.portId === portId),
),
});
},
[id, data, store],
);
const setPreBuildCommands = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
store.updateNodeData<"app">(id, {
preBuildCommands: e.currentTarget.value,
});
},
[id, store],
);
const sourceForm = useForm<z.infer<typeof sourceSchema>>({
resolver: zodResolver(sourceSchema),
mode: "onChange",
defaultValues: {
id: data?.repository?.id,
branch: data.repository && "branch" in data.repository ? data.repository.branch : undefined,
rootDir: data.repository && "rootDir" in data.repository ? data.repository.rootDir : undefined,
},
});
useEffect(() => {
const sub = sourceForm.watch(
(
value: DeepPartial<z.infer<typeof sourceSchema>>,
{ name }: { name?: keyof z.infer<typeof sourceSchema> | undefined; type?: EventType | undefined },
) => {
console.log(value);
if (name === "id") {
let edges = store.edges;
if (data?.repository?.id !== undefined) {
edges = edges.filter((e) => {
if (e.target === id && e.targetHandle === "repository" && e.source === data.repository.id) {
return false;
} else {
return true;
}
});
}
if (value.id !== undefined) {
edges = edges.concat({
id: uuidv4(),
source: value.id,
sourceHandle: "repository",
target: id,
targetHandle: "repository",
});
}
store.setEdges(edges);
store.updateNodeData<"app">(id, {
repository: {
id: value.id,
},
});
} else if (name === "branch") {
store.updateNodeData<"app">(id, {
repository: {
...data?.repository,
branch: value.branch,
},
});
} else if (name === "rootDir") {
store.updateNodeData<"app">(id, {
repository: {
...data?.repository,
rootDir: value.rootDir,
},
});
}
},
);
return () => sub.unsubscribe();
}, [id, data, sourceForm, 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>
Source
<Form {...sourceForm}>
<form className="space-y-2">
<FormField
control={sourceForm.control}
name="id"
render={({ field }) => (
<FormItem>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Repository" />
</SelectTrigger>
</FormControl>
<SelectContent>
{(
nodes.filter(
(n) => n.type === "github" && n.data.repository?.id !== undefined,
) as GithubNode[]
).map((n) => (
<SelectItem
key={n.id}
value={n.id}
>{`${n.data.repository?.fullName}`}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={sourceForm.control}
name="branch"
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder="master" className="border border-black" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={sourceForm.control}
name="rootDir"
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder="/" className="border border-black" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
Ports
<ul>
{data &&
data.ports &&
data.ports.map((p) => (
<li key={p.id} className="flex flex-row items-center gap-1">
<Button size={"icon"} variant={"ghost"} onClick={() => removePort(p.id)}>
<XIcon />
</Button>
<div>
{p.name} - {p.value}
</div>
</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>
<div className="flex flex-row items-center gap-1">
<Button size={"icon"} variant={"ghost"}>
<PencilIcon />
</Button>
<div>{value}</div>
</div>
</TooltipTrigger>
<TooltipContent>{v.name}</TooltipContent>
</Tooltip>
</TooltipProvider>
</li>
);
}
})}
</ul>
Pre-Build Commands
<Textarea
placeholder="new line separated list of commands to run before running the service"
value={data.preBuildCommands}
onChange={setPreBuildCommands}
/>
</>
);
}