blob: fa089773a9d1b80ac57fa09e0bf015437da7ad68 [file] [log] [blame]
import { v4 as uuidv4 } from "uuid";
import { NodeRect } from "./node-rect";
import { useStateStore, nodeLabel, AppState, nodeIsConnectable, useEnv, useGithubRepositories } from "@/lib/state";
import { ServiceNode, ServiceTypes, GatewayHttpsNode, GatewayTCPNode, BoundEnvVar, AppNode, GithubNode } from "config";
import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState } from "react";
import { z } from "zod";
import { useForm, EventType, DeepPartial } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Form, FormControl, FormField, FormItem, FormMessage } from "./ui/form";
import { Button } from "./ui/button";
import { Handle, Position, useNodes } from "@xyflow/react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
import { Textarea } from "./ui/textarea";
import { Input } from "./ui/input";
import { Switch } from "./ui/switch";
import { Label } from "./ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
import { Code, Container, Network, Pencil, Variable } from "lucide-react";
import { Badge } from "./ui/badge";
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "./ui/accordion";
import { Name } from "./node-name";
import { NodeDetailsProps } from "@/lib/types";
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} node={node} 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 sourceSchema = z.object({
id: z.string().min(1, "required"),
branch: z.string(),
rootDir: z.string(),
});
const devSchema = z.object({
enabled: z.boolean(),
});
const exposeSchema = z.object({
network: z.string().min(1, "reqired"),
subdomain: z.string().min(1, "required"),
});
const agentSchema = z.object({
geminiApiKey: z.string().optional(),
});
export function NodeAppDetails({ node, disabled, showName = true, isOverview = false }: NodeDetailsProps<ServiceNode>) {
const { data } = node;
return (
<>
{showName ? <Name node={node} disabled={disabled} /> : null}
<Tabs defaultValue="runtime">
<TabsList className="w-full flex flex-row justify-between">
<TabsTrigger value="runtime">
{isOverview ? (
<div className="flex flex-row gap-1 items-center">
<Container /> Runtime
</div>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Container />
</TooltipTrigger>
<TooltipContent>Runtime</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</TabsTrigger>
<TabsTrigger value="ports">
{isOverview ? (
<div className="flex flex-row gap-1 items-center">
<Network /> Ports
<Badge className="rounded-full">{data.ports?.length ?? 0}</Badge>
</div>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="flex flex-row gap-1 items-center">
<Network />
</TooltipTrigger>
<TooltipContent>
Ports{" "}
<Badge variant="secondary" className="rounded-full">
{data.ports?.length ?? 0}
</Badge>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</TabsTrigger>
<TabsTrigger value="vars">
{isOverview ? (
<div className="flex flex-row gap-1 items-center">
<Variable /> Variables
<Badge className="rounded-full">{data.envVars?.length ?? 0}</Badge>
</div>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="flex flex-row gap-1 items-center">
<Variable />
</TooltipTrigger>
<TooltipContent>
Variables{" "}
<Badge variant="secondary" className="rounded-full">
{data.envVars?.length ?? 0}
</Badge>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</TabsTrigger>
{node.data.type !== "sketch:latest" && (
<TabsTrigger value="dev">
{isOverview ? (
<div className="flex flex-row gap-1 items-center">
<Code /> Dev
</div>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="flex flex-row gap-1 items-center">
<Code />
</TooltipTrigger>
<TooltipContent>Dev</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</TabsTrigger>
)}
</TabsList>
<TabsContent value="runtime">
<Runtime node={node} disabled={disabled} />
</TabsContent>
<TabsContent value="ports">
<Ports node={node} disabled={disabled} />
</TabsContent>
<TabsContent value="vars">
<EnvVars node={node} disabled={disabled} />
</TabsContent>
{node.data.type !== "sketch:latest" && (
<TabsContent value="dev">
<Dev node={node} disabled={disabled} />
</TabsContent>
)}
</Tabs>
</>
);
}
function Runtime({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
const { id, data } = node;
const store = useStateStore();
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
mode: "onChange",
defaultValues: {
name: data.label,
type: data.type,
},
});
useEffect(() => {
const sub = form.watch(
(
value: DeepPartial<z.infer<typeof schema>>,
{ name, type }: { name?: keyof z.infer<typeof schema> | undefined; type?: EventType | undefined },
) => {
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 [typeProps, setTypeProps] = useState({});
useEffect(() => {
if (data.activeField === "type") {
setTypeProps({
open: true,
onOpenChange: () => store.updateNodeData(id, { activeField: undefined }),
});
} else {
setTypeProps({});
}
}, [id, data, store, setTypeProps]);
const setPreBuildCommands = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
store.updateNodeData<"app">(id, {
preBuildCommands: e.currentTarget.value,
});
},
[id, store],
);
const agentForm = useForm<z.infer<typeof agentSchema>>({
resolver: zodResolver(agentSchema),
mode: "onChange",
defaultValues: {
geminiApiKey: data.agent?.geminiApiKey,
},
});
useEffect(() => {
const sub = agentForm.watch((value) => {
store.updateNodeData<"app">(id, {
agent: {
geminiApiKey: value.geminiApiKey,
},
});
});
return () => sub.unsubscribe();
}, [id, agentForm, store]);
return (
<>
<SourceRepo node={node} disabled={disabled} />
{node.data.type !== "sketch:latest" && (
<Form {...form}>
<form className="space-y-2">
<Label>Container Image</Label>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<Select
onValueChange={field.onChange}
value={field.value || ""}
{...typeProps}
disabled={disabled}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{ServiceTypes.filter((t) => t !== "sketch:latest").map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)}
{node.data.type === "sketch:latest" && (
<Form {...agentForm}>
<form className="space-y-2">
<Label>Gemini API Key</Label>
<FormField
control={agentForm.control}
name="geminiApiKey"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
type="password"
placeholder="Override Gemini API key"
{...field}
value={field.value || ""}
disabled={disabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)}
{node.data.type !== "sketch:latest" && (
<>
<Label>Pre-Build Commands</Label>
<Textarea
placeholder="new line separated list of commands to run before running the service"
value={data.preBuildCommands}
onChange={setPreBuildCommands}
disabled={disabled}
/>
</>
)}
</>
);
}
function Ports({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
const { id, data } = node;
const store = useStateStore();
const [name, setName] = useState("");
const [value, setValue] = useState("");
const onSubmit = useCallback(() => {
const portId = uuidv4();
store.updateNodeData<"app">(id, {
ports: (data.ports || []).concat({
id: portId,
name: name.toUpperCase(),
value: Number(value),
}),
envVars: (data.envVars || []).concat({
id: uuidv4(),
source: null,
portId,
name: `DODO_PORT_${name.toUpperCase()}`,
}),
});
setName("");
setValue("");
}, [id, data, store, name, value, setName, setValue]);
const removePort = useCallback(
(portId: string) => {
// TODO(gio): this is ugly
const tcpRemoved = new Set<string>();
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],
);
return (
<div className="flex flex-col gap-1">
<div className="grid grid-cols-[1fr_1fr_auto] gap-1">
{data &&
data.ports &&
data.ports.map((p) => (
<>
<div className="flex items-center px-3">{p.name.toUpperCase()}</div>
<div className="flex items-center px-3">{p.value}</div>
<div className="flex items-center">
<Button
variant="destructive"
className="w-full"
onClick={() => removePort(p.id)}
disabled={disabled}
>
Remove
</Button>
</div>
</>
))}
<div>
<Input
placeholder="name"
className="uppercase w-0 min-w-full"
disabled={disabled}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div>
<Input
placeholder="0"
className="w-0 min-w-full"
disabled={disabled}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</div>
<div>
<Button type="submit" className="w-full" disabled={disabled} onClick={onSubmit}>
Add
</Button>
</div>
</div>
</div>
);
}
function EnvVars({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
const { id, data } = node;
const store = useStateStore();
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(),
};
}
if ("alias" in o) {
const { alias: _, ...rest } = o;
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],
);
return (
<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="uppercase"
defaultValue={value}
onKeyUp={saveAliasOnEnter(v)}
onBlur={saveAliasOnBlur(v)}
autoFocus={true}
disabled={disabled}
/>
</li>
);
}
return (
<li key={v.id} onClick={editAlias(v)}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="w-full">
<div className="w-full flex flex-row items-center gap-1 cursor-text">
<Pencil className="w-4 h-4" />
<div className="uppercase">{value}</div>
</div>
</TooltipTrigger>
<TooltipContent>{v.name}</TooltipContent>
</Tooltip>
</TooltipProvider>
</li>
);
}
})}
</ul>
);
}
function Dev({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
const { id, data } = node;
const env = useEnv();
const store = useStateStore();
const devForm = useForm<z.infer<typeof devSchema>>({
resolver: zodResolver(devSchema),
mode: "onChange",
defaultValues: {
enabled: data.dev ? data.dev.enabled : false,
},
});
useEffect(() => {
const sub = devForm.watch((value, { name }) => {
if (name === "enabled") {
if (value.enabled) {
const csGateway: Omit<GatewayHttpsNode, "position"> = {
id: uuidv4(),
type: "gateway-https",
data: {
readonly: true,
https: {
serviceId: id,
portId: `${id}-code-server`,
},
network: data.dev?.expose?.network,
subdomain: data.dev?.expose?.subdomain,
label: "",
envVars: [],
ports: [],
},
};
const sshGateway: Omit<GatewayTCPNode, "position"> = {
id: uuidv4(),
type: "gateway-tcp",
data: {
readonly: true,
exposed: [
{
serviceId: id,
portId: `${id}-ssh`,
},
],
network: data.dev?.expose?.network,
subdomain: data.dev?.expose?.subdomain,
label: "",
envVars: [],
ports: [],
},
};
store.addNode(csGateway);
store.addNode(sshGateway);
store.updateNodeData<"app">(id, {
dev: {
enabled: true,
expose: data.dev?.expose,
codeServerNodeId: csGateway.id,
sshNodeId: sshGateway.id,
},
ports: (data.ports || []).concat(
{
id: `${id}-code-server`,
name: "code-server",
value: 9090,
},
{
id: `${id}-ssh`,
name: "ssh",
value: 22,
},
),
});
let edges = store.edges.concat([
{
id: uuidv4(),
source: id,
sourceHandle: "ports",
target: csGateway.id,
targetHandle: "https",
},
{
id: uuidv4(),
source: id,
sourceHandle: "ports",
target: sshGateway.id,
targetHandle: "tcp",
},
]);
if (data.dev?.expose?.network !== undefined) {
edges = edges.concat([
{
id: uuidv4(),
source: csGateway.id,
sourceHandle: "subdomain",
target: data.dev.expose.network,
targetHandle: "subdomain",
},
{
id: uuidv4(),
source: sshGateway.id,
sourceHandle: "subdomain",
target: data.dev.expose.network,
targetHandle: "subdomain",
},
]);
}
store.setEdges(edges);
} else {
const { dev } = data;
if (dev?.enabled) {
store.setNodes(
store.nodes.filter((n) => n.id !== dev.codeServerNodeId && n.id !== dev.sshNodeId),
);
store.setEdges(
store.edges.filter((e) => e.target !== dev.codeServerNodeId && e.target !== dev.sshNodeId),
);
}
store.updateNodeData<"app">(id, {
dev: {
enabled: false,
expose: dev?.expose,
},
ports: (data.ports || []).filter((p) => p.name !== "code-server" && p.name !== "ssh"),
});
}
}
});
return () => sub.unsubscribe();
}, [id, data, devForm, store]);
const exposeForm = useForm<z.infer<typeof exposeSchema>>({
resolver: zodResolver(exposeSchema),
mode: "onChange",
defaultValues: {
network: data.dev?.expose?.network,
subdomain: data.dev?.expose?.subdomain,
},
});
useEffect(() => {
const sub = exposeForm.watch(
(
value: DeepPartial<z.infer<typeof exposeSchema>>,
{ name }: { name?: keyof z.infer<typeof exposeSchema> | undefined; type?: EventType | undefined },
) => {
const { dev } = data;
if (!dev?.enabled) {
return;
}
if (name === "network") {
let edges = store.edges;
if (dev.enabled && dev.expose?.network !== undefined) {
edges = edges.filter((e) => {
if (
(e.source === dev.codeServerNodeId || e.source === dev.sshNodeId) &&
e.sourceHandle === "subdomain" &&
e.target === dev.expose?.network &&
e.targetHandle === "subdomain"
) {
return false;
} else {
return true;
}
});
}
if (value.network !== undefined) {
edges = edges.concat(
{
id: uuidv4(),
source: dev.codeServerNodeId,
sourceHandle: "subdomain",
target: value.network,
targetHandle: "subdomain",
},
{
id: uuidv4(),
source: dev.sshNodeId,
sourceHandle: "subdomain",
target: value.network,
targetHandle: "subdomain",
},
);
}
store.setEdges(edges);
store.updateNodeData<"app">(id, {
dev: {
...dev,
expose: {
network: value.network,
subdomain: dev.expose?.subdomain,
},
},
});
store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { network: value.network });
store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { network: value.network });
} else if (name === "subdomain") {
store.updateNodeData<"app">(id, {
dev: {
...dev,
expose: {
network: dev.expose?.network,
subdomain: value.subdomain,
},
},
});
store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { subdomain: value.subdomain });
store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { subdomain: value.subdomain });
}
},
);
return () => sub.unsubscribe();
}, [id, data, exposeForm, store]);
return (
<>
<Form {...devForm}>
<form className="space-y-2">
<FormField
control={devForm.control}
name="enabled"
render={({ field }) => (
<FormItem>
<div className="flex flex-row gap-1 items-center">
<Switch
id="devEnabled"
onCheckedChange={field.onChange}
checked={field.value}
disabled={disabled}
/>
<Label htmlFor="devEnabled">Dev VM</Label>
</div>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
{data.dev && data.dev.enabled && (
<Form {...exposeForm}>
<form className="space-y-2">
<Label>Network</Label>
<FormField
control={exposeForm.control}
name="network"
render={({ field }) => (
<FormItem>
<Select
onValueChange={field.onChange}
value={field.value || ""}
disabled={disabled}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{env.networks.map((n) => (
<SelectItem
key={n.name}
value={n.domain}
>{`${n.name} - ${n.domain}`}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Label>Subdomain</Label>
<FormField
control={exposeForm.control}
name="subdomain"
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} disabled={disabled} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)}
</>
);
}
function SourceRepo({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
const { id, data } = node;
const store = useStateStore();
const nodes = useNodes<AppNode>();
const repo = useMemo(() => {
return nodes
.filter((n): n is GithubNode => n.type === "github")
.find((n) => n.id === data.repository?.repoNodeId);
}, [nodes, data.repository?.repoNodeId]);
const repos = useGithubRepositories();
const sourceForm = useForm<z.infer<typeof sourceSchema>>({
resolver: zodResolver(sourceSchema),
mode: "onChange",
defaultValues: {
id: data?.repository?.id?.toString(),
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 },
) => {
if (name === "id") {
const newRepoId = value.id ? parseInt(value.id, 10) : undefined;
if (!newRepoId) return;
const oldGithubNodeId = data.repository?.repoNodeId;
const selectedRepo = repos.find((r) => r.id === newRepoId);
if (!selectedRepo) return;
// If a node for the selected repo already exists, connect to it.
const existingNodeForSelectedRepo = nodes
.filter((n): n is GithubNode => n.type === "github")
.find((n) => n.data.repository?.id === selectedRepo.id);
if (existingNodeForSelectedRepo) {
let { nodes, edges } = store;
if (oldGithubNodeId) {
edges = edges.filter(
(e) =>
!(
e.target === id &&
e.source === oldGithubNodeId &&
e.targetHandle === "repository"
),
);
}
edges = edges.concat({
id: uuidv4(),
source: existingNodeForSelectedRepo.id,
sourceHandle: "repository",
target: id,
targetHandle: "repository",
});
nodes = nodes.map((n) => {
if (n.id !== id) {
return n;
} else {
const sn = n as ServiceNode;
return {
...sn,
data: {
...sn.data,
repository: {
...sn.data.repository,
id: newRepoId,
repoNodeId: existingNodeForSelectedRepo.id,
},
},
};
}
});
if (oldGithubNodeId && oldGithubNodeId !== existingNodeForSelectedRepo.id) {
const isOldNodeStillUsed = edges.some(
(e) => e.source === oldGithubNodeId && e.sourceHandle === "repository",
);
if (!isOldNodeStillUsed) {
nodes = nodes.filter((n) => n.id !== oldGithubNodeId);
}
}
store.setNodes(nodes);
store.setEdges(edges);
return;
}
// No node for selected repo, decide whether to update old node or create a new one.
if (oldGithubNodeId) {
const isOldNodeShared =
store.edges.filter(
(e) =>
e.source === oldGithubNodeId && e.target !== id && e.sourceHandle === "repository",
).length > 0;
if (!isOldNodeShared) {
// Update old node
store.updateNodeData<"github">(oldGithubNodeId, {
repository: {
id: selectedRepo.id,
sshURL: selectedRepo.ssh_url,
fullName: selectedRepo.full_name,
},
label: selectedRepo.full_name,
});
store.updateNodeData<"app">(id, {
repository: {
...data.repository,
id: newRepoId,
},
});
} else {
// Create new node because old one is shared
const newGithubNodeId = uuidv4();
store.addNode({
id: newGithubNodeId,
type: "github",
data: {
repository: {
id: selectedRepo.id,
sshURL: selectedRepo.ssh_url,
fullName: selectedRepo.full_name,
},
label: selectedRepo.full_name,
envVars: [],
ports: [],
},
});
let edges = store.edges;
// remove old edge
edges = edges.filter(
(e) =>
!(
e.target === id &&
e.source === oldGithubNodeId &&
e.targetHandle === "repository"
),
);
// add new edge
edges = edges.concat({
id: uuidv4(),
source: newGithubNodeId,
sourceHandle: "repository",
target: id,
targetHandle: "repository",
});
store.setEdges(edges);
store.updateNodeData<"app">(id, {
repository: {
...data.repository,
id: newRepoId,
repoNodeId: newGithubNodeId,
},
});
}
} else {
// No old github node, so create a new one
const newGithubNodeId = uuidv4();
store.addNode({
id: newGithubNodeId,
type: "github",
data: {
repository: {
id: selectedRepo.id,
sshURL: selectedRepo.ssh_url,
fullName: selectedRepo.full_name,
},
label: selectedRepo.full_name,
envVars: [],
ports: [],
},
});
store.setEdges(
store.edges.concat({
id: uuidv4(),
source: newGithubNodeId,
sourceHandle: "repository",
target: id,
targetHandle: "repository",
}),
);
store.updateNodeData<"app">(id, {
repository: {
...data.repository,
id: newRepoId,
repoNodeId: newGithubNodeId,
},
});
}
} 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, nodes, repos]);
const [isExpanded, setIsExpanded] = useState(false);
// useEffect(() => {
// if (data.repository === undefined) {
// setIsExpanded(true);
// }
// }, [data.repository, setIsExpanded]);
console.log(data.repository, isExpanded, repo);
return (
<Accordion type="single" collapsible>
<AccordionItem value="repository" className="border-none">
<AccordionTrigger onClick={() => setIsExpanded(!isExpanded)}>
Source {!isExpanded && repo !== undefined && repo.data.repository?.fullName}
</AccordionTrigger>
<AccordionContent className="px-1">
<Form {...sourceForm}>
<form className="space-y-2">
<Label>Repository</Label>
<FormField
control={sourceForm.control}
name="id"
render={({ field }) => (
<FormItem>
<Select onValueChange={field.onChange} value={field.value} disabled={disabled}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{repos.map((r) => (
<SelectItem
key={r.id}
value={r.id.toString()}
>{`${r.full_name}`}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Label>Branch</Label>
<FormField
control={sourceForm.control}
name="branch"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder="master"
className="lowercase"
{...field}
disabled={disabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Label>Root Directory</Label>
<FormField
control={sourceForm.control}
name="rootDir"
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder="/" {...field} disabled={disabled} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</AccordionContent>
</AccordionItem>
</Accordion>
);
}