Files
tesseract/web/src/workspaces/new-workspace-dialog.tsx
Kenneth e70344d0f3 fix: handle images with same image id
when two images with different tags point to the same image id, the image dropdown in new workspace dialog breaks because it assumes image id is unique
2024-12-03 00:07:41 +00:00

272 lines
6.7 KiB
TypeScript

import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { DialogFooter, DialogHeader } from "@/components/ui/dialog";
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { ToastAction } from "@/components/ui/toast";
import { DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useToast } from "@/hooks/use-toast";
import { useTemplateImages } from "@/templates/api";
import { superstructResolver } from "@hookform/resolvers/superstruct";
import { useRef, useEffect } from "react";
import { useForm } from "react-hook-form";
import { nonempty, object, pattern, string, type Infer } from "superstruct";
import { useCreateWorkspace, useWorkspaceRuntimes } from "./api";
import type { TemplateImage } from "@/templates/types";
import type { WorkspaceRuntime } from "./types";
import { API_ERROR_WORKSPACE_EXISTS, isApiErrorResponse } from "@/api";
interface NewWorkspaceDialogProps {
onCreateSuccess: () => void;
}
const NewWorkspaceFormSchema = object({
workspaceName: pattern(string(), /^[\w-]+$/),
image: nonempty(string()),
runtime: nonempty(string()),
});
function NewWorkspaceDialog({ onCreateSuccess }: NewWorkspaceDialogProps) {
const {
data: templateImages,
isLoading: isLoadingImages,
error,
} = useTemplateImages();
const { data: runtimes, isLoading: isLoadingRuntimes } =
useWorkspaceRuntimes();
const isLoading = isLoadingImages || isLoadingRuntimes;
function content() {
if (error) {
console.log(error);
return (
<p className="opacity-80">
An error occurred when fetching available options.
</p>
);
}
if (isLoading) {
return (
<div className="w-full flex items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (!templateImages || !runtimes) {
return null;
}
if (templateImages.length === 0) {
return (
<>
<p className="opacity-80">
No images found. Create and build a template, and the resulting
image will show up here.
</p>
<Alert>
<AlertTitle>What are images?</AlertTitle>
<AlertDescription>
An image is used to bootstrap a workspace, including the operating
system, the environment, and packages, as specified by a template.
</AlertDescription>
</Alert>
</>
);
}
return (
<NewWorkspaceForm
templateImages={templateImages}
runtimes={runtimes}
onCreateSuccess={onCreateSuccess}
/>
);
}
return (
<DialogContent>
<DialogHeader>
<DialogTitle>New workspace</DialogTitle>
</DialogHeader>
{content()}
</DialogContent>
);
}
function NewWorkspaceForm({
templateImages,
runtimes,
onCreateSuccess,
}: {
templateImages: TemplateImage[];
runtimes: WorkspaceRuntime[];
onCreateSuccess: () => void;
}) {
const form = useForm({
resolver: superstructResolver(NewWorkspaceFormSchema),
defaultValues: {
workspaceName: "",
// image is in the form "imageTag imageId" (space as separator)
// this is to prevent two image tags pointing to the same image id
image: "",
runtime: "",
},
});
const { createWorkspace, status } = useCreateWorkspace();
const { toast } = useToast();
const formRef = useRef<HTMLFormElement | null>(null);
useEffect(() => {
switch (status.type) {
case "error":
if (isApiErrorResponse(status.error)) {
let toastTitle = "";
switch (status.error.code) {
case API_ERROR_WORKSPACE_EXISTS:
toastTitle = "Workspace already exists.";
break;
default:
toastTitle = "Failed to create the workspace.";
break;
}
toast({
variant: "destructive",
title: toastTitle,
description: status.error.error,
});
} else {
toast({
variant: "destructive",
title: "Failed to create the workspace.",
description: "Unknown error",
action: (
<ToastAction
onClick={() => {
formRef.current?.requestSubmit();
}}
altText="Try again"
>
Try again
</ToastAction>
),
});
}
break;
case "ok":
onCreateSuccess();
break;
default:
break;
}
}, [status, toast, onCreateSuccess]);
async function onSubmit(values: Infer<typeof NewWorkspaceFormSchema>) {
await createWorkspace({
workspaceName: values.workspaceName,
imageId: values.image.split(" ")[1],
runtime: values.runtime,
});
}
return (
<Form {...form}>
<form
ref={formRef}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="workspaceName"
render={({ field }) => (
<FormItem>
<FormLabel>Workspace name</FormLabel>
<FormControl>
<Input placeholder="my-workspace" {...field} />
</FormControl>
<FormDescription>
Must only contain alphanumeric characters and "-".
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="image"
render={({ field }) => (
<FormItem>
<FormLabel>Image for this workspace</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an image" />
</SelectTrigger>
</FormControl>
<SelectContent>
{templateImages.map((image) => (
<SelectItem
key={image.imageTag}
value={`${image.imageTag} ${image.imageId}`}
>
{image.imageTag}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="runtime"
render={({ field }) => (
<FormItem>
<FormLabel>Docker runtime</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a Docker runtime" />
</SelectTrigger>
</FormControl>
<SelectContent>
{runtimes.map((runtime) => (
<SelectItem key={runtime.name} value={runtime.name}>
{runtime.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
)}
/>
<DialogFooter>
<Button type="submit">Create</Button>
</DialogFooter>
</form>
</Form>
);
}
export { NewWorkspaceDialog };