blob: fcfe2a1aba2c6571f781339f4f3d5567a024dfc6 [file] [log] [blame]
gio5f2f1002025-03-20 18:38:48 +04001import { v4 as uuidv4 } from "uuid";
2import { NodeRect } from './node-rect';
3import { useStateStore, ServiceNode, ServiceTypes, nodeLabel, BoundEnvVar, AppState, nodeIsConnectable } from '@/lib/state';
4import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState } from 'react';
5import { z } from "zod";
6import { DeepPartial, EventType, useForm } from 'react-hook-form';
7import { zodResolver } from '@hookform/resolvers/zod';
8import { Form, FormControl, FormField, FormItem, FormMessage } from './ui/form';
9import { Input } from './ui/input';
10import { Button } from './ui/button';
11import { Handle, Position } from "@xyflow/react";
12import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
giob41ecae2025-04-24 08:46:50 +000013import { PencilIcon, XIcon } from "lucide-react";
gio5f2f1002025-03-20 18:38:48 +040014import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
15
16export function NodeApp(node: ServiceNode) {
17 const { id, selected } = node;
18 const isConnectablePorts = useMemo(() => nodeIsConnectable(node, "ports"), [node]);
19 const isConnectableRepository = useMemo(() => nodeIsConnectable(node, "repository"), [node]);
20 return (
gio1dc800a2025-04-24 17:15:43 +000021 <NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
gio5f2f1002025-03-20 18:38:48 +040022 <div style={{ padding: '10px 20px' }}>
23 {nodeLabel(node)}
24 <Handle
25 id="repository"
26 type={"target"}
27 position={Position.Left}
28 isConnectableStart={isConnectableRepository}
29 isConnectableEnd={isConnectableRepository}
30 isConnectable={isConnectableRepository}
31 />
32 <Handle
33 id="ports"
34 type={"source"}
35 position={Position.Top}
36 isConnectableStart={isConnectablePorts}
37 isConnectableEnd={isConnectablePorts}
38 isConnectable={isConnectablePorts}
39 />
40 <Handle
41 id="env_var"
42 type={"target"}
43 position={Position.Bottom}
44 isConnectableStart={true}
45 isConnectableEnd={true}
46 isConnectable={true}
47 />
48 </div>
49 </NodeRect>
50 );
51}
52
53const schema = z.object({
54 name: z.string().min(1, "requried"),
55 type: z.enum(ServiceTypes),
56});
57
58const portSchema = z.object({
59 name: z.string().min(1, "required"),
60 value: z.coerce.number().gt(0, "can not be negative"),
61});
62
63export function NodeAppDetails({ id, data }: ServiceNode) {
64 const store = useStateStore();
65 const form = useForm<z.infer<typeof schema>>({
66 resolver: zodResolver(schema),
67 mode: "onChange",
68 defaultValues: {
69 name: data.label,
70 type: data.type,
71 }
72 });
73 const portForm = useForm<z.infer<typeof portSchema>>({
74 resolver: zodResolver(portSchema),
75 mode: "onSubmit",
76 defaultValues: {
77 name: "",
78 value: 0,
79 }
80 });
81 const onSubmit = useCallback((values: z.infer<typeof portSchema>) => {
giob41ecae2025-04-24 08:46:50 +000082 const portId = uuidv4();
gio5f2f1002025-03-20 18:38:48 +040083 store.updateNodeData<"app">(id, {
84 ports: (data.ports || []).concat({
giob41ecae2025-04-24 08:46:50 +000085 id: portId,
gio5f2f1002025-03-20 18:38:48 +040086 name: values.name,
87 value: values.value,
gio355883e2025-04-23 14:10:51 +000088 }),
89 envVars: (data.envVars || []).concat({
90 id: uuidv4(),
91 source: null,
giob41ecae2025-04-24 08:46:50 +000092 portId,
gio355883e2025-04-23 14:10:51 +000093 name: `DODO_PORT_${values.name.toUpperCase()}`,
94 }),
gio5f2f1002025-03-20 18:38:48 +040095 });
96 portForm.reset();
97 }, [data, portForm]);
98 useEffect(() => {
99 const sub = form.watch((value: DeepPartial<z.infer<typeof schema>>, { name, type }: { name?: keyof z.infer<typeof schema> | undefined, type?: EventType | undefined }) => {
100 console.log({ name, type });
101 if (type !== "change") {
102 return;
103 }
104 switch (name) {
105 case "name":
106 if (!value.name) {
107 break;
108 }
109 store.updateNodeData<"app">(id, {
110 label: value.name,
111 });
112 break;
113 case "type":
114 if (!value.type) {
115 break;
116 }
117 store.updateNodeData<"app">(id, {
118 type: value.type,
119 })
120 break;
121 }
122 });
123 return () => sub.unsubscribe();
124 }, [form, store]);
125 const focus = useCallback((field: any, name: string) => {
126 return (e: HTMLElement | null) => {
127 field.ref(e);
128 if (e != null && name === data.activeField) {
129 console.log(e);
130 e.focus();
131 store.updateNodeData(id, {
132 activeField: undefined,
133 });
134 }
135 }
136 }, [data, store]);
137 const [typeProps, setTypeProps] = useState({});
138 useEffect(() => {
139 if (data.activeField === "type") {
140 setTypeProps({
141 open: true,
142 onOpenChange: () => store.updateNodeData(id, { activeField: undefined }),
143 });
144 } else {
145 setTypeProps({});
146 }
147 }, [store, data, setTypeProps]);
148 const editAlias = useCallback((e: BoundEnvVar) => {
149 return () => {
150 store.updateNodeData(id, {
151 ...data,
152 envVars: data.envVars!.map((o) => {
153 if (o.id !== e.id) {
154 return o;
155 } else return {
156 ...o,
157 isEditting: true,
158 }
159 }),
160 });
161 };
162 }, [id, data, store]);
163 const saveAlias = (e: BoundEnvVar, value: string, store: AppState) => {
164 store.updateNodeData(id, {
165 ...data,
166 envVars: data.envVars!.map((o) => {
167 if (o.id !== e.id) {
168 return o;
169 }
170 if (value) {
171 return {
172 ...o,
173 isEditting: false,
174 alias: value.toUpperCase(),
175 }
176 }
177 console.log(o);
178 if ("alias" in o) {
179 const { alias: tmp, ...rest } = o;
180 console.log(rest);
181 return {
182 ...rest,
183 isEditting: false,
184 };
185 }
186 return {
187 ...o,
188 isEditting: false,
189 };
190 }),
191 });
192 };
193 const saveAliasOnEnter = useCallback((e: BoundEnvVar) => {
194 return (event: KeyboardEvent<HTMLInputElement>) => {
195 if (event.key === "Enter") {
196 event.preventDefault();
197 saveAlias(e, event.currentTarget.value, store);
198 }
199 }
200 }, [id, data, store]);
201 const saveAliasOnBlur = useCallback((e: BoundEnvVar) => {
202 return (event: FocusEvent<HTMLInputElement>) => {
203 saveAlias(e, event.currentTarget.value, store);
204 }
205 }, [id, data, store]);
giob41ecae2025-04-24 08:46:50 +0000206 const removePort = useCallback((portId: string) => {
207 store.setEdges(store.edges.filter((e) => {
208 if (e.source !== id || e.sourceHandle !== "ports") {
209 return true;
210 }
211 if (e.targetHandle === "https") {
212 return false;
213 }
214 if (e.targetHandle === "env_var") {
215 const tn = store.nodes.find((n) => n.type === "app" && n.id == e.target);
216 console.log("111", tn!.data.envVars);
217 if (tn && (tn.data.envVars || []).find((ev) => ev.source === id && "portId" in ev && ev.portId === portId)) {
218 return false;
219 }
220 }
221 return true;
222 }));
223 store.nodes.filter((n) => n.type === "gateway-https" && n.data.https && n.data.https.serviceId === id && n.data.https.portId === portId).forEach((n) => {
224 store.updateNodeData<"gateway-https">(n.id, {
225 https: undefined,
226 });
227 });
228 store.nodes.filter((n) => n.type === "app" && n.data.envVars).forEach((n) => {
229 store.updateNodeData<"app">(n.id, {
230 envVars: n.data.envVars.filter((ev) => {
231 if (ev.source === id && "portId" in ev && ev.portId === portId) {
232 return false;
233 }
234 return true;
235 })
236 });
237 })
238 store.updateNodeData<"app">(id, {
239 ports: (data.ports || []).filter((p) => p.id !== portId),
240 envVars: (data.envVars || []).filter((ev) => !(ev.source === null && "portId" in ev && ev.portId === portId)),
241 });
242 }, [id, data, store]);
gio5f2f1002025-03-20 18:38:48 +0400243 return (
244 <>
245 <Form {...form}>
246 <form>
247 <FormField
248 control={form.control}
249 name="name"
250 render={({ field }) => (
251 <FormItem>
252 <FormControl>
253 <Input placeholder="name" className="border border-black" {...field} ref={focus(field, "name")} />
254 </FormControl>
255 <FormMessage />
256 </FormItem>
257 )}
258 />
259 <FormField
260 control={form.control}
261 name="type"
262 render={({ field }) => (
263 <FormItem>
264 <Select onValueChange={field.onChange} defaultValue={field.value} {...typeProps}>
265 <FormControl>
266 <SelectTrigger>
267 <SelectValue placeholder="Runtime" />
268 </SelectTrigger>
269 </FormControl>
270 <SelectContent>
271 {ServiceTypes.map((t) => (
272 <SelectItem key={t} value={t}>{t}</SelectItem>
273 ))}
274 </SelectContent>
275 </Select>
276 <FormMessage />
277 </FormItem>
278 )}
279 />
280 </form>
281 </Form>
282 Ports
283 <ul>
giob41ecae2025-04-24 08:46:50 +0000284 {data && data.ports && data.ports.map((p) => (<li key={p.id}><Button size={"icon"} variant={"ghost"} onClick={() => removePort(p.id)}><XIcon /></Button> {p.name} - {p.value}</li>))}
gio5f2f1002025-03-20 18:38:48 +0400285 </ul>
286 <Form {...portForm}>
287 <form className="flex flex-row space-x-1" onSubmit={portForm.handleSubmit(onSubmit)}>
288 <FormField
289 control={portForm.control}
290 name="name"
291 render={({ field }) => (
292 <FormItem>
293 <FormControl>
294 <Input placeholder="name" className="border border-black" {...field} />
295 </FormControl>
296 <FormMessage />
297 </FormItem>
298 )}
299 />
300 <FormField
301 control={portForm.control}
302 name="value"
303 render={({ field }) => (
304 <FormItem>
305 <FormControl>
306 <Input placeholder="value" className="border border-black" {...field} />
307 </FormControl>
308 <FormMessage />
309 </FormItem>
310 )}
311 />
312 <Button type="submit">Add Port</Button>
313 </form>
314 </Form>
315 Env Vars
316 <ul>
317 {data && data.envVars && data.envVars.map((v) => {
318 if ("name" in v) {
319 const value = "alias" in v ? v.alias : v.name;
320 if (v.isEditting) {
321 return (<li key={v.id}><Input type="text" className="border border-black" defaultValue={value} onKeyUp={saveAliasOnEnter(v)} onBlur={saveAliasOnBlur(v)} autoFocus={true} /></li>);
322 }
323 return (
324 <li key={v.id} onClick={editAlias(v)}>
325 <TooltipProvider>
326 <Tooltip>
327 <TooltipTrigger>
giob41ecae2025-04-24 08:46:50 +0000328 <Button size={"icon"} variant={"ghost"}><PencilIcon /></Button>
gio5f2f1002025-03-20 18:38:48 +0400329 {value}
330 </TooltipTrigger>
331 <TooltipContent>
332 {v.name}
333 </TooltipContent>
334 </Tooltip>
335 </TooltipProvider>
336 </li>
337 );
338 }
339 })}
340 </ul>
341 </>);
342}