feat: form updates
This commit is contained in:
parent
0cc8515bbd
commit
b326241ced
|
@ -14,10 +14,11 @@ import { Input } from "~/components/ui/input";
|
|||
import { Label } from "~/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import React, { Suspense } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "~/trpc/react";
|
||||
import { FormInputGroup } from "~/components/FormInput";
|
||||
|
||||
// kinda large dependency, but syntax highlighting is nice
|
||||
const Editor = React.lazy(() => import("./YAMLEditor"));
|
||||
|
@ -29,9 +30,12 @@ enum ProjectType {
|
|||
}
|
||||
|
||||
export function CreateProjectButton() {
|
||||
const create = api.projects.create.useMutation();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: "",
|
||||
internalName: "",
|
||||
type: ProjectType.Template,
|
||||
composeURL: "",
|
||||
composeFile:
|
||||
|
@ -44,6 +48,18 @@ export function CreateProjectButton() {
|
|||
return "A Docker Compose file is required";
|
||||
}
|
||||
},
|
||||
|
||||
name: (value) => !value && "A project name is required",
|
||||
|
||||
internalName: (value) => {
|
||||
if (!value) {
|
||||
return "An internal project name is required";
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9\-]+$/g.test(value)) {
|
||||
return "Internal project names can only contain letters, numbers, and dashes";
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -56,20 +72,30 @@ export function CreateProjectButton() {
|
|||
{
|
||||
enabled: false,
|
||||
cacheTime: 0,
|
||||
retry: false,
|
||||
onSuccess(data) {
|
||||
form.setFieldValue("composeFile", data);
|
||||
toast.success("Fetched Docker Compose file");
|
||||
},
|
||||
onError(error) {
|
||||
toast.error("Failed to fetch Docker Compose file");
|
||||
console.error(error);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// reset fetchComposeFile when the URL changes
|
||||
React.useEffect(() => {
|
||||
fetchComposeFile.remove();
|
||||
}, [form.values.composeURL]);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">Create Project</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="m-4 max-w-4xl overflow-scroll [&>*]:max-w-full">
|
||||
<DialogContent className="overflow-auto sm:max-w-4xl [&>*]:max-w-full">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a Project</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
@ -80,24 +106,62 @@ export function CreateProjectButton() {
|
|||
</DialogHeader>
|
||||
|
||||
<form
|
||||
onSubmit={form.onSubmit(() => {})}
|
||||
onSubmit={form.onSubmit((data) => {
|
||||
create.mutate({
|
||||
friendlyName: data.name,
|
||||
internalName: data.internalName,
|
||||
});
|
||||
})}
|
||||
className="max-w-full space-y-4"
|
||||
id="create-project-form"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-name">Project Name</Label>
|
||||
<FormInputGroup
|
||||
label="Project Name"
|
||||
description="The name of your project"
|
||||
required
|
||||
form={form}
|
||||
formKey="name"
|
||||
>
|
||||
<Input
|
||||
id="project-name"
|
||||
placeholder="My Project"
|
||||
className="w-full"
|
||||
{...form.getInputProps("name")}
|
||||
onChange={(e) => {
|
||||
form.setFieldValue("name", e.target.value);
|
||||
|
||||
if (!form.isDirty("internalName")) {
|
||||
form.setFieldValue(
|
||||
"internalName",
|
||||
e.target.value.replace(/[^a-zA-Z\d:]/g, "-").toLowerCase(),
|
||||
);
|
||||
|
||||
form.setDirty({ internalName: false });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormInputGroup>
|
||||
|
||||
<FormInputGroup
|
||||
label="Internal Project Name"
|
||||
description="The name of your project, used internally for networking and DNS"
|
||||
required
|
||||
form={form}
|
||||
formKey="internalName"
|
||||
>
|
||||
<Input
|
||||
id="project-internal-name"
|
||||
placeholder="my-project"
|
||||
className="w-full"
|
||||
{...form.getInputProps("internalName")}
|
||||
/>
|
||||
</FormInputGroup>
|
||||
|
||||
<Tabs defaultValue="templates" className="w-full max-w-full">
|
||||
<TabsList className="mx-auto">
|
||||
<TabsTrigger value="templates">From a Template</TabsTrigger>
|
||||
<TabsTrigger value="compose">
|
||||
From a Docker Compose file
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="blank">Create Blank Project</TabsTrigger>
|
||||
<TabsList className="mx-auto flex w-fit flex-wrap items-center text-center">
|
||||
<TabsTrigger value="templates">Template</TabsTrigger>
|
||||
<TabsTrigger value="compose">Docker Compose</TabsTrigger>
|
||||
<TabsTrigger value="blank">Blank Project</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="templates">
|
||||
|
@ -109,18 +173,27 @@ export function CreateProjectButton() {
|
|||
<TabsContent value="compose" className="space-y-2">
|
||||
<Label htmlFor="compose-url">Docker Compose URL</Label>
|
||||
<div className="flex flex-row gap-2">
|
||||
{/* Maybe we should add the ability to fetch URLs protected by CORS by routing through the server
|
||||
Currently sites like pastebin.com will not work due to strict CORS policies
|
||||
|
||||
If we do this, make sure the user cannot access private URLs and proper input validation is done */}
|
||||
<Input
|
||||
id="compose-url"
|
||||
placeholder="https://example.com/docker-compose.yml"
|
||||
className="flex-grow"
|
||||
type="url"
|
||||
{...form.getInputProps("composeURL")}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
variant={fetchComposeFile.isSuccess ? "success" : "outline"}
|
||||
onClick={() => fetchComposeFile.refetch()}
|
||||
isLoading={fetchComposeFile.isFetching}
|
||||
disabled={
|
||||
fetchComposeFile.isFetching || !form.values.composeURL
|
||||
}
|
||||
>
|
||||
Fetch
|
||||
{fetchComposeFile.isSuccess ? "Fetched!" : "Fetch"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
@ -140,7 +213,13 @@ export function CreateProjectButton() {
|
|||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit">Create Project</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-project-form"
|
||||
isLoading={create.isLoading}
|
||||
>
|
||||
Create Project
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import CodeMirror, { EditorView } from "@uiw/react-codemirror";
|
||||
// import { yaml } from "@nicktomlin/codemirror-lang-yaml-lite";
|
||||
import { langs } from "@uiw/codemirror-extensions-langs";
|
||||
import { useTheme } from "next-themes";
|
||||
|
@ -20,7 +20,7 @@ export default function YAMLEditor({
|
|||
value={value}
|
||||
onChange={onChange}
|
||||
theme={theme.theme === "dark" ? "dark" : "light"}
|
||||
extensions={[langs.yaml()]}
|
||||
extensions={[langs.yaml(), EditorView.lineWrapping]}
|
||||
data-enable-grammarly="false"
|
||||
spellCheck={false}
|
||||
width="100%"
|
||||
|
|
0
src/app/project/[id]/page.tsx
Normal file
0
src/app/project/[id]/page.tsx
Normal file
40
src/components/FormInput.tsx
Normal file
40
src/components/FormInput.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { GetInputProps, UseFormReturnType } from "@mantine/form/lib/types";
|
||||
import { Label } from "./ui/label";
|
||||
import { Required } from "./ui/required";
|
||||
|
||||
export function FormInputGroup<Values>({
|
||||
children,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
form,
|
||||
formKey,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
label: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
|
||||
formKey: keyof Values;
|
||||
form: UseFormReturnType<Values, any>;
|
||||
}) {
|
||||
const inputProps = form.getInputProps(formKey);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Label>
|
||||
{label}
|
||||
{required && <Required />}
|
||||
{inputProps.error && (
|
||||
<span className="text-red-500"> - {inputProps.error}</span>
|
||||
)}
|
||||
</Label>
|
||||
{/* screen readers should probably read the description first */}
|
||||
{description && <div className="sr-only">{description}</div>}
|
||||
{children}
|
||||
{description && (
|
||||
<div className="text-sm text-gray-500">{description}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -19,6 +19,7 @@ const buttonVariants = cva(
|
|||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
success: "bg-green-500 text-white shadow-sm hover:bg-green-600",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
|
|
Loading…
Reference in a new issue