Canvas: Reuse node details component in overview
Make app details tabular.
Change-Id: I78a641e8e513eec44573bb8c8a391ef81a66e7fe
diff --git a/apps/canvas/front/src/Canvas.tsx b/apps/canvas/front/src/Canvas.tsx
index ae7e137..f8a4b4d 100644
--- a/apps/canvas/front/src/Canvas.tsx
+++ b/apps/canvas/front/src/Canvas.tsx
@@ -33,7 +33,7 @@
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
- <ResizablePanel defaultSize={20} className="!overflow-y-auto">
+ <ResizablePanel defaultSize={20} className="!overflow-y-auto !overflow-x-hidden">
<Details />
</ResizablePanel>
</ResizablePanelGroup>
diff --git a/apps/canvas/front/src/Overview.tsx b/apps/canvas/front/src/Overview.tsx
index a2b35df..4e286ce 100644
--- a/apps/canvas/front/src/Overview.tsx
+++ b/apps/canvas/front/src/Overview.tsx
@@ -1,267 +1,30 @@
-import React, { useCallback, useMemo, useState } from "react";
-import {
- useStateStore,
- GithubNode,
- ServiceNode,
- GatewayHttpsNode,
- nodeLabel,
- Port,
- nodeEnvVarNames,
- AppNode,
-} from "@/lib/state";
-import { Button } from "./components/ui/button";
-import { Icon } from "./components/icon";
-import { PlusIcon } from "lucide-react";
-import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from "./components/ui/dialog";
-import { Input } from "./components/ui/input";
-import { Label } from "./components/ui/label";
-import { useToast } from "./hooks/use-toast";
-import { v4 as uuidv4 } from "uuid";
+import React, { useMemo } from "react";
+import { useStateStore, ServiceNode } from "@/lib/state";
+import { NodeDetails } from "./components/node-details";
+import { Actions } from "./components/actions";
+import { Canvas } from "./components/canvas";
export function Overview(): React.ReactNode {
- const nodes = useStateStore((state) => state.nodes);
- const edges = useStateStore((state) => state.edges);
- const githubNodes = useMemo(() => nodes.filter((node): node is GithubNode => node.type === "github"), [nodes]);
- const getServicesForRepo = useCallback(
- (repoId: string): ServiceNode[] => {
- return nodes.filter((node): node is ServiceNode => {
- if (node.type !== "app") return false;
- return edges.some(
- (edge) =>
- edge.source === repoId &&
- edge.target === node.id &&
- edge.sourceHandle === "repository" &&
- edge.targetHandle === "repository",
- );
- });
- },
- [nodes, edges],
- );
+ const store = useStateStore();
+ const nodes = useMemo(() => store.nodes, [store.nodes]);
+ const isDeployMode = useMemo(() => store.mode === "deploy", [store.mode]);
return (
- <div className="h-full overflow-auto bg-muted p-4 flex flex-col gap-4">
- {githubNodes.map((repoNode) => {
- const services = getServicesForRepo(repoNode.id);
- return (
- <div key={repoNode.id}>
- <h2 className="text-xl font-medium mb-3 flex flex-row items-center gap-2">
- <Icon type="github" /> {nodeLabel(repoNode)}
- </h2>
- {services.length > 0 ? (
- <ul className="space-y-4">
- {services.map((serviceNode) => (
- <li key={serviceNode.id} className="pl-4 border-l-2 border-gray-200">
- <Service service={serviceNode} />
- </li>
- ))}
- </ul>
- ) : (
- <p className="text-sm text-gray-500 pl-4">No services imported from this repository.</p>
- )}
- </div>
- );
- })}
- {nodes
- .filter((n) => n.type === "volume")
- .map((n) => {
- return (
- <div key={n.id}>
- <h2 className="text-xl font-medium mb-3 flex flex-row items-center gap-2">
- <Icon type="volume" /> {nodeLabel(n)}
- </h2>
- <div className="pl-4 border-l-2 border-gray-200">
- <Exports n={n} />
- </div>
- </div>
- );
- })}
- {nodes
- .filter((n) => n.type === "postgresql")
- .map((n) => {
- return (
- <div key={n.id}>
- <h2 className="text-xl font-medium mb-3 flex flex-row items-center gap-2">
- <Icon type="postgresql" /> {nodeLabel(n)}
- </h2>
- <div className="pl-4 border-l-2 border-gray-200">
- <Exports n={n} />
- </div>
- </div>
- );
- })}
- {nodes
- .filter((n) => n.type === "mongodb")
- .map((n) => {
- return (
- <div key={n.id}>
- <h2 className="text-xl font-medium mb-3 flex flex-row items-center gap-2">
- <Icon type="mongodb" /> {nodeLabel(n)}
- </h2>
- <div className="pl-4 border-l-2 border-gray-200">
- <Exports n={n} />
- </div>
- </div>
- );
- })}
- </div>
- );
-}
-
-function Service({ service: serviceNode }: { service: ServiceNode }): React.ReactNode {
- const { toast } = useToast();
- const nodes = useStateStore((state) => state.nodes);
- const updateNodeData = useStateStore((state) => state.updateNodeData);
- const [isAddPortModalOpen, setIsAddPortModalOpen] = useState(false);
- const [newPortName, setNewPortName] = useState("");
- const [newPortValue, setNewPortValue] = useState("");
-
- const httpsGateways = useMemo(
- () => nodes.filter((node): node is GatewayHttpsNode => node.type === "gateway-https"),
- [nodes],
- );
- const getGatewayForServicePort = useCallback(
- (serviceId: string, port: Port): GatewayHttpsNode[] => {
- return httpsGateways.filter(
- (g) => g.data.https?.serviceId === serviceId && g.data.https?.portId === port.id,
- );
- },
- [httpsGateways],
- );
- const getGatewayUrl = (g: GatewayHttpsNode): string => {
- if (g.data.subdomain && g.data.network) {
- return `https://${g.data.subdomain}.${g.data.network}`;
- }
- return "Gateway not fully configured";
- };
-
- const handleAddPort = () => {
- if (!newPortName || !newPortValue) {
- toast({
- title: "Port name and value are required.",
- variant: "destructive",
- });
- return;
- }
- const portValueNumber = parseInt(newPortValue, 10);
- if (isNaN(portValueNumber) || portValueNumber <= 0 || portValueNumber > 65535) {
- toast({
- title: "Invalid port number.",
- variant: "destructive",
- });
- return;
- }
- const newPort: Port = {
- id: uuidv4(),
- name: newPortName,
- value: portValueNumber,
- };
- updateNodeData<"app">(serviceNode.id, {
- ports: [...(serviceNode.data.ports || []), newPort],
- } as Partial<ServiceNode["data"]>);
- setNewPortName("");
- setNewPortValue("");
- setIsAddPortModalOpen(false);
- };
-
- return (
- <>
- <h3 className="text-lg font-medium text-gray-700 flex flex-row items-center gap-2">
- <Icon type="app" /> {nodeLabel(serviceNode)}
- </h3>
- <div className="text-sm text-gray-500 pl-4 flex flex-row items-center gap-2">
- <div>Branch: {serviceNode.data.repository?.branch ?? "master"}</div>
- <div>Location: {serviceNode.data.repository?.rootDir ?? "/"}</div>
+ <div className="h-full w-full overflow-auto bg-white p-2">
+ <div className="w-full flex flex-row justify-end">
+ <Actions />
+ <Canvas className="hidden" />
</div>
- <div className="pl-4">
- <h4 className="text-sm font-medium text-gray-500 flex flex-row items-center gap-2">
- Ports
- <Button variant="ghost" size="icon" onClick={() => setIsAddPortModalOpen(true)}>
- <PlusIcon />
- </Button>
- </h4>
- <ul className="pl-2">
- {(serviceNode.data.ports || []).map((port) => {
- const gateways = getGatewayForServicePort(serviceNode.id, port);
+ <div className="flex flex-wrap gap-4 pt-2">
+ {nodes
+ .filter((n): n is ServiceNode => n.type === "app")
+ .map((n) => {
return (
- <li key={port.id} className="text-sm text-gray-600">
- <span className="font-medium">{port.name.toUpperCase()}:</span> {port.value}
- {gateways.map((g) => (
- <Button variant="link" asChild key={g.id} className="!h-fit !py-0">
- <a href={getGatewayUrl(g)} target="_blank" rel="noopener noreferrer">
- {getGatewayUrl(g)}
- </a>
- </Button>
- ))}
- </li>
+ <div key={n.id} className="h-fit w-fit rounded-lg border-gray-200 border-2 p-2">
+ <NodeDetails disabled={isDeployMode} {...n} />
+ </div>
);
})}
- </ul>
</div>
- <div className="pl-4">
- <Exports n={serviceNode} />
- </div>
- <Dialog open={isAddPortModalOpen} onOpenChange={setIsAddPortModalOpen}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Add New Port to {nodeLabel(serviceNode)}</DialogTitle>
- </DialogHeader>
- <div>
- <div>
- <Label htmlFor="portName">Name</Label>
- <Input
- id="portName"
- value={newPortName}
- onChange={(e) => setNewPortName(e.target.value)}
- placeholder="e.g., HTTP, Admin"
- />
- </div>
- <div>
- <Label htmlFor="portValue">Port Number</Label>
- <Input
- id="portValue"
- type="number"
- value={newPortValue}
- onChange={(e) => setNewPortValue(e.target.value)}
- placeholder="e.g., 80, 8080"
- />
- </div>
- </div>
- <DialogFooter>
- <DialogClose asChild>
- <Button variant="outline">Cancel</Button>
- </DialogClose>
- <Button onClick={handleAddPort}>Add Port</Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- </>
- );
-}
-
-import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./components/ui/accordion";
-import { Badge } from "./components/ui/badge";
-
-function Exports({ n }: { n: AppNode }): React.ReactNode {
- return (
- <Accordion type="single" collapsible className="w-full">
- <AccordionItem value="exports" className="!border-none">
- <AccordionTrigger className="flex flex-row-reverse !gap-1 !justify-end !h-fit !py-1">
- <Badge className="h-5 min-w-5 rounded-full px-2 font-mono tabular-nums">
- {nodeEnvVarNames(n).length}
- </Badge>{" "}
- Exports
- </AccordionTrigger>
- <AccordionContent>
- <ul className="pl-2 space-y-1">
- {nodeEnvVarNames(n).map((name) => {
- return (
- <li key={name} className="text-xs font-mono">
- {name}
- </li>
- );
- })}
- </ul>
- </AccordionContent>
- </AccordionItem>
- </Accordion>
+ </div>
);
}
diff --git a/apps/canvas/front/src/components/actions.tsx b/apps/canvas/front/src/components/actions.tsx
index dbb5ea6..d87f458 100644
--- a/apps/canvas/front/src/components/actions.tsx
+++ b/apps/canvas/front/src/components/actions.tsx
@@ -11,7 +11,7 @@
DropdownMenuContent,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
-import { LoaderCircle, Menu } from "lucide-react";
+import { Ellipsis, LoaderCircle } from "lucide-react";
function toNodeType(t: string): string {
if (t === "ingress") {
@@ -70,7 +70,6 @@
return;
}
const data: { type: string; name: string; status: string }[] = await resp.json();
- console.log(data);
for (const n of nodes) {
if (n.type === "network") {
continue;
@@ -269,7 +268,9 @@
</Button>
<DropdownMenu>
<DropdownMenuTrigger>
- <Menu className="rounded-md bg-gray-200 opacity-50" />
+ <Button size="icon">
+ <Ellipsis />
+ </Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuGroup>
@@ -322,7 +323,9 @@
<Button onClick={save}>Save</Button>
<DropdownMenu>
<DropdownMenuTrigger>
- <Menu />
+ <Button size="icon">
+ <Ellipsis />
+ </Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuGroup>
diff --git a/apps/canvas/front/src/components/canvas.tsx b/apps/canvas/front/src/components/canvas.tsx
index 861437f..96b8f03 100644
--- a/apps/canvas/front/src/components/canvas.tsx
+++ b/apps/canvas/front/src/components/canvas.tsx
@@ -31,7 +31,7 @@
onConnect: state.onConnect,
});
-export function Canvas() {
+export function Canvas({ className }: { className?: string }) {
const { nodes, edges, onNodesChange, onEdgesChange, onConnect } = useStateStore(useShallow(selector));
const store = useStateStore();
const instance = useReactFlow();
@@ -101,7 +101,7 @@
[instance],
);
return (
- <div style={{ width: "100%", height: "100%" }}>
+ <div style={{ width: "100%", height: "100%" }} className={className}>
<ReactFlow
nodeTypes={nodeTypes}
nodes={nodes}
@@ -119,7 +119,7 @@
gap={12}
size={1}
/>
- <Panel position="bottom-right">
+ <Panel position="top-right">
<Actions />
</Panel>
</ReactFlow>
diff --git a/apps/canvas/front/src/components/node-app.tsx b/apps/canvas/front/src/components/node-app.tsx
index 7516d19..71fc358 100644
--- a/apps/canvas/front/src/components/node-app.tsx
+++ b/apps/canvas/front/src/components/node-app.tsx
@@ -13,21 +13,26 @@
AppNode,
GithubNode,
useEnv,
+ useGithubRepositories,
} 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 { 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 { PencilIcon, XIcon } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
import { Textarea } from "./ui/textarea";
import { Input } from "./ui/input";
-import { Checkbox } from "./ui/checkbox";
+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 { Icon } from "./icon";
+import { Badge } from "./ui/badge";
+import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "./ui/accordion";
export function NodeApp(node: ServiceNode) {
const { id, selected } = node;
@@ -71,11 +76,6 @@
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(),
@@ -91,10 +91,124 @@
subdomain: z.string().min(1, "required"),
});
-export function NodeAppDetails({ id, data, disabled }: ServiceNode & { disabled?: boolean }) {
+export function NodeAppDetails({ node, disabled }: { node: ServiceNode; disabled?: boolean }) {
+ const { data } = node;
+ return (
+ <>
+ <Name node={node} disabled={disabled} />
+ <Tabs defaultValue="runtime">
+ <TabsList className="w-full flex flex-row justify-between">
+ <TabsTrigger value="runtime">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <Container />
+ </TooltipTrigger>
+ <TooltipContent>Runtime</TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </TabsTrigger>
+ <TabsTrigger value="ports">
+ <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">
+ <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>
+ <TabsTrigger value="dev">
+ <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>
+ <TabsContent value="dev">
+ <Dev node={node} disabled={disabled} />
+ </TabsContent>
+ </Tabs>
+ </>
+ );
+}
+
+function Name({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
+ const { id, data } = node;
const store = useStateStore();
- const nodes = useNodes<AppNode>();
- const env = useEnv();
+ const [isEditing, setIsEditing] = useState(false);
+ useEffect(() => {
+ if (data.label === "" && !disabled) {
+ setIsEditing(true);
+ }
+ }, [data.label, disabled]);
+ return (
+ <div className="flex flex-row gap-1 items-center">
+ <Icon type="app" />
+ {isEditing ? (
+ <Input
+ placeholder="Name"
+ value={data.label}
+ onChange={(e) => store.updateNodeData(id, { label: e.target.value })}
+ onBlur={() => {
+ if (data.label !== "") {
+ setIsEditing(false);
+ }
+ }}
+ autoFocus={true}
+ />
+ ) : (
+ <h3
+ className="text-lg font-bold cursor-text select-none hover:outline-solid hover:outline-2 hover:outline-gray-200"
+ onClick={() => {
+ if (!disabled) {
+ setIsEditing(true);
+ }
+ }}
+ >
+ {data.label}
+ </h3>
+ )}
+ </div>
+ );
+}
+
+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",
@@ -103,41 +217,12 @@
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;
}
@@ -163,21 +248,6 @@
);
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") {
@@ -189,82 +259,88 @@
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,
- };
- }),
+ const setPreBuildCommands = useCallback(
+ (e: React.ChangeEvent<HTMLTextAreaElement>) => {
+ store.updateNodeData<"app">(id, {
+ preBuildCommands: e.currentTarget.value,
});
},
- [id, data],
+ [id, store],
);
- const saveAliasOnEnter = useCallback(
- (e: BoundEnvVar) => {
- return (event: KeyboardEvent<HTMLInputElement>) => {
- if (event.key === "Enter") {
- event.preventDefault();
- saveAlias(e, event.currentTarget.value, store);
- }
- };
- },
- [store, saveAlias],
+ return (
+ <>
+ <SourceRepo node={node} disabled={disabled} />
+ <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.map((t) => (
+ <SelectItem key={t} value={t}>
+ {t}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </form>
+ </Form>
+ <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}
+ />
+ </>
);
- const saveAliasOnBlur = useCallback(
- (e: BoundEnvVar) => {
- return (event: FocusEvent<HTMLInputElement>) => {
- saveAlias(e, event.currentTarget.value, store);
- };
- },
- [store, saveAlias],
- );
+}
+
+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>();
- console.log(store.edges);
store.setEdges(
store.edges.filter((e) => {
if (e.source !== id || e.sourceHandle !== "ports") {
@@ -350,76 +426,174 @@
},
[id, data, store],
);
- const setPreBuildCommands = useCallback(
- (e: React.ChangeEvent<HTMLTextAreaElement>) => {
- store.updateNodeData<"app">(id, {
- preBuildCommands: e.currentTarget.value,
+ 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, store],
+ [id, data],
);
-
- 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,
- },
- });
+ const saveAliasOnEnter = useCallback(
+ (e: BoundEnvVar) => {
+ return (event: KeyboardEvent<HTMLInputElement>) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ saveAlias(e, event.currentTarget.value, store);
}
- },
- );
- return () => sub.unsubscribe();
- }, [id, data, sourceForm, 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",
@@ -627,215 +801,6 @@
}, [id, data, exposeForm, store]);
return (
<>
- <Form {...exposeForm}>
- <form className="space-y-2">
- <FormField
- control={form.control}
- name="name"
- render={({ field }) => (
- <FormItem>
- <FormControl>
- <Input
- placeholder="name"
- className="lowercase"
- {...field}
- ref={focus(field, "name")}
- disabled={disabled}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="type"
- render={({ field }) => (
- <FormItem>
- <Select
- onValueChange={field.onChange}
- defaultValue={field.value}
- {...typeProps}
- disabled={disabled}
- >
- <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} disabled={disabled}>
- <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="lowercase" {...field} disabled={disabled} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={sourceForm.control}
- name="rootDir"
- render={({ field }) => (
- <FormItem>
- <FormControl>
- <Input placeholder="/" {...field} disabled={disabled} />
- </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)}
- className="w-4 h-4"
- disabled={disabled}
- >
- <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="lowercase" {...field} disabled={disabled} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={portForm.control}
- name="value"
- render={({ field }) => (
- <FormItem>
- <FormControl>
- <Input placeholder="value" {...field} disabled={disabled} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <Button type="submit" disabled={disabled}>
- 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="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>
- <div className="flex flex-row items-center gap-1">
- <Button size={"icon"} variant={"ghost"} className="w-4 h-4">
- <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}
- disabled={disabled}
- />
- Dev
<Form {...devForm}>
<form className="space-y-2">
<FormField
@@ -844,13 +809,13 @@
render={({ field }) => (
<FormItem>
<div className="flex flex-row gap-1 items-center">
- <Checkbox
+ <Switch
id="devEnabled"
onCheckedChange={field.onChange}
checked={field.value}
disabled={disabled}
/>
- <Label htmlFor="devEnabled">Enabled</Label>
+ <Label htmlFor="devEnabled">Dev VM</Label>
</div>
<FormMessage />
</FormItem>
@@ -861,6 +826,7 @@
{data.dev && data.dev.enabled && (
<Form {...exposeForm}>
<form className="space-y-2">
+ <Label>Network</Label>
<FormField
control={exposeForm.control}
name="network"
@@ -868,12 +834,12 @@
<FormItem>
<Select
onValueChange={field.onChange}
- defaultValue={field.value}
+ value={field.value || ""}
disabled={disabled}
>
<FormControl>
<SelectTrigger>
- <SelectValue placeholder="Network" />
+ <SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -889,13 +855,14 @@
</FormItem>
)}
/>
+ <Label>Subdomain</Label>
<FormField
control={exposeForm.control}
name="subdomain"
render={({ field }) => (
<FormItem>
<FormControl>
- <Input placeholder="subdomain" {...field} disabled={disabled} />
+ <Input {...field} disabled={disabled} />
</FormControl>
<FormMessage />
</FormItem>
@@ -907,3 +874,294 @@
</>
);
}
+
+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>
+ );
+}
diff --git a/apps/canvas/front/src/components/node-details.tsx b/apps/canvas/front/src/components/node-details.tsx
index 2d9cd58..d120c76 100644
--- a/apps/canvas/front/src/components/node-details.tsx
+++ b/apps/canvas/front/src/components/node-details.tsx
@@ -22,7 +22,7 @@
function NodeDetailsImpl(props: NodeDetailsProps) {
switch (props.type) {
case "app":
- return <NodeAppDetails {...props} />;
+ return <NodeAppDetails node={props} disabled={props.disabled} />;
case "gateway-https":
return <NodeGatewayHttpsDetails {...props} />;
case "gateway-tcp":
diff --git a/apps/canvas/front/src/components/node-gateway-https.tsx b/apps/canvas/front/src/components/node-gateway-https.tsx
index 81c40c3..1eb0409 100644
--- a/apps/canvas/front/src/components/node-gateway-https.tsx
+++ b/apps/canvas/front/src/components/node-gateway-https.tsx
@@ -17,10 +17,10 @@
import { Form, FormControl, FormField, FormItem, FormMessage } from "./ui/form";
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
-import { Checkbox } from "./ui/checkbox";
import { Label } from "./ui/label";
import { Button } from "./ui/button";
import { XIcon } from "lucide-react";
+import { Switch } from "./ui/switch";
const schema = z.object({
network: z.string().min(1, "reqired"),
@@ -411,7 +411,7 @@
render={({ field }) => (
<FormItem>
<div className="flex flex-row gap-1 items-center">
- <Checkbox
+ <Switch
id="authEnabled"
onCheckedChange={field.onChange}
checked={field.value}
diff --git a/apps/canvas/front/src/components/node-github.tsx b/apps/canvas/front/src/components/node-github.tsx
index dd7c68a..4373359 100644
--- a/apps/canvas/front/src/components/node-github.tsx
+++ b/apps/canvas/front/src/components/node-github.tsx
@@ -20,7 +20,6 @@
import { Form, FormControl, FormField, FormItem, FormMessage } from "./ui/form";
import { Handle, Position } from "@xyflow/react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
-import { GitHubRepository } from "../lib/github";
import { useProjectId } from "@/lib/state";
import { Alert, AlertDescription } from "./ui/alert";
import { AlertCircle, LoaderCircle, RefreshCw } from "lucide-react";
@@ -64,36 +63,11 @@
const repoError = useGithubRepositoriesError();
const fetchStoreRepositories = useFetchGithubRepositories();
- const [displayRepos, setDisplayRepos] = useState<GitHubRepository[]>([]);
-
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [showModal, setShowModal] = useState(false);
const [discoveredServices, setDiscoveredServices] = useState<z.infer<typeof serviceAnalyzisSchema>[]>([]);
const [selectedServices, setSelectedServices] = useState<Record<string, boolean>>({});
- useEffect(() => {
- let currentRepoInStore = false;
- if (data.repository) {
- currentRepoInStore = storeRepos.some((r) => r.id === data.repository!.id);
- }
-
- if (data.repository && !currentRepoInStore) {
- const currentRepoForDisplay: GitHubRepository = {
- id: data.repository.id,
- name: data.repository.sshURL.split("/").pop() || "",
- full_name: data.repository.fullName || data.repository.sshURL.split("/").slice(-2).join("/"),
- html_url: "",
- ssh_url: data.repository.sshURL,
- description: null,
- private: false,
- default_branch: "main",
- };
- setDisplayRepos([currentRepoForDisplay, ...storeRepos.filter((r) => r.id !== data.repository!.id)]);
- } else {
- setDisplayRepos(storeRepos);
- }
- }, [data.repository, storeRepos]);
-
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
mode: "onChange",
@@ -118,7 +92,7 @@
switch (name) {
case "repositoryId":
if (value.repositoryId) {
- const repo = displayRepos.find((r) => r.id === value.repositoryId);
+ const repo = storeRepos.find((r) => r.id === value.repositoryId);
if (repo) {
store.updateNodeData<"github">(id, {
repository: {
@@ -134,7 +108,7 @@
},
);
return () => sub.unsubscribe();
- }, [form, store, id, displayRepos]);
+ }, [form, store, id, storeRepos]);
const analyze = useCallback(async () => {
if (!data.repository?.sshURL) return;
@@ -177,7 +151,8 @@
const newNodeData: Omit<ServiceData, "activeField" | "state"> = {
label: service.name,
repository: {
- id: id,
+ id: data.repository!.id,
+ repoNodeId: id,
},
info: service,
type: "nodejs:24.0.2" as ServiceType,
@@ -239,7 +214,7 @@
githubService
? isLoadingRepos
? "Loading..."
- : displayRepos.length === 0
+ : storeRepos.length === 0
? "No repositories found"
: "Select a repository"
: "GitHub not configured"
@@ -248,7 +223,7 @@
</SelectTrigger>
</FormControl>
<SelectContent>
- {displayRepos.map((repo) => (
+ {storeRepos.map((repo) => (
<SelectItem key={repo.id} value={repo.id.toString()}>
{repo.full_name}
{repo.description && ` - ${repo.description}`}
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index 5d8013d..a0e4e91 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -132,16 +132,19 @@
export type ServiceData = NodeData & {
type: ServiceType;
- repository:
+ repository?:
| {
- id: string;
+ id: number;
+ repoNodeId: string;
}
| {
- id: string;
+ id: number;
+ repoNodeId: string;
branch: string;
}
| {
- id: string;
+ id: number;
+ repoNodeId: string;
branch: string;
rootDir: string;
};
@@ -640,7 +643,10 @@
return ret;
}
-export const useStateStore = create<AppState>((set, get): AppState => {
+export const useStateStore = create<AppState>((setOg, get): AppState => {
+ const set = (state: Partial<AppState>) => {
+ setOg(state);
+ };
const setN = (nodes: AppNode[]) => {
set({
nodes,
@@ -845,6 +851,17 @@
});
}
}
+ if (c.targetHandle === "repository") {
+ const sourceNode = nodes.find((n) => n.id === c.source);
+ if (sourceNode && sourceNode.type === "github" && sourceNode.data.repository) {
+ updateNodeData<"app">(tn.id, {
+ repository: {
+ id: sourceNode.data.repository.id,
+ repoNodeId: c.source,
+ },
+ });
+ }
+ }
}
if (c.sourceHandle === "volume") {
updateNodeData<"volume">(c.source, {
@@ -900,17 +917,6 @@
});
}
}
- if (tn.type === "app") {
- if (c.targetHandle === "repository") {
- updateNodeData<"app">(tn.id, {
- repository: {
- id: c.source,
- branch: "master",
- rootDir: "/",
- },
- });
- }
- }
}
const fetchGithubRepositories = async () => {