Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
308 changes: 308 additions & 0 deletions app/(main)/_components/createProject.tsx
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Don't toast on project creation success
  2. Configurable options should render properly. Not just inputs. useConfigurableOptions() should yield something like the file attached: example.json

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in commit 2726407:

  1. ✅ Removed success toast - the dialog now closes silently on successful creation
  2. ✅ Configuration options now render properly based on the metadata structure:
    • Boolean fields (type: "bool") render as checkboxes
    • Integer fields (type: "integer") render as number inputs
    • String fields render as text inputs
    • Options are grouped by category (features, limits, restricted, etc.) in expandable accordions
    • Parses the nested structure from the example.json format with keys arrays containing config objects

Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
"use client";

// Component for creating new Incus projects
import { Button } from "@/app/_components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/app/_components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/app/_components/ui/form";
import { Input } from "@/app/_components/ui/input";
import { useState, use } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import z from "zod";
import { createProject } from "@/app/(main)/_lib/projects";
import { mutate } from "swr";
import { toast } from "sonner";
import { Textarea } from "@/app/_components/textarea";
import { useConfigurableOptions } from "@/app/_hooks/server";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/app/_components/ui/accordion";
import ProjectsContext from "@/app/(main)/_context/projects";
import { Checkbox } from "@/app/_components/ui/checkbox";

const formSchema = z.object({
name: z
.string()
.min(1, "Project name is required")
.max(63, "Project name must be at most 63 characters long")
.regex(
/^[a-z][a-z0-9-]*[a-z0-9]$/,
"Project name must start with a lowercase letter and can only contain lowercase letters, numbers, and dashes. It cannot end with a dash."
),
description: z.string().optional(),
config: z.record(z.string()).optional(),
});

interface ProjectConfigField {
key: string;
type?: string;
shortdesc?: string;
longdesc?: string;
defaultdesc?: string;
initialvaluedesc?: string;
}

interface ConfigCategory {
keys: Array<Record<string, ProjectConfigField>>;
}

interface ProjectConfigOptions {
[category: string]: ConfigCategory;
}

interface CreateProjectProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}

export default function CreateProject({ open, onOpenChange }: CreateProjectProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const { data: configurableOptions } = useConfigurableOptions();
const { setProject } = use(ProjectsContext);

const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
description: "",
config: {},
},
});

// Parse project configuration options from metadata
const projectConfigOptions: Array<{ category: string; fields: Array<{ name: string; field: ProjectConfigField }> }> = [];

if (configurableOptions?.configs?.project) {
const projectConfig = configurableOptions.configs.project as ProjectConfigOptions;

Object.entries(projectConfig).forEach(([category, categoryData]) => {
if (categoryData.keys && Array.isArray(categoryData.keys)) {
const fields = categoryData.keys.map((keyObj) => {
const [name, field] = Object.entries(keyObj)[0];
return { name, field };
});
projectConfigOptions.push({ category, fields });
}
});
}

const onSubmit = async (values: z.infer<typeof formSchema>) => {
try {
setIsSubmitting(true);

// Filter out empty config values
const config = Object.entries(values.config || {}).reduce(
(acc, [key, value]) => {
if (value && value.trim() !== "") {
acc[key] = value;
}
return acc;
},
{} as Record<string, string>
);

await createProject({
name: values.name,
description: values.description,
config: Object.keys(config).length > 0 ? config : undefined,
});

// Invalidate the projects cache to refresh the list
await mutate("/1.0/projects?recursion=1");

// Set the newly created project as the current project
setProject(values.name);

onOpenChange(false);
form.reset();
} catch (error) {
console.error("Failed to create project:", error);
toast.error("Failed to create project", {
description:
error instanceof Error ? error.message : "An unknown error occurred",
});
} finally {
setIsSubmitting(false);
}
};

const renderConfigField = (
name: string,
field: ProjectConfigField,
formField: {
value: string;
onChange: (value: string) => void;
onBlur: () => void;
name: string;
ref: React.Ref<HTMLInputElement>;
}
) => {
const defaultDesc = field.defaultdesc || field.initialvaluedesc || "";

if (field.type === "bool") {
return (
<div className="flex items-center space-x-2">
<Checkbox
id={name}
checked={formField.value === "true"}
onCheckedChange={(checked) => {
formField.onChange(checked ? "true" : "false");
}}
disabled={isSubmitting}
/>
<label
htmlFor={name}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{name}
</label>
</div>
);
} else if (field.type === "integer") {
return (
<Input
type="number"
placeholder={defaultDesc}
{...formField}
disabled={isSubmitting}
/>
);
} else {
return (
<Input
placeholder={defaultDesc}
{...formField}
disabled={isSubmitting}
/>
);
}
};

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Project</DialogTitle>
<DialogDescription>
Create a new project with optional configuration
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="my-project"
{...field}
disabled={isSubmitting}
/>
</FormControl>
<FormDescription>
The name must start with a lowercase letter and contain only
lowercase letters, numbers, and dashes.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description (Optional)</FormLabel>
<FormControl>
<Textarea
placeholder="Project description"
{...field}
disabled={isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>

{projectConfigOptions.length > 0 && (
<Accordion type="multiple" className="w-full">
{projectConfigOptions.map(({ category, fields }) => (
<AccordionItem key={category} value={category}>
<AccordionTrigger className="capitalize">
{category}
</AccordionTrigger>
<AccordionContent>
<div className="space-y-4 pt-4">
{fields.map(({ name, field }) => (
<FormField
key={name}
control={form.control}
name={`config.${name}`}
render={({ field: formField }) => (
<FormItem>
{field.type !== "bool" && (
<FormLabel className="text-xs">
{name}
</FormLabel>
)}
<FormControl>
{renderConfigField(name, field, formField)}
</FormControl>
{(field.longdesc || field.shortdesc) && (
<FormDescription className="text-xs">
{field.shortdesc}
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
))}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)}

<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Project"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
22 changes: 20 additions & 2 deletions app/(main)/_components/header.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";
import { Separator } from "@/app/_components/ui/separator";
import { SidebarTrigger } from "@/app/_components/ui/sidebar";
import { GlobeIcon } from "lucide-react";
import { GlobeIcon, PlusIcon } from "lucide-react";
import {
Select,
SelectContent,
Expand All @@ -12,12 +12,16 @@ import {
SelectTrigger,
SelectValue,
} from "../../_components/ui/select";
import { use } from "react";
import { use, useState } from "react";
import ProjectsContext from "@/app/(main)/_context/projects";
import CreateProject from "./createProject";
import { Button } from "@/app/_components/ui/button";

export function SiteHeader() {
const { projects, currentProject, setProject, isLoading } =
use(ProjectsContext);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);

return (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
Expand Down Expand Up @@ -54,8 +58,22 @@ export function SiteHeader() {
))
: null}
</SelectGroup>
<SelectSeparator />
<Button
variant="ghost"
size="sm"
className="w-full justify-start font-normal"
onClick={() => setIsCreateDialogOpen(true)}
>
<PlusIcon className="mr-2 size-4" />
Create Project
</Button>
</SelectContent>
</Select>
<CreateProject
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
/>
</div>
</header>
);
Expand Down
30 changes: 29 additions & 1 deletion app/(main)/_lib/projects.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,36 @@
import { jsonFetcher } from "../../_lib/fetcher";
import type { ProjectsMetadata } from "./projects.d";
import type { Project, ProjectsMetadata } from "./projects.d";

export async function getProjects() {
return jsonFetcher("/1.0/projects?recursion=1").then(
(data) => data.metadata as ProjectsMetadata
);
}

export interface CreateProjectRequest {
name: string;
description?: string;
config?: Record<string, string>;
}

export async function createProject(
projectData: CreateProjectRequest
): Promise<Project> {
const endpoint = new URL(window.location.origin + "/1.0/projects");

const response = await fetch(endpoint.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(projectData),
});

const data = await response.json();

if (data.type === "error") {
throw new Error(data.error || "Failed to create project");
}

return data.metadata as Project;
}
Loading