refactor: migrate file upload modals to react-hook-form with zod validation schemas

This commit is contained in:
Yashwin 2026-05-13 10:15:35 +05:30
parent 40e43389df
commit c516ea18bc
2 changed files with 312 additions and 214 deletions

View File

@ -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<typeof fileUploadSchema>;
// ─────────────────────────────────────────────────────────────────────────────
// 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<string[]>([]);
const [description, setDescription] = useState("");
// ── Form configuration ──
const {
control,
handleSubmit,
setValue,
watch,
reset,
formState: { errors },
} = useForm<FileUploadFormData>({
resolver: zodResolver(fileUploadSchema) as any,
defaultValues: {
entity_type: defaultEntityType,
entity_id: defaultEntityId,
category: "",
tags: [],
description: "",
files: [],
},
});
// ── File state ──
const [files, setFiles] = useState<FileEntry[]>([]);
// 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<string | null>(null);
const [uploadSuccess, setUploadSuccess] = useState(false);
const [showCategoryDropdown, setShowCategoryDropdown] = useState(false);
const [categorySearch, setCategorySearch] = useState("");
// Ref for the hidden file input
const inputRef = useRef<HTMLInputElement>(null);
const categoryDropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// File entries with upload progress (internal state kept separate from zod files array for progress tracking)
const [fileEntries, setFileEntries] = useState<FileEntry[]>([]);
// ── 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<HTMLDivElement>) => {
@ -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
</SecondaryButton>
<PrimaryButton
onClick={handleUpload}
disabled={isUploading || files.length === 0 || validCount === 0}
onClick={handleSubmit(handleUpload as any)}
disabled={isUploading}
className="flex items-center gap-2"
>
{isUploading ? (
@ -375,8 +411,8 @@ export const FileUploadModal = ({
<div className="px-6 py-5 space-y-5">
{/* Drop Zone */}
<div>
<p className="text-xs font-semibold text-[#0e1b2a] mb-2 uppercase tracking-wide">Attach Files</p>
{files.length === 0 ? (
<p className="text-[13px] font-medium text-[#0e1b2a] mb-2">Attach Files <span className="text-[#e02424]">*</span></p>
{fileEntries.length === 0 ? (
<div
onDragOver={(e) => { 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 */}
<div className="divide-y divide-[rgba(0,0,0,0.06)]">
{files.map((entry) => (
{fileEntries.map((entry) => (
<div key={entry.id} className="flex items-center gap-3 px-4 py-2.5">
<div className="shrink-0">{getFileIcon(entry.file.type, entry.file.name)}</div>
<div className="flex-1 min-w-0">
@ -480,6 +516,11 @@ export const FileUploadModal = ({
</div>
</div>
)}
{errors.files && (
<p className="text-xs text-[#ef4444] mt-1.5 flex items-center gap-1">
<AlertCircle className="w-3 h-3" /> {errors.files.message}
</p>
)}
<p className="text-[11px] text-[#9aa6b2] mt-2">Up to {MAX_FILES} files allowed</p>
{uploadSuccess && (
@ -500,30 +541,43 @@ export const FileUploadModal = ({
{/* Fields: Entity Type + Entity ID */}
<div className="grid grid-cols-2 gap-4 pb-0">
<FormSelect
label="Entity Type"
required
value={entityType}
onValueChange={setEntityType}
disabled={!!defaultEntityType || isUploading}
options={ENTITY_TYPES.map((t) => ({
value: t,
label: t.charAt(0).toUpperCase() + t.slice(1),
}))}
placeholder="Select type"
<Controller
name="entity_type"
control={control}
render={({ field }) => (
<FormSelect
label="Entity Type"
required
value={field.value}
onValueChange={field.onChange}
disabled={!!defaultEntityType || isUploading}
options={ENTITY_TYPES.map((t) => ({
value: t,
label: t.charAt(0).toUpperCase() + t.slice(1),
}))}
placeholder="Select type"
error={errors.entity_type?.message}
/>
)}
/>
<div className="relative">
<FormField
label="Entity ID"
required
value={entityId}
onChange={(e) => setEntityId(e.target.value)}
disabled={!!defaultEntityId || isUploading}
placeholder="e.g. PRJ-1204"
<Controller
name="entity_id"
control={control}
render={({ field }) => (
<FormField
label="Entity ID"
required
{...field}
disabled={!!defaultEntityId || isUploading}
placeholder="e.g. PRJ-1204"
error={errors.entity_id?.message}
/>
)}
/>
{!defaultEntityId && !isUploading && isTenantAdmin && (
<button
onClick={() => setEntityId(generateUUID())}
onClick={() => setValue("entity_id", generateUUID(), { shouldValidate: true })}
title="Regenerate ID"
className="absolute right-3 top-[38px] p-1 text-[#9aa6b2] hover:text-[#084cc8] transition-colors"
type="button"
@ -536,39 +590,65 @@ export const FileUploadModal = ({
{/* Category Name (Editable Combobox) */}
<div className="relative pb-4" ref={categoryDropdownRef}>
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">
<label className="text-[13px] font-medium text-[#0e1b2a] block mb-1.5">
Category Name <span className="text-[#9aa6b2] font-normal">(Select or Enter New)</span>
</label>
<div className="relative">
<input
type="text"
value={categoryInput}
onChange={(e) => {
setCategoryInput(e.target.value);
setCategorySearch(e.target.value);
setShowCategoryDropdown(true);
}}
onFocus={() => setShowCategoryDropdown(true)}
disabled={isUploading}
placeholder="Type or select a category..."
className="w-full h-10 border border-[rgba(0,0,0,0.12)] rounded-lg pl-3 pr-9 text-sm text-[#0e1b2a] bg-white focus:outline-none focus:ring-2 focus:ring-[#084cc8]/20 focus:border-[#084cc8]"
<Controller
name="category"
control={control}
render={({ field }) => (
<Input
type="text"
{...field}
onChange={(e) => {
field.onChange(e.target.value);
setCategorySearch(e.target.value);
setShowCategoryDropdown(true);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (categorySearch.trim()) {
field.onChange(categorySearch.trim());
setShowCategoryDropdown(false);
}
}
}}
onFocus={() => setShowCategoryDropdown(true)}
disabled={isUploading}
placeholder="Type or select a category..."
className="pr-10"
/>
)}
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1.5 text-[#9aa6b2]">
<div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 text-[#9aa6b2]">
{categoryInput && (
<button
type="button"
onClick={() => { setCategoryInput(""); setCategorySearch(""); }}
className="p-1 hover:text-red-500"
onClick={() => { setValue("category", ""); setCategorySearch(""); }}
className="p-1.5 hover:text-red-500 transition-colors"
title="Clear input"
>
<X className="w-3 h-3" />
<X className="w-3.5 h-3.5" />
</button>
)}
<ChevronDownIcon className="w-3.5 h-3.5" />
<button
type="button"
onClick={() => setShowCategoryDropdown(!showCategoryDropdown)}
className="p-1.5 hover:text-[#0e1b2a] transition-colors"
title="Toggle dropdown"
>
<ChevronDownIcon className={cn("w-4 h-4 transition-transform duration-200", showCategoryDropdown && "rotate-180")} />
</button>
</div>
</div>
{errors.category && (
<p className="text-xs text-[#ef4444] mt-1">{errors.category.message}</p>
)}
{showCategoryDropdown && !isUploading && (
<div className="absolute top-full left-0 right-0 z-[210] mt-1 bg-white border border-[rgba(0,0,0,0.1)] shadow-xl rounded-xl py-1 max-h-[200px] overflow-y-auto custom-scrollbar">
<div className="absolute top-[calc(100%-12px)] left-0 right-0 z-[210] bg-white border border-[rgba(0,0,0,0.12)] shadow-xl rounded-lg py-1.5 mt-1 max-h-[220px] overflow-y-auto custom-scrollbar animate-in fade-in zoom-in-95 duration-200">
{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 && <CheckCircle2 className="w-3.5 h-3.5" />}
</button>
))
) : (
<div className="px-4 py-2 text-xs text-[#9aa6b2] italic">
{categorySearch ? `Press enter to use "${categorySearch}"` : "No categories found"}
<div className="px-4 py-3 text-center">
<p className="text-sm text-[#0e1b2a] font-medium">
{categorySearch ? `Use "${categorySearch}"` : "No categories found"}
</p>
<p className="text-[11px] text-[#9aa6b2] mt-0.5">
{categorySearch ? "Press Enter to create this as a new category" : "Start typing to create a new one"}
</p>
</div>
)}
</div>
@ -597,50 +687,36 @@ export const FileUploadModal = ({
{/* Tags */}
<div className="pb-4">
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">Tags</label>
<div className="flex flex-wrap items-center gap-1.5 min-h-10 border border-[rgba(0,0,0,0.12)] rounded-lg px-2 py-1.5 focus-within:ring-2 focus-within:ring-[#084cc8]/20 focus-within:border-[#084cc8]">
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 bg-gray-100 text-[#0e1b2a] text-xs font-medium rounded-md px-2 py-0.5"
>
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
disabled={isUploading}
className="text-[#9aa6b2] hover:text-red-500"
>
<X className="w-3 h-3" />
</button>
</span>
))}
<input
type="text"
value={tagInput}
onChange={(e) => 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]"
/>
</div>
<Controller
name="tags"
control={control}
render={({ field }) => (
<FormTagInput
label="Tags"
value={field.value || []}
onChange={field.onChange}
error={errors.tags?.message}
placeholder="Add a tag..."
/>
)}
/>
</div>
{/* Description */}
<FormTextArea
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={isUploading}
maxLength={500}
rows={3}
placeholder="Description of this file..."
<Controller
name="description"
control={control}
render={({ field }) => (
<FormTextArea
label="Description"
{...field}
disabled={isUploading}
maxLength={500}
rows={3}
placeholder="Description of this file..."
error={errors.description?.message}
/>
)}
/>
{/* Upload error */}

View File

@ -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<typeof fileVersionSchema>;
export const FileVersionUploadModal = ({
isOpen,
onClose,
file,
onUploaded,
}: FileVersionUploadModalProps): ReactElement | null => {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [description, setDescription] = useState("");
const [tagInput, setTagInput] = useState("");
const [tags, setTags] = useState<string[]>(file.tags || []);
// ── Form configuration ──
const {
control,
handleSubmit,
setValue,
watch,
reset,
formState: { errors },
} = useForm<FileVersionFormData>({
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<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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
</SecondaryButton>
<PrimaryButton
onClick={handleUpload}
disabled={isUploading || !selectedFile}
onClick={handleSubmit(handleUpload as any)}
disabled={isUploading}
className="flex items-center gap-2"
>
{isUploading ? (
@ -138,7 +171,7 @@ export const FileVersionUploadModal = ({
>
<div className="px-6 py-5 space-y-5">
<div>
<p className="text-xs font-semibold text-[#0e1b2a] mb-2 uppercase tracking-wide">Select New File</p>
<p className="text-[13px] font-medium text-[#0e1b2a] mb-2">Select New File <span className="text-[#e02424]">*</span></p>
<div
onDragOver={(e) => { 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 = ({
<p className="text-xs text-[#9aa6b2] mt-0.5">
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
</p>
<button className="text-xs font-semibold text-[#084cc8] mt-2 underline">
<button type="button" className="text-xs font-semibold text-[#084cc8] mt-2 underline">
Change file
</button>
</div>
@ -183,6 +216,11 @@ export const FileVersionUploadModal = ({
</>
)}
</div>
{errors.file && (
<p className="text-xs text-[#ef4444] mt-1.5 flex items-center gap-1">
<AlertCircle className="w-3 h-3" /> {errors.file.message as string}
</p>
)}
<input
ref={inputRef}
type="file"
@ -192,51 +230,35 @@ export const FileVersionUploadModal = ({
</div>
<div>
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">Tags</label>
<div className="flex flex-wrap items-center gap-1.5 min-h-10 border border-[rgba(0,0,0,0.12)] rounded-lg px-2 py-1.5 focus-within:ring-2 focus-within:ring-[#084cc8]/20 focus-within:border-[#084cc8]">
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 bg-gray-100 text-[#0e1b2a] text-xs font-medium rounded-md px-2 py-0.5"
>
{tag}
<button
type="button"
onClick={() => setTags((prev) => prev.filter((x) => x !== tag))}
disabled={isUploading}
className="text-[#9aa6b2] hover:text-red-500"
>
<X className="w-3 h-3" />
</button>
</span>
))}
<input
type="text"
value={tagInput}
onChange={(e) => 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"
/>
</div>
<Controller
name="tags"
control={control}
render={({ field }) => (
<FormTagInput
label="Tags"
value={field.value || []}
onChange={field.onChange}
error={errors.tags?.message}
placeholder="Add a tag..."
/>
)}
/>
</div>
<FormTextArea
label="Version Notes / Changes"
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={isUploading}
maxLength={500}
rows={3}
placeholder="What's new in this version?"
<Controller
name="description"
control={control}
render={({ field }) => (
<FormTextArea
label="Version Notes / Changes"
{...field}
disabled={isUploading}
maxLength={500}
rows={3}
placeholder="What's new in this version?"
error={errors.description?.message}
/>
)}
/>
{uploadError && (