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 { useForm } from "react-hook-form";
import { z } from "zod";
import { slugify } from "~/lib/utils";
import { Button } from "./ui/button";
import {
@@ -15,19 +15,21 @@ import {
} from "./ui/dialog";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
const formSchema = z.object({
name: z
.string()
.min(2, {
message: "Volume name must be at least 2 characters long",
})
.max(32, {
message: "Volume name must be at most 32 characters long",
}),
export const formSchema = type({
name: "2<=string<=32",
backend: "'directory'",
}).or({
name: "2<=string<=32",
backend: "'nfs'",
server: "string",
exportPath: "string",
port: "number >= 1",
version: "'3' | '4' | '4.1'",
});
type FormValues = z.infer<typeof formSchema>;
type FormValues = typeof formSchema.infer;
type Props = {
open: boolean;
setOpen: (open: boolean) => void;
@@ -35,13 +37,16 @@ type Props = {
};
export const CreateVolumeDialog = ({ open, setOpen, onSubmit }: Props) => {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
const form = useForm<typeof formSchema.infer>({
resolver: arktypeResolver(formSchema),
defaultValues: {
name: "",
backend: "directory",
},
});
const watchedBackend = form.watch("backend");
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
@@ -76,6 +81,97 @@ export const CreateVolumeDialog = ({ open, setOpen, onSubmit }: Props) => {
</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 && ( */}
{/* <div className="text-red-500 text-sm"> */}
{/* {createVolume.error.message} */}

View File

@@ -1,16 +1,17 @@
import { type } from "arktype";
import { Copy, Folder } from "lucide-react";
import { useState } from "react";
import { useFetcher } from "react-router";
import { toast } from "sonner";
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 { Input } from "~/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { parseError } from "~/lib/errors";
import { cn } from "~/lib/utils";
import type { Route } from "./+types/home";
import { parseError } from "~/lib/errors";
import { toast } from "sonner";
export function meta(_: Route.MetaArgs) {
return [
@@ -27,7 +28,8 @@ export async function clientAction({ request }: Route.ClientActionArgs) {
const { _action, ...rest } = Object.fromEntries(formData.entries());
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) {
toast.error("Failed to delete volume", {
@@ -41,7 +43,17 @@ export async function clientAction({ request }: Route.ClientActionArgs) {
}
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) {
toast.error("Failed to create volume", {
description: parseError(error)?.message,
@@ -139,7 +151,7 @@ export default function Home({ loaderData, actionData }: Route.ComponentProps) {
<TableCell>
<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} />
Dir
Volume
</span>
</TableCell>
<TableCell>

View File

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

View File

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

View File

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

View File

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