refactor(client): zod to arktype

This commit is contained in:
Nicolas Meienberger
2025-09-02 19:33:56 +02:00
parent eb2fbe8f75
commit 9ef21d4ec2
6 changed files with 134 additions and 26 deletions

View File

@@ -1,7 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { arktypeResolver } from "@hookform/resolvers/arktype";
import { type } from "arktype";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod";
import { slugify } from "~/lib/utils"; import { slugify } from "~/lib/utils";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { import {
@@ -15,19 +15,21 @@ import {
} from "./ui/dialog"; } from "./ui/dialog";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
import { Input } from "./ui/input"; import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
const formSchema = z.object({ export const formSchema = type({
name: z name: "2<=string<=32",
.string() backend: "'directory'",
.min(2, { }).or({
message: "Volume name must be at least 2 characters long", name: "2<=string<=32",
}) backend: "'nfs'",
.max(32, { server: "string",
message: "Volume name must be at most 32 characters long", exportPath: "string",
}), port: "number >= 1",
version: "'3' | '4' | '4.1'",
}); });
type FormValues = z.infer<typeof formSchema>; type FormValues = typeof formSchema.infer;
type Props = { type Props = {
open: boolean; open: boolean;
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
@@ -35,13 +37,16 @@ type Props = {
}; };
export const CreateVolumeDialog = ({ open, setOpen, onSubmit }: Props) => { export const CreateVolumeDialog = ({ open, setOpen, onSubmit }: Props) => {
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<typeof formSchema.infer>({
resolver: zodResolver(formSchema), resolver: arktypeResolver(formSchema),
defaultValues: { defaultValues: {
name: "", name: "",
backend: "directory",
}, },
}); });
const watchedBackend = form.watch("backend");
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
@@ -76,6 +81,97 @@ export const CreateVolumeDialog = ({ open, setOpen, onSubmit }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
name="backend"
render={({ field }) => (
<FormItem>
<FormLabel>Backend</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a backend" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="directory">Directory</SelectItem>
<SelectItem value="nfs">NFS</SelectItem>
</SelectContent>
</Select>
<FormDescription>Choose the storage backend for this volume.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{watchedBackend === "nfs" && (
<>
<FormField
name="server"
render={({ field }) => (
<FormItem>
<FormLabel>Server</FormLabel>
<FormControl>
<Input placeholder="192.168.1.100" value={field.value ?? ""} onChange={field.onChange} />
</FormControl>
<FormDescription>NFS server IP address or hostname.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="exportPath"
render={({ field }) => (
<FormItem>
<FormLabel>Export Path</FormLabel>
<FormControl>
<Input placeholder="/export/data" value={field.value ?? ""} onChange={field.onChange} />
</FormControl>
<FormDescription>Path to the NFS export on the server.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input
type="number"
placeholder="2049"
value={field.value ?? ""}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
/>
</FormControl>
<FormDescription>NFS server port (default: 2049).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="version"
render={({ field }) => (
<FormItem>
<FormLabel>Version</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select NFS version" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="3">NFS v3</SelectItem>
<SelectItem value="4">NFS v4</SelectItem>
<SelectItem value="4.1">NFS v4.1</SelectItem>
</SelectContent>
</Select>
<FormDescription>NFS protocol version to use.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{/* {createVolume.error && ( */} {/* {createVolume.error && ( */}
{/* <div className="text-red-500 text-sm"> */} {/* <div className="text-red-500 text-sm"> */}
{/* {createVolume.error.message} */} {/* {createVolume.error.message} */}

View File

@@ -1,16 +1,17 @@
import { type } from "arktype";
import { Copy, Folder } from "lucide-react"; import { Copy, Folder } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useFetcher } from "react-router"; import { useFetcher } from "react-router";
import { toast } from "sonner";
import { createVolume, deleteVolume, listVolumes } from "~/api-client"; import { createVolume, deleteVolume, listVolumes } from "~/api-client";
import { CreateVolumeDialog } from "~/components/create-volume-dialog"; import { CreateVolumeDialog, formSchema } from "~/components/create-volume-dialog";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"; import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { parseError } from "~/lib/errors";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import type { Route } from "./+types/home"; import type { Route } from "./+types/home";
import { parseError } from "~/lib/errors";
import { toast } from "sonner";
export function meta(_: Route.MetaArgs) { export function meta(_: Route.MetaArgs) {
return [ return [
@@ -27,7 +28,8 @@ export async function clientAction({ request }: Route.ClientActionArgs) {
const { _action, ...rest } = Object.fromEntries(formData.entries()); const { _action, ...rest } = Object.fromEntries(formData.entries());
if (_action === "delete") { if (_action === "delete") {
const { error } = await deleteVolume({ path: { name: rest.name as string } }); const name = rest.name as string;
const { error } = await deleteVolume({ path: { name: name } });
if (error) { if (error) {
toast.error("Failed to delete volume", { toast.error("Failed to delete volume", {
@@ -41,7 +43,17 @@ export async function clientAction({ request }: Route.ClientActionArgs) {
} }
if (_action === "create") { if (_action === "create") {
const { error } = await createVolume({ body: { name: rest.name as string, config: { backend: "directory" } } }); const validationResult = formSchema(rest);
if (validationResult instanceof type.errors) {
toast.error("Invalid form data", {
description: "Please check your input and try again.",
});
return { error: validationResult, _action: "create" as const };
}
const validatedData = validationResult as typeof formSchema.infer;
const { error } = await createVolume({ body: { name: validatedData.name, config: validatedData } });
if (error) { if (error) {
toast.error("Failed to create volume", { toast.error("Failed to create volume", {
description: parseError(error)?.message, description: parseError(error)?.message,
@@ -139,7 +151,7 @@ export default function Home({ loaderData, actionData }: Route.ComponentProps) {
<TableCell> <TableCell>
<span className="mx-auto flex items-center gap-2 text-purple-800 dark:text-purple-300 rounded-md px-2 py-1"> <span className="mx-auto flex items-center gap-2 text-purple-800 dark:text-purple-300 rounded-md px-2 py-1">
<Folder size={10} /> <Folder size={10} />
Dir Volume
</span> </span>
</TableCell> </TableCell>
<TableCell> <TableCell>

View File

@@ -31,8 +31,7 @@
"react-hook-form": "^7.62.0", "react-hook-form": "^7.62.0",
"react-router": "^7.7.1", "react-router": "^7.7.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1"
"zod": "^4.0.17"
}, },
"devDependencies": { "devDependencies": {
"@react-router/dev": "^7.7.1", "@react-router/dev": "^7.7.1",

View File

@@ -13,8 +13,8 @@ const nfsConfigSchema = type({
backend: "'nfs'", backend: "'nfs'",
server: "string", server: "string",
exportPath: "string", exportPath: "string",
port: "number", port: "number >= 1",
version: "string", // Shold be an enum: "3" | "4" | "4.1" version: "'3' | '4' | '4.1'",
}); });
const smbConfigSchema = type({ const smbConfigSchema = type({

View File

@@ -34,7 +34,6 @@
"react-router": "^7.7.1", "react-router": "^7.7.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"zod": "^4.0.17",
}, },
"devDependencies": { "devDependencies": {
"@react-router/dev": "^7.7.1", "@react-router/dev": "^7.7.1",

View File

@@ -8,10 +8,12 @@ sync:
- ".DS_Store" - ".DS_Store"
- "tmp" - "tmp"
- "logs" - "logs"
- "mutagen.yml.lock"
- "data/ironmount.db"
ironmount: ironmount:
alpha: "." alpha: "."
beta: "nicolas@192.168.2.42:/home/nicolas/ironmount" beta: "nicolas@192.168.2.42:/home/nicolas/ironmount"
mode: "one-way-safe" mode: "one-way-replica"
flushOnCreate: true flushOnCreate: true
ignore: ignore:
paths: paths: