From c516ea18bcaea7d13d7ee6c9f08e72b8a84fc081 Mon Sep 17 00:00:00 2001 From: Yashwin Date: Wed, 13 May 2026 10:15:35 +0530 Subject: [PATCH] refactor: migrate file upload modals to react-hook-form with zod validation schemas --- src/components/shared/FileUploadModal.tsx | 366 +++++++++++------- .../shared/FileVersionUploadModal.tsx | 160 ++++---- 2 files changed, 312 insertions(+), 214 deletions(-) diff --git a/src/components/shared/FileUploadModal.tsx b/src/components/shared/FileUploadModal.tsx index 7d08fba..9842d2a 100644 --- a/src/components/shared/FileUploadModal.tsx +++ b/src/components/shared/FileUploadModal.tsx @@ -35,7 +35,12 @@ import { FormField, FormSelect, FormTextArea, + FormTagInput, } from "@/components/shared"; +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Input } from "@/components/ui/input"; import fileAttachmentService, { type CategoriesFilterOptions, } from "@/services/file-attachment-service"; @@ -135,6 +140,20 @@ const ENTITY_TYPES = [ "other", ]; +// ───────────────────────────────────────────────────────────────────────────── +// Validation Schema +// ───────────────────────────────────────────────────────────────────────────── +const fileUploadSchema = z.object({ + entity_type: z.string().min(1, "Entity Type is required"), + entity_id: z.string().min(1, "Entity ID is required"), + category: z.string().optional(), + tags: z.array(z.string()).default([]), + description: z.string().max(500, "Description must be at most 500 characters").optional(), + files: z.array(z.any()).min(1, "At least one file is required"), +}); + +type FileUploadFormData = z.infer; + // ───────────────────────────────────────────────────────────────────────────── // Component // ───────────────────────────────────────────────────────────────────────────── @@ -147,32 +166,51 @@ export const FileUploadModal = ({ categories = [], isTenantAdmin = true, }: FileUploadModalProps): ReactElement | null => { - // ── Form state ── - const [entityType, setEntityType] = useState(defaultEntityType); - const [entityId, setEntityId] = useState(defaultEntityId); - const [categoryInput, setCategoryInput] = useState(""); - const [tagInput, setTagInput] = useState(""); - const [tags, setTags] = useState([]); - const [description, setDescription] = useState(""); + // ── Form configuration ── + const { + control, + handleSubmit, + setValue, + watch, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(fileUploadSchema) as any, + defaultValues: { + entity_type: defaultEntityType, + entity_id: defaultEntityId, + category: "", + tags: [], + description: "", + files: [], + }, + }); - // ── File state ── - const [files, setFiles] = useState([]); + // Watch form values + const entityId = watch("entity_id"); + const categoryInput = watch("category"); + + // ── Local UI state ── const [isDragging, setIsDragging] = useState(false); const [isUploading, setIsUploading] = useState(false); const [uploadError, setUploadError] = useState(null); const [uploadSuccess, setUploadSuccess] = useState(false); const [showCategoryDropdown, setShowCategoryDropdown] = useState(false); const [categorySearch, setCategorySearch] = useState(""); + + // Ref for the hidden file input + const inputRef = useRef(null); const categoryDropdownRef = useRef(null); - const inputRef = useRef(null); + // File entries with upload progress (internal state kept separate from zod files array for progress tracking) + const [fileEntries, setFileEntries] = useState([]); // ── Auto-generate Entity ID ── useEffect(() => { if (isOpen && !entityId && !defaultEntityId) { - setEntityId(generateUUID()); + setValue("entity_id", generateUUID()); } - }, [isOpen, defaultEntityId]); + }, [isOpen, defaultEntityId, entityId, setValue]); // ── Close dropdown on click outside ── useEffect(() => { @@ -188,13 +226,15 @@ export const FileUploadModal = ({ // ── Reset on close ── const handleClose = () => { if (isUploading) return; - setFiles([]); - setEntityType(defaultEntityType); - setEntityId(defaultEntityId); - setCategoryInput(""); - setTags([]); - setTagInput(""); - setDescription(""); + reset({ + entity_type: defaultEntityType, + entity_id: defaultEntityId || (isOpen ? generateUUID() : ""), + category: "", + tags: [], + description: "", + files: [], + }); + setFileEntries([]); setUploadError(null); setUploadSuccess(false); onClose(); @@ -212,11 +252,14 @@ export const FileUploadModal = ({ error: isBlocked(file.name) ? "Blocked file type" : undefined, })); - setFiles((prev) => { + setFileEntries((prev) => { const combined = [...prev, ...newEntries]; - return combined.slice(0, MAX_FILES); + const limited = combined.slice(0, MAX_FILES); + // Sync with react-hook-form + setValue("files", limited.map(e => e.file), { shouldValidate: true }); + return limited; }); - }, []); + }, [setValue]); const onDrop = useCallback( (e: DragEvent) => { @@ -234,52 +277,45 @@ export const FileUploadModal = ({ }; const removeFile = (id: string) => { - setFiles((prev) => prev.filter((f) => f.id !== id)); + setFileEntries((prev) => { + const filtered = prev.filter((f) => f.id !== id); + // Sync with react-hook-form + setValue("files", filtered.map(e => e.file), { shouldValidate: true }); + return filtered; + }); }; - // ── Tags ── - const addTag = () => { - const t = tagInput.trim(); - if (t && !tags.includes(t)) setTags((prev) => [...prev, t]); - setTagInput(""); - }; - - const removeTag = (t: string) => setTags((prev) => prev.filter((x) => x !== t)); - // ── Upload ── - const handleUpload = async () => { - if (!entityType.trim()) { setUploadError("Entity Type is required"); return; } - if (!entityId.trim()) { setUploadError("Entity ID is required"); return; } - - const validFiles = files.filter((f) => f.status !== "blocked"); + const handleUpload = async (data: FileUploadFormData) => { + const validFiles = fileEntries.filter((f) => f.status !== "blocked"); if (validFiles.length === 0) { setUploadError("No valid files to upload"); return; } setIsUploading(true); setUploadError(null); // Mark all valid as uploading - setFiles((prev) => + setFileEntries((prev) => prev.map((f) => (f.status === "idle" ? { ...f, status: "uploading", progress: 10 } : f)) ); try { // Find matching category ID if it exists in the list const matchedCategory = categories.find( - (c) => c.category.toLowerCase() === categoryInput.toLowerCase() + (c) => c.category.toLowerCase() === (data.category || "").toLowerCase() ); if (validFiles.length === 1) { // Single upload await fileAttachmentService.upload({ files: [validFiles[0].file], - entity_type: entityType, - entity_id: entityId, - category: matchedCategory ? matchedCategory.category : categoryInput, + entity_type: data.entity_type, + entity_id: data.entity_id, + category: matchedCategory ? matchedCategory.category : data.category, category_id: matchedCategory ? matchedCategory.category_id || undefined : undefined, - description: description.trim() || undefined, - tags: tags.length ? tags : undefined, + description: data.description?.trim() || undefined, + tags: data.tags?.length ? data.tags : undefined, }); - setFiles((prev) => + setFileEntries((prev) => prev.map((f) => f.id === validFiles[0].id ? { ...f, status: "done", progress: 100 } : f ) @@ -289,15 +325,15 @@ export const FileUploadModal = ({ const result = await fileAttachmentService.uploadMultiple( { files: validFiles.map((f) => f.file), - entity_type: entityType, - entity_id: entityId, - category: matchedCategory ? matchedCategory.category : categoryInput, + entity_type: data.entity_type, + entity_id: data.entity_id, + category: matchedCategory ? matchedCategory.category : data.category, category_id: matchedCategory ? matchedCategory.category_id || undefined : undefined, - description: description.trim() || undefined, - tags: tags.length ? tags : undefined, + description: data.description?.trim() || undefined, + tags: data.tags?.length ? data.tags : undefined, }, (_, percent) => { - setFiles((prev) => + setFileEntries((prev) => prev.map((f) => f.status === "uploading" ? { ...f, progress: Math.max(f.progress, percent) } : f ) @@ -305,7 +341,7 @@ export const FileUploadModal = ({ } ); - setFiles((prev) => + setFileEntries((prev) => prev.map((f) => { const errEntry = result.data.errors.find((e) => e.file === f.file.name); if (errEntry) return { ...f, status: "error", error: errEntry.error, progress: 0 }; @@ -320,7 +356,7 @@ export const FileUploadModal = ({ } catch (err: any) { const msg = err?.response?.data?.error?.message || err?.message || "Upload failed"; setUploadError(msg); - setFiles((prev) => + setFileEntries((prev) => prev.map((f) => f.status === "uploading" ? { ...f, status: "error", error: msg, progress: 0 } : f ) @@ -332,8 +368,8 @@ export const FileUploadModal = ({ if (!isOpen) return null; - const validCount = files.filter((f) => f.status !== "blocked").length; - const doneCount = files.filter((f) => f.status === "done").length; + const validCount = fileEntries.filter((f) => f.status !== "blocked").length; + const doneCount = fileEntries.filter((f) => f.status === "done").length; const footer = ( <> @@ -344,8 +380,8 @@ export const FileUploadModal = ({ Cancel {isUploading ? ( @@ -375,8 +411,8 @@ export const FileUploadModal = ({
{/* Drop Zone */}
-

Attach Files

- {files.length === 0 ? ( +

Attach Files *

+ {fileEntries.length === 0 ? (
{ e.preventDefault(); setIsDragging(true); }} onDragLeave={() => setIsDragging(false)} @@ -384,7 +420,7 @@ export const FileUploadModal = ({ onClick={() => inputRef.current?.click()} className={cn( "border-2 border-dashed rounded-xl flex flex-col items-center justify-center gap-3 py-8 cursor-pointer transition-all", - isDragging + errors.files ? "border-[#ef4444] bg-[#ef4444]/5" : isDragging ? "border-[#084cc8] bg-[#084cc8]/5" : "border-[rgba(0,0,0,0.12)] hover:border-[#084cc8]/50 hover:bg-gray-50" )} @@ -407,7 +443,7 @@ export const FileUploadModal = ({ onDrop={onDrop} className={cn( "border-2 border-dashed rounded-xl transition-all", - isDragging ? "border-[#084cc8] bg-[#084cc8]/5" : "border-[rgba(0,0,0,0.08)]" + errors.files ? "border-[#ef4444] bg-[#ef4444]/5" : isDragging ? "border-[#084cc8] bg-[#084cc8]/5" : "border-[rgba(0,0,0,0.08)]" )} > {/* Add files button */} @@ -423,7 +459,7 @@ export const FileUploadModal = ({ {/* File list */}
- {files.map((entry) => ( + {fileEntries.map((entry) => (
{getFileIcon(entry.file.type, entry.file.name)}
@@ -480,6 +516,11 @@ export const FileUploadModal = ({
)} + {errors.files && ( +

+ {errors.files.message} +

+ )}

Up to {MAX_FILES} files allowed

{uploadSuccess && ( @@ -500,30 +541,43 @@ export const FileUploadModal = ({ {/* Fields: Entity Type + Entity ID */}
- ({ - value: t, - label: t.charAt(0).toUpperCase() + t.slice(1), - }))} - placeholder="Select type" + ( + ({ + value: t, + label: t.charAt(0).toUpperCase() + t.slice(1), + }))} + placeholder="Select type" + error={errors.entity_type?.message} + /> + )} />
- setEntityId(e.target.value)} - disabled={!!defaultEntityId || isUploading} - placeholder="e.g. PRJ-1204" + ( + + )} /> {!defaultEntityId && !isUploading && isTenantAdmin && ( )} - +
+ {errors.category && ( +

{errors.category.message}

+ )} {showCategoryDropdown && !isUploading && ( -
+
{categories.filter(c => c.category.toLowerCase().includes(categorySearch.toLowerCase())).length > 0 ? ( categories .filter(c => c.category.toLowerCase().includes(categorySearch.toLowerCase())) @@ -577,18 +657,28 @@ export const FileUploadModal = ({ key={cat.category_id ?? cat.category} type="button" onClick={() => { - setCategoryInput(cat.category); + setValue("category", cat.category, { shouldValidate: true }); setShowCategoryDropdown(false); }} - className="w-full text-left px-4 py-2 text-sm hover:bg-[#084cc8]/5 text-[#475569] hover:text-[#084cc8] transition-colors flex items-center justify-between" + className={cn( + "w-full text-left px-4 py-2.5 text-sm transition-colors flex items-center justify-between", + categoryInput === cat.category + ? "bg-[#084cc8]/5 text-[#084cc8] font-medium" + : "text-[#475569] hover:bg-gray-50 hover:text-[#0e1b2a]" + )} > {cat.category} {categoryInput === cat.category && } )) ) : ( -
- {categorySearch ? `Press enter to use "${categorySearch}"` : "No categories found"} +
+

+ {categorySearch ? `Use "${categorySearch}"` : "No categories found"} +

+

+ {categorySearch ? "Press Enter to create this as a new category" : "Start typing to create a new one"} +

)}
@@ -597,50 +687,36 @@ export const FileUploadModal = ({ {/* Tags */}
- -
- {tags.map((tag) => ( - - {tag} - - - ))} - setTagInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === ",") { - e.preventDefault(); - addTag(); - } - }} - placeholder={tags.length === 0 ? "Add a tag..." : ""} - disabled={isUploading} - className="flex-1 min-w-[80px] text-sm outline-none bg-transparent placeholder:text-[#c4cbd6]" - /> -
+ ( + + )} + />
{/* Description */} - setDescription(e.target.value)} - disabled={isUploading} - maxLength={500} - rows={3} - placeholder="Description of this file..." + ( + + )} /> {/* Upload error */} diff --git a/src/components/shared/FileVersionUploadModal.tsx b/src/components/shared/FileVersionUploadModal.tsx index 2e445b6..74abf6d 100644 --- a/src/components/shared/FileVersionUploadModal.tsx +++ b/src/components/shared/FileVersionUploadModal.tsx @@ -11,9 +11,9 @@ import { type ChangeEvent, type DragEvent, type ReactElement, + useEffect, } from "react"; import { - X, Upload, CheckCircle2, Loader2, @@ -25,7 +25,11 @@ import { PrimaryButton, SecondaryButton, FormTextArea, + FormTagInput, } from "@/components/shared"; +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; import fileAttachmentService, { type FileAttachment, } from "@/services/file-attachment-service"; @@ -37,29 +41,60 @@ interface FileVersionUploadModalProps { onUploaded?: (newFile: FileAttachment) => void; } +const fileVersionSchema = z.object({ + description: z.string().max(500, "Notes must be at most 500 characters").optional(), + tags: z.array(z.string()).default([]), + file: z.any().refine((file) => !!file, "New file is required"), +}); + +type FileVersionFormData = z.infer; + export const FileVersionUploadModal = ({ isOpen, onClose, file, onUploaded, }: FileVersionUploadModalProps): ReactElement | null => { - const [selectedFile, setSelectedFile] = useState(null); - const [description, setDescription] = useState(""); - const [tagInput, setTagInput] = useState(""); - const [tags, setTags] = useState(file.tags || []); - + // ── Form configuration ── + const { + control, + handleSubmit, + setValue, + watch, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(fileVersionSchema) as any, + defaultValues: { + description: "", + tags: file.tags || [], + file: null, + }, + }); + + const selectedFile = watch("file"); + + // ── Local UI state ── const [isUploading, setIsUploading] = useState(false); const [uploadError, setUploadError] = useState(null); const [isDragging, setIsDragging] = useState(false); - + const inputRef = useRef(null); + // Sync tags if file prop changes + useEffect(() => { + if (isOpen) { + setValue("tags", file.tags || []); + } + }, [isOpen, file.tags, setValue]); + const handleClose = () => { if (isUploading) return; - setSelectedFile(null); - setDescription(""); - setTags(file.tags || []); - setTagInput(""); + reset({ + description: "", + tags: file.tags || [], + file: null, + }); setUploadError(null); onClose(); }; @@ -69,26 +104,24 @@ export const FileVersionUploadModal = ({ e.preventDefault(); setIsDragging(false); const dropped = e.dataTransfer.files[0]; - if (dropped) setSelectedFile(dropped); + if (dropped) setValue("file", dropped, { shouldValidate: true }); }, - [] + [setValue] ); const onInputChange = (e: ChangeEvent) => { - if (e.target.files?.[0]) setSelectedFile(e.target.files[0]); + if (e.target.files?.[0]) setValue("file", e.target.files[0], { shouldValidate: true }); e.target.value = ""; }; - const handleUpload = async () => { - if (!selectedFile) return; - + const handleUpload = async (data: FileVersionFormData) => { setIsUploading(true); setUploadError(null); try { - const res = await fileAttachmentService.uploadVersion(file.id, selectedFile, { - description: description.trim() || undefined, - tags: tags.length ? tags : undefined, + const res = await fileAttachmentService.uploadVersion(file.id, data.file, { + description: data.description?.trim() || undefined, + tags: data.tags?.length ? data.tags : undefined, category: file.category, }); @@ -117,8 +150,8 @@ export const FileVersionUploadModal = ({ Cancel {isUploading ? ( @@ -138,7 +171,7 @@ export const FileVersionUploadModal = ({ >
-

Select New File

+

Select New File *

{ e.preventDefault(); setIsDragging(true); }} onDragLeave={() => setIsDragging(false)} @@ -146,10 +179,10 @@ export const FileVersionUploadModal = ({ onClick={() => inputRef.current?.click()} className={cn( "border-2 border-dashed rounded-xl flex flex-col items-center justify-center gap-3 py-8 cursor-pointer transition-all", - isDragging + errors.file ? "border-[#ef4444] bg-[#ef4444]/5" : isDragging ? "border-[#084cc8] bg-[#084cc8]/5" : "border-[rgba(0,0,0,0.12)] hover:border-[#084cc8]/50 hover:bg-gray-50", - selectedFile && "border-emerald-500/50 bg-emerald-50/10" + selectedFile && !errors.file && "border-emerald-500/50 bg-emerald-50/10" )} > {selectedFile ? ( @@ -164,7 +197,7 @@ export const FileVersionUploadModal = ({

{(selectedFile.size / 1024 / 1024).toFixed(2)} MB

-
@@ -183,6 +216,11 @@ export const FileVersionUploadModal = ({ )}
+ {errors.file && ( +

+ {errors.file.message as string} +

+ )}
- -
- {tags.map((tag) => ( - - {tag} - - - ))} - setTagInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === ",") { - e.preventDefault(); - const t = tagInput.trim(); - if (t && !tags.includes(t)) setTags((prev) => [...prev, t]); - setTagInput(""); - } - }} - placeholder={tags.length === 0 ? "Add a tag..." : ""} - disabled={isUploading} - className="flex-1 min-w-[80px] text-sm outline-none bg-transparent" - /> -
+ ( + + )} + />
- setDescription(e.target.value)} - disabled={isUploading} - maxLength={500} - rows={3} - placeholder="What's new in this version?" + ( + + )} /> {uploadError && (