From a29dceda9ffe7bb86f6cee2764110f577c3134a3 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Wed, 10 Dec 2025 20:25:28 +0100 Subject: [PATCH] refactor: split create-volume-form --- app/client/components/create-volume-form.tsx | 709 ------------------ .../volumes/components/create-volume-form.tsx | 214 ++++++ .../volume-forms/directory-form.tsx | 51 ++ .../volumes/components/volume-forms/index.ts | 5 + .../components/volume-forms/nfs-form.tsx | 120 +++ .../components/volume-forms/rclone-form.tsx | 127 ++++ .../components/volume-forms/smb-form.tsx | 164 ++++ .../components/volume-forms/webdav-form.tsx | 146 ++++ .../modules/volumes/routes/create-volume.tsx | 2 +- app/client/modules/volumes/tabs/info.tsx | 2 +- 10 files changed, 829 insertions(+), 711 deletions(-) delete mode 100644 app/client/components/create-volume-form.tsx create mode 100644 app/client/modules/volumes/components/create-volume-form.tsx create mode 100644 app/client/modules/volumes/components/volume-forms/directory-form.tsx create mode 100644 app/client/modules/volumes/components/volume-forms/index.ts create mode 100644 app/client/modules/volumes/components/volume-forms/nfs-form.tsx create mode 100644 app/client/modules/volumes/components/volume-forms/rclone-form.tsx create mode 100644 app/client/modules/volumes/components/volume-forms/smb-form.tsx create mode 100644 app/client/modules/volumes/components/volume-forms/webdav-form.tsx diff --git a/app/client/components/create-volume-form.tsx b/app/client/components/create-volume-form.tsx deleted file mode 100644 index 7f2af9a1..00000000 --- a/app/client/components/create-volume-form.tsx +++ /dev/null @@ -1,709 +0,0 @@ -import { arktypeResolver } from "@hookform/resolvers/arktype"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { type } from "arktype"; -import { CheckCircle, ExternalLink, Loader2, Pencil, Plug, Save, XCircle } from "lucide-react"; -import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; -import { cn, slugify } from "~/client/lib/utils"; -import { deepClean } from "~/utils/object"; -import { DirectoryBrowser } from "./directory-browser"; -import { Button } from "./ui/button"; -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"; -import { volumeConfigSchema } from "~/schemas/volumes"; -import { listRcloneRemotesOptions, testConnectionMutation } from "../api-client/@tanstack/react-query.gen"; -import { Alert, AlertDescription } from "./ui/alert"; -import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; -import { useSystemInfo } from "~/client/hooks/use-system-info"; - -export const formSchema = type({ - name: "2<=string<=32", -}).and(volumeConfigSchema); -const cleanSchema = type.pipe((d) => formSchema(deepClean(d))); - -export type FormValues = typeof formSchema.inferIn; - -type Props = { - onSubmit: (values: FormValues) => void; - mode?: "create" | "update"; - initialValues?: Partial; - formId?: string; - loading?: boolean; - className?: string; -}; - -const defaultValuesForType = { - directory: { backend: "directory" as const, path: "/" }, - nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const }, - smb: { backend: "smb" as const, port: 445, vers: "3.0" as const }, - webdav: { backend: "webdav" as const, port: 80, ssl: false }, - rclone: { backend: "rclone" as const, remote: "", path: "" }, -}; - -export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => { - const form = useForm({ - resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema), - defaultValues: initialValues, - resetOptions: { - keepDefaultValues: true, - keepDirtyValues: false, - }, - }); - - const { watch, getValues } = form; - - const { capabilities } = useSystemInfo(); - - const { data: rcloneRemotes, isLoading: isLoadingRemotes } = useQuery({ - ...listRcloneRemotesOptions(), - enabled: capabilities.rclone, - }); - - const watchedBackend = watch("backend"); - - useEffect(() => { - if (mode === "create") { - form.reset({ - name: form.getValues().name, - ...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType], - }); - } - }, [watchedBackend, form, mode]); - - const [testMessage, setTestMessage] = useState<{ success: boolean; message: string } | null>(null); - - const testBackendConnection = useMutation({ - ...testConnectionMutation(), - onMutate: () => { - setTestMessage(null); - }, - onError: (error) => { - setTestMessage({ - success: false, - message: error?.message || "Failed to test connection. Please try again.", - }); - }, - onSuccess: (data) => { - setTestMessage(data); - }, - }); - - const handleTestConnection = async () => { - const formValues = getValues(); - - if (formValues.backend === "nfs" || formValues.backend === "smb" || formValues.backend === "webdav") { - testBackendConnection.mutate({ - body: { config: formValues }, - }); - } - }; - - return ( -
- - ( - - Name - - field.onChange(slugify(e.target.value))} - max={32} - min={1} - /> - - Unique identifier for the volume. - - - )} - /> - ( - - Backend - - Choose the storage backend for this volume. - - - )} - /> - - {watchedBackend === "directory" && ( - { - return ( - - Directory Path - - {field.value ? ( -
-
-
Selected path:
-
{field.value}
-
- -
- ) : ( - field.onChange(path)} selectedPath={field.value} /> - )} -
- Browse and select a directory on the host filesystem to track. - -
- ); - }} - /> - )} - - {watchedBackend === "nfs" && ( - <> - ( - - Server - - - - NFS server IP address or hostname. - - - )} - /> - ( - - Export Path - - - - Path to the NFS export on the server. - - - )} - /> - ( - - Port - - field.onChange(parseInt(e.target.value, 10) || undefined)} - /> - - NFS server port (default: 2049). - - - )} - /> - ( - - Version - - NFS protocol version to use. - - - )} - /> - ( - - Read-only Mode - -
- field.onChange(e.target.checked)} - className="rounded border-gray-300" - /> - Mount volume as read-only -
-
- - Prevent any modifications to the volume. Recommended for backup sources and sensitive data. - - -
- )} - /> - - )} - - {watchedBackend === "webdav" && ( - <> - ( - - Server - - - - WebDAV server hostname or IP address. - - - )} - /> - ( - - Path - - - - Path to the WebDAV directory on the server. - - - )} - /> - ( - - Username (Optional) - - - - Username for WebDAV authentication (optional). - - - )} - /> - ( - - Password (Optional) - - - - Password for WebDAV authentication (optional). - - - )} - /> - ( - - Port - - field.onChange(parseInt(e.target.value, 10) || undefined)} - /> - - WebDAV server port (default: 80 for HTTP, 443 for HTTPS). - - - )} - /> - ( - - Use SSL/HTTPS - -
- field.onChange(e.target.checked)} - className="rounded border-gray-300" - /> - Enable HTTPS for secure connections -
-
- Use HTTPS instead of HTTP for secure connections. - -
- )} - /> - ( - - Read-only Mode - -
- field.onChange(e.target.checked)} - className="rounded border-gray-300" - /> - Mount volume as read-only -
-
- - Prevent any modifications to the volume. Recommended for backup sources and sensitive data. - - -
- )} - /> - - )} - - {watchedBackend === "smb" && ( - <> - ( - - Server - - - - SMB server IP address or hostname. - - - )} - /> - ( - - Share - - - - SMB share name on the server. - - - )} - /> - ( - - Username - - - - Username for SMB authentication. - - - )} - /> - ( - - Password - - - - Password for SMB authentication. - - - )} - /> - ( - - SMB Version - - SMB protocol version to use (default: 3.0). - - - )} - /> - ( - - Domain (Optional) - - - - Domain or workgroup for authentication (optional). - - - )} - /> - ( - - Port - - field.onChange(parseInt(e.target.value, 10) || undefined)} - /> - - SMB server port (default: 445). - - - )} - /> - ( - - Read-only Mode - -
- field.onChange(e.target.checked)} - className="rounded border-gray-300" - /> - Mount volume as read-only -
-
- - Prevent any modifications to the volume. Recommended for backup sources and sensitive data. - - -
- )} - /> - - )} - - {watchedBackend === "rclone" && - (!rcloneRemotes || rcloneRemotes.length === 0 ? ( - - -

No rclone remotes configured

-

- To use rclone, you need to configure remotes on your host system -

- - View rclone documentation - - -
-
- ) : ( - <> - ( - - Remote - - Select the rclone remote configured on your host system. - - - )} - /> - ( - - Path - - - - Path on the remote to mount. Use "/" for the root. - - - )} - /> - ( - - Read-only Mode - -
- field.onChange(e.target.checked)} - className="rounded border-gray-300" - /> - Mount volume as read-only -
-
- - Prevent any modifications to the volume. Recommended for backup sources and sensitive data. - - -
- )} - /> - - ))} {watchedBackend && watchedBackend !== "directory" && watchedBackend !== "rclone" && ( -
-
- -
- {testMessage && ( -
- {testMessage.message} -
- )} -
- )} - {mode === "update" && ( - - )} - - - ); -}; diff --git a/app/client/modules/volumes/components/create-volume-form.tsx b/app/client/modules/volumes/components/create-volume-form.tsx new file mode 100644 index 00000000..8c66151e --- /dev/null +++ b/app/client/modules/volumes/components/create-volume-form.tsx @@ -0,0 +1,214 @@ +import { arktypeResolver } from "@hookform/resolvers/arktype"; +import { useMutation } from "@tanstack/react-query"; +import { type } from "arktype"; +import { CheckCircle, Loader2, Plug, Save, XCircle } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { cn, slugify } from "~/client/lib/utils"; +import { deepClean } from "~/utils/object"; +import { Button } from "../../../components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../../../components/ui/form"; +import { Input } from "../../../components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select"; +import { volumeConfigSchema } from "~/schemas/volumes"; +import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip"; +import { useSystemInfo } from "~/client/hooks/use-system-info"; +import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm } from "./volume-forms"; + +export const formSchema = type({ + name: "2<=string<=32", +}).and(volumeConfigSchema); +const cleanSchema = type.pipe((d) => formSchema(deepClean(d))); + +export type FormValues = typeof formSchema.inferIn; + +type Props = { + onSubmit: (values: FormValues) => void; + mode?: "create" | "update"; + initialValues?: Partial; + formId?: string; + loading?: boolean; + className?: string; +}; + +const defaultValuesForType = { + directory: { backend: "directory" as const, path: "/" }, + nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const }, + smb: { backend: "smb" as const, port: 445, vers: "3.0" as const }, + webdav: { backend: "webdav" as const, port: 80, ssl: false }, + rclone: { backend: "rclone" as const, remote: "", path: "" }, +}; + +export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => { + const form = useForm({ + resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema), + defaultValues: initialValues, + resetOptions: { + keepDefaultValues: true, + keepDirtyValues: false, + }, + }); + + const { watch, getValues } = form; + + const { capabilities } = useSystemInfo(); + + const watchedBackend = watch("backend"); + + useEffect(() => { + if (mode === "create") { + form.reset({ + name: form.getValues().name, + ...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType], + }); + } + }, [watchedBackend, form, mode]); + + const [testMessage, setTestMessage] = useState<{ success: boolean; message: string } | null>(null); + + const testBackendConnection = useMutation({ + ...testConnectionMutation(), + onMutate: () => { + setTestMessage(null); + }, + onError: (error) => { + setTestMessage({ + success: false, + message: error?.message || "Failed to test connection. Please try again.", + }); + }, + onSuccess: (data) => { + setTestMessage(data); + }, + }); + + const handleTestConnection = async () => { + const formValues = getValues(); + + if (formValues.backend === "nfs" || formValues.backend === "smb" || formValues.backend === "webdav") { + testBackendConnection.mutate({ + body: { config: formValues }, + }); + } + }; + + return ( +
+ + ( + + Name + + field.onChange(slugify(e.target.value))} + max={32} + min={1} + /> + + Unique identifier for the volume. + + + )} + /> + ( + + Backend + + Choose the storage backend for this volume. + + + )} + /> + {watchedBackend === "directory" && } + {watchedBackend === "nfs" && } + {watchedBackend === "webdav" && } + {watchedBackend === "smb" && } + {watchedBackend === "rclone" && } + {watchedBackend && watchedBackend !== "directory" && watchedBackend !== "rclone" && ( +
+
+ +
+ {testMessage && ( +
+ {testMessage.message} +
+ )} +
+ )} + {mode === "update" && ( + + )} + + + ); +}; diff --git a/app/client/modules/volumes/components/volume-forms/directory-form.tsx b/app/client/modules/volumes/components/volume-forms/directory-form.tsx new file mode 100644 index 00000000..4209c5f7 --- /dev/null +++ b/app/client/modules/volumes/components/volume-forms/directory-form.tsx @@ -0,0 +1,51 @@ +import { Pencil } from "lucide-react"; +import type { UseFormReturn } from "react-hook-form"; +import type { FormValues } from "../create-volume-form"; +import { DirectoryBrowser } from "../../../../components/directory-browser"; +import { Button } from "../../../../components/ui/button"; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../../../../components/ui/form"; + +type Props = { + form: UseFormReturn; +}; + +export const DirectoryForm = ({ form }: Props) => { + return ( + { + return ( + + Directory Path + + {field.value ? ( +
+
+
Selected path:
+
{field.value}
+
+ +
+ ) : ( + field.onChange(path)} selectedPath={field.value} /> + )} +
+ Browse and select a directory on the host filesystem to track. + +
+ ); + }} + /> + ); +}; diff --git a/app/client/modules/volumes/components/volume-forms/index.ts b/app/client/modules/volumes/components/volume-forms/index.ts new file mode 100644 index 00000000..cdb2f2a3 --- /dev/null +++ b/app/client/modules/volumes/components/volume-forms/index.ts @@ -0,0 +1,5 @@ +export { DirectoryForm } from "./directory-form"; +export { NFSForm } from "./nfs-form"; +export { SMBForm } from "./smb-form"; +export { WebDAVForm } from "./webdav-form"; +export { RcloneForm } from "./rclone-form"; diff --git a/app/client/modules/volumes/components/volume-forms/nfs-form.tsx b/app/client/modules/volumes/components/volume-forms/nfs-form.tsx new file mode 100644 index 00000000..e4ebb0a5 --- /dev/null +++ b/app/client/modules/volumes/components/volume-forms/nfs-form.tsx @@ -0,0 +1,120 @@ +import type { UseFormReturn } from "react-hook-form"; +import type { FormValues } from "../create-volume-form"; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../../../../components/ui/form"; +import { Input } from "../../../../components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../../components/ui/select"; + +type Props = { + form: UseFormReturn; +}; + +export const NFSForm = ({ form }: Props) => { + return ( + <> + ( + + Server + + + + NFS server IP address or hostname. + + + )} + /> + ( + + Export Path + + + + Path to the NFS export on the server. + + + )} + /> + ( + + Port + + field.onChange(parseInt(e.target.value, 10) || undefined)} + /> + + NFS server port (default: 2049). + + + )} + /> + ( + + Version + + NFS protocol version to use. + + + )} + /> + ( + + Read-only Mode + +
+ field.onChange(e.target.checked)} + className="rounded border-gray-300" + /> + Mount volume as read-only +
+
+ + Prevent any modifications to the volume. Recommended for backup sources and sensitive data. + + +
+ )} + /> + + ); +}; diff --git a/app/client/modules/volumes/components/volume-forms/rclone-form.tsx b/app/client/modules/volumes/components/volume-forms/rclone-form.tsx new file mode 100644 index 00000000..3ca157f5 --- /dev/null +++ b/app/client/modules/volumes/components/volume-forms/rclone-form.tsx @@ -0,0 +1,127 @@ +import { ExternalLink } from "lucide-react"; +import type { UseFormReturn } from "react-hook-form"; +import type { FormValues } from "../create-volume-form"; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../../../../components/ui/form"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../../components/ui/select"; +import { Alert, AlertDescription } from "../../../../components/ui/alert"; +import { Input } from "../../../../components/ui/input"; +import { listRcloneRemotesOptions } from "~/client/api-client/@tanstack/react-query.gen"; +import { useSystemInfo } from "~/client/hooks/use-system-info"; +import { useQuery } from "@tanstack/react-query"; + +type Props = { + form: UseFormReturn; +}; + +export const RcloneForm = ({ form }: Props) => { + const { capabilities } = useSystemInfo(); + + const { data: rcloneRemotes, isLoading: isLoadingRemotes } = useQuery({ + ...listRcloneRemotesOptions(), + enabled: capabilities.rclone, + }); + + if (!isLoadingRemotes && (!rcloneRemotes || rcloneRemotes.length === 0)) { + return ( + + +

No rclone remotes configured

+

+ To use rclone, you need to configure remotes on your host system +

+ + View rclone documentation + + +
+
+ ); + } + + return ( + <> + ( + + Remote + + Select the rclone remote configured on your host system. + + + )} + /> + ( + + Path + + + + Path on the remote to mount. Use "/" for the root. + + + )} + /> + ( + + Read-only Mode + +
+ field.onChange(e.target.checked)} + className="rounded border-gray-300" + /> + Mount volume as read-only +
+
+ + Prevent any modifications to the volume. Recommended for backup sources and sensitive data. + + +
+ )} + /> + + ); +}; diff --git a/app/client/modules/volumes/components/volume-forms/smb-form.tsx b/app/client/modules/volumes/components/volume-forms/smb-form.tsx new file mode 100644 index 00000000..86ed73de --- /dev/null +++ b/app/client/modules/volumes/components/volume-forms/smb-form.tsx @@ -0,0 +1,164 @@ +import type { UseFormReturn } from "react-hook-form"; +import type { FormValues } from "../create-volume-form"; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../../../../components/ui/form"; +import { Input } from "../../../../components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../../components/ui/select"; + +type Props = { + form: UseFormReturn; +}; + +export const SMBForm = ({ form }: Props) => { + return ( + <> + ( + + Server + + + + SMB server IP address or hostname. + + + )} + /> + ( + + Share + + + + SMB share name on the server. + + + )} + /> + ( + + Username + + + + Username for SMB authentication. + + + )} + /> + ( + + Password + + + + Password for SMB authentication. + + + )} + /> + ( + + SMB Version + + SMB protocol version to use (default: 3.0). + + + )} + /> + ( + + Domain (Optional) + + + + Domain or workgroup for authentication (optional). + + + )} + /> + ( + + Port + + field.onChange(parseInt(e.target.value, 10) || undefined)} + /> + + SMB server port (default: 445). + + + )} + /> + ( + + Read-only Mode + +
+ field.onChange(e.target.checked)} + className="rounded border-gray-300" + /> + Mount volume as read-only +
+
+ + Prevent any modifications to the volume. Recommended for backup sources and sensitive data. + + +
+ )} + /> + + ); +}; diff --git a/app/client/modules/volumes/components/volume-forms/webdav-form.tsx b/app/client/modules/volumes/components/volume-forms/webdav-form.tsx new file mode 100644 index 00000000..1a399ed7 --- /dev/null +++ b/app/client/modules/volumes/components/volume-forms/webdav-form.tsx @@ -0,0 +1,146 @@ +import type { UseFormReturn } from "react-hook-form"; +import type { FormValues } from "../create-volume-form"; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../../../../components/ui/form"; +import { Input } from "../../../../components/ui/input"; + +type Props = { + form: UseFormReturn; +}; + +export const WebDAVForm = ({ form }: Props) => { + return ( + <> + ( + + Server + + + + WebDAV server hostname or IP address. + + + )} + /> + ( + + Path + + + + Path to the WebDAV directory on the server. + + + )} + /> + ( + + Username (Optional) + + + + Username for WebDAV authentication (optional). + + + )} + /> + ( + + Password (Optional) + + + + Password for WebDAV authentication (optional). + + + )} + /> + ( + + Port + + field.onChange(parseInt(e.target.value, 10) || undefined)} + /> + + WebDAV server port (default: 80 for HTTP, 443 for HTTPS). + + + )} + /> + ( + + Use SSL/HTTPS + +
+ field.onChange(e.target.checked)} + className="rounded border-gray-300" + /> + Enable HTTPS for secure connections +
+
+ Use HTTPS instead of HTTP for secure connections. + +
+ )} + /> + ( + + Read-only Mode + +
+ field.onChange(e.target.checked)} + className="rounded border-gray-300" + /> + Mount volume as read-only +
+
+ + Prevent any modifications to the volume. Recommended for backup sources and sensitive data. + + +
+ )} + /> + + ); +}; diff --git a/app/client/modules/volumes/routes/create-volume.tsx b/app/client/modules/volumes/routes/create-volume.tsx index 35e8bbba..e2faf6a6 100644 --- a/app/client/modules/volumes/routes/create-volume.tsx +++ b/app/client/modules/volumes/routes/create-volume.tsx @@ -4,7 +4,7 @@ import { useId } from "react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; import { createVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen"; -import { CreateVolumeForm, type FormValues } from "~/client/components/create-volume-form"; +import { CreateVolumeForm, type FormValues } from "~/client/modules/volumes/components/create-volume-form"; import { Button } from "~/client/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card"; import { parseError } from "~/client/lib/errors"; diff --git a/app/client/modules/volumes/tabs/info.tsx b/app/client/modules/volumes/tabs/info.tsx index d311bcf9..79a9c200 100644 --- a/app/client/modules/volumes/tabs/info.tsx +++ b/app/client/modules/volumes/tabs/info.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; import { Check } from "lucide-react"; -import { CreateVolumeForm, type FormValues } from "~/client/components/create-volume-form"; +import { CreateVolumeForm, type FormValues } from "~/client/modules/volumes/components/create-volume-form"; import { AlertDialog, AlertDialogAction,