refactor: migrate file upload modals to react-hook-form with zod validation schemas
This commit is contained in:
parent
40e43389df
commit
c516ea18bc
@ -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 */}
|
||||
|
||||
@ -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 && (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user