Canvas: Fix project creation flow

Change-Id: I2373982b37807db17391149a7ad40ebd4a5894ed
diff --git a/apps/canvas/back/src/index.ts b/apps/canvas/back/src/index.ts
index 8bb1a81..295bdf3 100644
--- a/apps/canvas/back/src/index.ts
+++ b/apps/canvas/back/src/index.ts
@@ -23,7 +23,7 @@
 		resp.header("Content-Type", "application/json");
 		resp.write(
 			JSON.stringify({
-				id,
+				id: id.toString(),
 			}),
 		);
 	} catch (e) {
diff --git a/apps/canvas/front/src/ProjectSelect.tsx b/apps/canvas/front/src/ProjectSelect.tsx
index 6903c5e..4367bef 100644
--- a/apps/canvas/front/src/ProjectSelect.tsx
+++ b/apps/canvas/front/src/ProjectSelect.tsx
@@ -3,38 +3,59 @@
 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./components/ui/select";
 import { Input } from "./components/ui/input";
 import { Button } from "./components/ui/button";
-import { Dialog, DialogContent, DialogTrigger } from "./components/ui/dialog";
+import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "./components/ui/dialog";
 import { useToast } from "@/hooks/use-toast";
 import { Separator } from "./components/ui/separator";
 import { Plus } from "lucide-react";
+import { z } from "zod";
+
+const createNewSchema = z.object({
+	id: z.string().min(1),
+});
 
 export function ProjectSelect() {
 	const { toast } = useToast();
 	const store = useStateStore();
 	const [projects, setProjects] = useState<Project[]>([]);
 	const projectId = useProjectId();
+	// Track the selected project ID locally to ensure UI consistency
+	const [selectedId, setSelectedId] = useState<string | undefined>(projectId);
 
-	const refreshProjects = useCallback(async () => {
-		try {
-			const resp = await fetch("/api/project");
-			const projectList = await resp.json();
-			const sortedProjects = [...projectList].sort((a, b) =>
-				a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
-			);
-			setProjects(sortedProjects);
-		} catch (e) {
-			console.log(e);
-		}
-	}, [setProjects]);
-
+	// Keep local state in sync with global state
 	useEffect(() => {
-		if (projects.length > 0 && (projectId == null || !projects.some((p) => p.id === projectId))) {
-			store.setProject(projects[0].id);
-		}
-	}, [projectId, projects, store]);
+		setSelectedId(projectId);
+	}, [projectId]);
+
+	const refreshProjects = useCallback(
+		async (id?: string) => {
+			console.log("refreshProjects", id);
+			try {
+				const resp = await fetch("/api/project");
+				const projectList = await resp.json();
+				const sortedProjects = [...projectList].sort((a, b) =>
+					a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
+				);
+				setProjects(sortedProjects);
+				if (id && !sortedProjects.some((p) => p.id === id)) {
+					throw new Error("MUST NOT REACH!");
+				}
+				if (id == null) {
+					id = sortedProjects[0].id;
+				}
+				if (id !== selectedId) {
+					setSelectedId(id);
+					store.setProject(id);
+				}
+			} catch (e) {
+				console.log(e);
+			}
+		},
+		[selectedId, store, setProjects, setSelectedId],
+	);
+
 	useEffect(() => {
 		refreshProjects();
-	}, [refreshProjects]);
+	});
 
 	const [createNewOpen, setCreateNewOpen] = useState(false);
 	const onSelect = useCallback(
@@ -42,11 +63,13 @@
 			if (projectId === "create-new") {
 				setCreateNewOpen(true);
 			} else {
+				setSelectedId(projectId);
 				store.setProject(projectId);
 			}
 		},
-		[store],
+		[store, setSelectedId],
 	);
+
 	const [name, setName] = useState<string | undefined>(undefined);
 	const updateName = useCallback(
 		(e: ChangeEvent<HTMLInputElement>) => {
@@ -54,9 +77,13 @@
 		},
 		[setName],
 	);
+
 	const createNew = useCallback(() => {
-		console.log(name);
 		if (!name) {
+			toast({
+				variant: "destructive",
+				title: "Name is required",
+			});
 			return;
 		}
 		fetch("/api/project", {
@@ -70,12 +97,20 @@
 		})
 			.then(async (resp) => {
 				if (!resp.ok) {
+					return false;
+				}
+				const result = createNewSchema.safeParse(await resp.json());
+				if (!result.success) {
+					toast({
+						variant: "destructive",
+						title: `Failed to create project: ${name}`,
+					});
 					return;
 				}
-				const { id } = await resp.json();
-				await refreshProjects();
-				store.setProject(id as string);
+				const { id } = result.data;
+				await refreshProjects(id);
 				setCreateNewOpen(false);
+				setName(undefined); // Clear the input for next time
 				toast({
 					title: `Created project: ${name}`,
 				});
@@ -87,32 +122,50 @@
 					title: `Failed to create project: ${name}`,
 				});
 			});
-	}, [name, setCreateNewOpen, toast, store, refreshProjects]);
+	}, [name, setCreateNewOpen, toast, refreshProjects]);
+
 	return (
-		<Select onValueChange={onSelect} value={projectId}>
-			<SelectTrigger className="w-[200px] !border-none !shadow-none !focus:ring-0 !focus:ring-offset-0">
-				<SelectValue placeholder="Choose Project" defaultValue={projectId} />
-			</SelectTrigger>
-			<SelectContent>
-				{projects.map((p) => (
-					<SelectItem key={p.id} value={p.id}>
-						{p.name}
-					</SelectItem>
-				))}
-				<Separator />
-				<SelectItem value={"create-new"}>
-					<Dialog open={createNewOpen} onOpenChange={setCreateNewOpen}>
-						<DialogTrigger className="flex flex-row items-center">
+		<>
+			<Select onValueChange={onSelect} value={selectedId}>
+				<SelectTrigger className="w-[200px] !border-none !shadow-none !focus:ring-0 !focus:ring-offset-0">
+					<SelectValue placeholder="Choose Project" />
+				</SelectTrigger>
+				<SelectContent>
+					{projects.map((p) => (
+						<SelectItem key={p.id} value={p.id}>
+							{p.name}
+						</SelectItem>
+					))}
+					<Separator />
+					<SelectItem value={"create-new"}>
+						<div className="flex flex-row items-center gap-1">
 							<Plus />
-							Create New
-						</DialogTrigger>
-						<DialogContent>
-							<Input type="text" placeholder="Name" onChange={updateName} />
-							<Button onClick={createNew}>Create New</Button>
-						</DialogContent>
-					</Dialog>
-				</SelectItem>
-			</SelectContent>
-		</Select>
+							<div>New project</div>
+						</div>
+					</SelectItem>
+				</SelectContent>
+			</Select>
+			<Dialog open={createNewOpen} onOpenChange={setCreateNewOpen}>
+				<DialogContent>
+					<DialogHeader>
+						<DialogTitle>New project</DialogTitle>
+					</DialogHeader>
+					<Input
+						type="text"
+						placeholder="Name"
+						onChange={updateName}
+						value={name || ""}
+						onKeyDown={(e) => {
+							if (e.key === "Enter") {
+								createNew();
+							}
+						}}
+					/>
+					<DialogFooter>
+						<Button onClick={createNew}>Create</Button>
+					</DialogFooter>
+				</DialogContent>
+			</Dialog>
+		</>
 	);
 }
diff --git a/apps/canvas/front/src/components/actions.tsx b/apps/canvas/front/src/components/actions.tsx
index f292c30..de04e9e 100644
--- a/apps/canvas/front/src/components/actions.tsx
+++ b/apps/canvas/front/src/components/actions.tsx
@@ -155,6 +155,7 @@
 	const edit = useCallback(async () => {
 		store.setMode("edit");
 	}, [store]);
+	// TODO(gio): refresh projects
 	const deleteProject = useCallback(async () => {
 		if (projectId == null) {
 			return;