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
245 changes: 245 additions & 0 deletions app/(main)/_components/editProject.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
"use client";

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 } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import z from "zod";
import { useProject } from "@/app/(main)/_hooks/project";
import { useConfigurableOptions } from "@/app/_hooks/server";
import { updateProject } from "@/app/(main)/_lib/projects";
import { mutate } from "swr";
import { toast } from "sonner";
import { Skeleton } from "@/app/_components/ui/skeleton";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/app/_components/ui/accordion";

const formSchema = z.object({
description: z.string().optional(),
config: z.record(z.string(), z.string()),
});

interface EditProjectProps {
projectName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}

export default function EditProject({
projectName,
open,
onOpenChange,
}: EditProjectProps) {
const { data: project, isLoading: projectLoading } = useProject(projectName);
const { data: configurableOptions, isLoading: optionsLoading } =
useConfigurableOptions();
const [isSubmitting, setIsSubmitting] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);

const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
values: project
? {
description: project.description || "",
config: project.config || {},
}
: {
description: "",
config: {},
},
});

const projectConfigOptions = configurableOptions?.configs.project || {};

async function onSubmit(values: z.infer<typeof formSchema>) {
setIsSubmitting(true);
try {
await updateProject(projectName, values.config, values.description);
// Revalidate the project data
await mutate("/1.0/projects?recursion=1");
await mutate(`/1.0/projects/${projectName}`);
toast.success(`Project "${projectName}" updated successfully`);
setHasUnsavedChanges(false);
onOpenChange(false);
} catch (error) {
const errorMessage =
error instanceof Error
? error.message
: "Failed to update project. Please try again.";
toast.error(errorMessage);
console.error("Error updating project:", error);
} finally {
setIsSubmitting(false);
}
}

function handleOpenChange(open: boolean) {
if (!open && hasUnsavedChanges && !isSubmitting) {
if (
confirm(
"You have unsaved changes. Are you sure you want to close without saving?"
)
) {
setHasUnsavedChanges(false);
onOpenChange(open);
}
} else {
onOpenChange(open);
}
}

const isLoading = projectLoading || optionsLoading;

return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Project: {projectName}</DialogTitle>
<DialogDescription>
Update the project configuration. See the{" "}
<a
href="https://linuxcontainers.org/incus/docs/main/reference/projects/"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
Incus documentation
</a>{" "}
for more information.
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
onChange={() => setHasUnsavedChanges(true)}
>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
placeholder="Project description"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<div className="space-y-2">
<h3 className="text-sm font-medium">Configuration</h3>
{Object.keys(projectConfigOptions).length > 0 ? (
<Accordion type="single" collapsible className="w-full">
{Object.entries(projectConfigOptions).map(
([key, option]) => (
<AccordionItem key={key} value={key}>
<AccordionTrigger className="text-sm">
<div className="flex items-center gap-2">
<code className="text-xs bg-muted px-1 rounded">
{key}
</code>
{option.shortdesc && (
<span className="text-muted-foreground text-left">
{option.shortdesc}
</span>
)}
</div>
</AccordionTrigger>
<AccordionContent>
<FormField
control={form.control}
name={`config.${key}`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">
{key}
</FormLabel>
<FormControl>
<Input
placeholder={
option.defaultdesc ||
`Enter value for ${key}`
}
{...field}
value={field.value ?? ""}
onChange={(e) =>
field.onChange(e.target.value)
}
/>
</FormControl>
{option.longdesc && (
<FormDescription className="text-xs">
{option.longdesc}
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
</AccordionContent>
</AccordionItem>
)
)}
</Accordion>
) : (
<p className="text-sm text-muted-foreground">
No configuration options available. Configuration metadata
could not be loaded from the server.
</p>
)}
</div>

<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</form>
</Form>
)}
</DialogContent>
</Dialog>
);
}
88 changes: 58 additions & 30 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, PencilIcon } from "lucide-react";
import {
Select,
SelectContent,
Expand All @@ -12,12 +12,20 @@ import {
SelectTrigger,
SelectValue,
} from "../../_components/ui/select";
import { use } from "react";
import { use, useState } from "react";
import ProjectsContext from "@/app/(main)/_context/projects";
import { Button } from "@/app/_components/ui/button";
import EditProject from "./editProject";
import { ALL_PROJECTS_VALUE } from "@/app/(main)/_context/projects";

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

const canEditCurrentProject =
!isLoading && currentProject !== ALL_PROJECTS_VALUE;

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 All @@ -28,35 +36,55 @@ export function SiteHeader() {
/>

<h1 className="text-base font-medium">Instances</h1>
<Select
value={currentProject}
onValueChange={setProject}
disabled={isLoading}
>
<SelectTrigger size="sm" className="ml-auto min-w-48 justify-between">
<SelectValue placeholder="Select Project" />
</SelectTrigger>
<SelectContent className="max-h-96">
<SelectItem value="all">
<div className="flex items-center gap-2">
<GlobeIcon className="size-4" />
<span>All Projects</span>
</div>
</SelectItem>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Projects</SelectLabel>
{!isLoading
? projects.map((project) => (
<SelectItem key={project.name} value={project.name}>
{project.name}
</SelectItem>
))
: null}
</SelectGroup>
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-2">
<Select
value={currentProject}
onValueChange={setProject}
disabled={isLoading}
>
<SelectTrigger size="sm" className="min-w-48 justify-between">
<SelectValue placeholder="Select Project" />
</SelectTrigger>
<SelectContent className="max-h-96">
<SelectItem value="all">
<div className="flex items-center gap-2">
<GlobeIcon className="size-4" />
<span>All Projects</span>
</div>
</SelectItem>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Projects</SelectLabel>
{!isLoading
? projects.map((project) => (
<SelectItem key={project.name} value={project.name}>
{project.name}
</SelectItem>
))
: null}
</SelectGroup>
</SelectContent>
</Select>
{canEditCurrentProject && (
<Button
size="sm"
variant="outline"
onClick={() => setEditDialogOpen(true)}
aria-label={`Edit project ${currentProject}`}
>
<PencilIcon className="size-4" />
<span className="ml-2">Edit Project</span>
</Button>
)}
</div>
</div>
{currentProject !== ALL_PROJECTS_VALUE && (
<EditProject
projectName={currentProject}
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
/>
)}
</header>
);
}
9 changes: 9 additions & 0 deletions app/(main)/_hooks/project.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import useSWR from "swr";
import { getProject } from "@/app/(main)/_lib/projects";

export function useProject(projectName: string | null) {
return useSWR(
projectName ? `/1.0/projects/${projectName}` : null,
() => (projectName ? getProject(projectName) : null)
);
}
Loading