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,
|
FormField,
|
||||||
FormSelect,
|
FormSelect,
|
||||||
FormTextArea,
|
FormTextArea,
|
||||||
|
FormTagInput,
|
||||||
} from "@/components/shared";
|
} 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, {
|
import fileAttachmentService, {
|
||||||
type CategoriesFilterOptions,
|
type CategoriesFilterOptions,
|
||||||
} from "@/services/file-attachment-service";
|
} from "@/services/file-attachment-service";
|
||||||
@ -135,6 +140,20 @@ const ENTITY_TYPES = [
|
|||||||
"other",
|
"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
|
// Component
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@ -147,32 +166,51 @@ export const FileUploadModal = ({
|
|||||||
categories = [],
|
categories = [],
|
||||||
isTenantAdmin = true,
|
isTenantAdmin = true,
|
||||||
}: FileUploadModalProps): ReactElement | null => {
|
}: FileUploadModalProps): ReactElement | null => {
|
||||||
// ── Form state ──
|
// ── Form configuration ──
|
||||||
const [entityType, setEntityType] = useState(defaultEntityType);
|
const {
|
||||||
const [entityId, setEntityId] = useState(defaultEntityId);
|
control,
|
||||||
const [categoryInput, setCategoryInput] = useState("");
|
handleSubmit,
|
||||||
const [tagInput, setTagInput] = useState("");
|
setValue,
|
||||||
const [tags, setTags] = useState<string[]>([]);
|
watch,
|
||||||
const [description, setDescription] = useState("");
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<FileUploadFormData>({
|
||||||
|
resolver: zodResolver(fileUploadSchema) as any,
|
||||||
|
defaultValues: {
|
||||||
|
entity_type: defaultEntityType,
|
||||||
|
entity_id: defaultEntityId,
|
||||||
|
category: "",
|
||||||
|
tags: [],
|
||||||
|
description: "",
|
||||||
|
files: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// ── File state ──
|
// Watch form values
|
||||||
const [files, setFiles] = useState<FileEntry[]>([]);
|
const entityId = watch("entity_id");
|
||||||
|
const categoryInput = watch("category");
|
||||||
|
|
||||||
|
// ── Local UI state ──
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
const [uploadSuccess, setUploadSuccess] = useState(false);
|
const [uploadSuccess, setUploadSuccess] = useState(false);
|
||||||
const [showCategoryDropdown, setShowCategoryDropdown] = useState(false);
|
const [showCategoryDropdown, setShowCategoryDropdown] = useState(false);
|
||||||
const [categorySearch, setCategorySearch] = useState("");
|
const [categorySearch, setCategorySearch] = useState("");
|
||||||
|
|
||||||
|
// Ref for the hidden file input
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const categoryDropdownRef = useRef<HTMLDivElement>(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 ──
|
// ── Auto-generate Entity ID ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && !entityId && !defaultEntityId) {
|
if (isOpen && !entityId && !defaultEntityId) {
|
||||||
setEntityId(generateUUID());
|
setValue("entity_id", generateUUID());
|
||||||
}
|
}
|
||||||
}, [isOpen, defaultEntityId]);
|
}, [isOpen, defaultEntityId, entityId, setValue]);
|
||||||
|
|
||||||
// ── Close dropdown on click outside ──
|
// ── Close dropdown on click outside ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -188,13 +226,15 @@ export const FileUploadModal = ({
|
|||||||
// ── Reset on close ──
|
// ── Reset on close ──
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
if (isUploading) return;
|
if (isUploading) return;
|
||||||
setFiles([]);
|
reset({
|
||||||
setEntityType(defaultEntityType);
|
entity_type: defaultEntityType,
|
||||||
setEntityId(defaultEntityId);
|
entity_id: defaultEntityId || (isOpen ? generateUUID() : ""),
|
||||||
setCategoryInput("");
|
category: "",
|
||||||
setTags([]);
|
tags: [],
|
||||||
setTagInput("");
|
description: "",
|
||||||
setDescription("");
|
files: [],
|
||||||
|
});
|
||||||
|
setFileEntries([]);
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
setUploadSuccess(false);
|
setUploadSuccess(false);
|
||||||
onClose();
|
onClose();
|
||||||
@ -212,11 +252,14 @@ export const FileUploadModal = ({
|
|||||||
error: isBlocked(file.name) ? "Blocked file type" : undefined,
|
error: isBlocked(file.name) ? "Blocked file type" : undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setFiles((prev) => {
|
setFileEntries((prev) => {
|
||||||
const combined = [...prev, ...newEntries];
|
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(
|
const onDrop = useCallback(
|
||||||
(e: DragEvent<HTMLDivElement>) => {
|
(e: DragEvent<HTMLDivElement>) => {
|
||||||
@ -234,52 +277,45 @@ export const FileUploadModal = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removeFile = (id: string) => {
|
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 ──
|
// ── Upload ──
|
||||||
const handleUpload = async () => {
|
const handleUpload = async (data: FileUploadFormData) => {
|
||||||
if (!entityType.trim()) { setUploadError("Entity Type is required"); return; }
|
const validFiles = fileEntries.filter((f) => f.status !== "blocked");
|
||||||
if (!entityId.trim()) { setUploadError("Entity ID is required"); return; }
|
|
||||||
|
|
||||||
const validFiles = files.filter((f) => f.status !== "blocked");
|
|
||||||
if (validFiles.length === 0) { setUploadError("No valid files to upload"); return; }
|
if (validFiles.length === 0) { setUploadError("No valid files to upload"); return; }
|
||||||
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
|
|
||||||
// Mark all valid as uploading
|
// Mark all valid as uploading
|
||||||
setFiles((prev) =>
|
setFileEntries((prev) =>
|
||||||
prev.map((f) => (f.status === "idle" ? { ...f, status: "uploading", progress: 10 } : f))
|
prev.map((f) => (f.status === "idle" ? { ...f, status: "uploading", progress: 10 } : f))
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Find matching category ID if it exists in the list
|
// Find matching category ID if it exists in the list
|
||||||
const matchedCategory = categories.find(
|
const matchedCategory = categories.find(
|
||||||
(c) => c.category.toLowerCase() === categoryInput.toLowerCase()
|
(c) => c.category.toLowerCase() === (data.category || "").toLowerCase()
|
||||||
);
|
);
|
||||||
|
|
||||||
if (validFiles.length === 1) {
|
if (validFiles.length === 1) {
|
||||||
// Single upload
|
// Single upload
|
||||||
await fileAttachmentService.upload({
|
await fileAttachmentService.upload({
|
||||||
files: [validFiles[0].file],
|
files: [validFiles[0].file],
|
||||||
entity_type: entityType,
|
entity_type: data.entity_type,
|
||||||
entity_id: entityId,
|
entity_id: data.entity_id,
|
||||||
category: matchedCategory ? matchedCategory.category : categoryInput,
|
category: matchedCategory ? matchedCategory.category : data.category,
|
||||||
category_id: matchedCategory ? matchedCategory.category_id || undefined : undefined,
|
category_id: matchedCategory ? matchedCategory.category_id || undefined : undefined,
|
||||||
description: description.trim() || undefined,
|
description: data.description?.trim() || undefined,
|
||||||
tags: tags.length ? tags : undefined,
|
tags: data.tags?.length ? data.tags : undefined,
|
||||||
});
|
});
|
||||||
setFiles((prev) =>
|
setFileEntries((prev) =>
|
||||||
prev.map((f) =>
|
prev.map((f) =>
|
||||||
f.id === validFiles[0].id ? { ...f, status: "done", progress: 100 } : f
|
f.id === validFiles[0].id ? { ...f, status: "done", progress: 100 } : f
|
||||||
)
|
)
|
||||||
@ -289,15 +325,15 @@ export const FileUploadModal = ({
|
|||||||
const result = await fileAttachmentService.uploadMultiple(
|
const result = await fileAttachmentService.uploadMultiple(
|
||||||
{
|
{
|
||||||
files: validFiles.map((f) => f.file),
|
files: validFiles.map((f) => f.file),
|
||||||
entity_type: entityType,
|
entity_type: data.entity_type,
|
||||||
entity_id: entityId,
|
entity_id: data.entity_id,
|
||||||
category: matchedCategory ? matchedCategory.category : categoryInput,
|
category: matchedCategory ? matchedCategory.category : data.category,
|
||||||
category_id: matchedCategory ? matchedCategory.category_id || undefined : undefined,
|
category_id: matchedCategory ? matchedCategory.category_id || undefined : undefined,
|
||||||
description: description.trim() || undefined,
|
description: data.description?.trim() || undefined,
|
||||||
tags: tags.length ? tags : undefined,
|
tags: data.tags?.length ? data.tags : undefined,
|
||||||
},
|
},
|
||||||
(_, percent) => {
|
(_, percent) => {
|
||||||
setFiles((prev) =>
|
setFileEntries((prev) =>
|
||||||
prev.map((f) =>
|
prev.map((f) =>
|
||||||
f.status === "uploading" ? { ...f, progress: Math.max(f.progress, percent) } : 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) => {
|
prev.map((f) => {
|
||||||
const errEntry = result.data.errors.find((e) => e.file === f.file.name);
|
const errEntry = result.data.errors.find((e) => e.file === f.file.name);
|
||||||
if (errEntry) return { ...f, status: "error", error: errEntry.error, progress: 0 };
|
if (errEntry) return { ...f, status: "error", error: errEntry.error, progress: 0 };
|
||||||
@ -320,7 +356,7 @@ export const FileUploadModal = ({
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const msg = err?.response?.data?.error?.message || err?.message || "Upload failed";
|
const msg = err?.response?.data?.error?.message || err?.message || "Upload failed";
|
||||||
setUploadError(msg);
|
setUploadError(msg);
|
||||||
setFiles((prev) =>
|
setFileEntries((prev) =>
|
||||||
prev.map((f) =>
|
prev.map((f) =>
|
||||||
f.status === "uploading" ? { ...f, status: "error", error: msg, progress: 0 } : f
|
f.status === "uploading" ? { ...f, status: "error", error: msg, progress: 0 } : f
|
||||||
)
|
)
|
||||||
@ -332,8 +368,8 @@ export const FileUploadModal = ({
|
|||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const validCount = files.filter((f) => f.status !== "blocked").length;
|
const validCount = fileEntries.filter((f) => f.status !== "blocked").length;
|
||||||
const doneCount = files.filter((f) => f.status === "done").length;
|
const doneCount = fileEntries.filter((f) => f.status === "done").length;
|
||||||
|
|
||||||
const footer = (
|
const footer = (
|
||||||
<>
|
<>
|
||||||
@ -344,8 +380,8 @@ export const FileUploadModal = ({
|
|||||||
Cancel
|
Cancel
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={handleUpload}
|
onClick={handleSubmit(handleUpload as any)}
|
||||||
disabled={isUploading || files.length === 0 || validCount === 0}
|
disabled={isUploading}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
{isUploading ? (
|
{isUploading ? (
|
||||||
@ -375,8 +411,8 @@ export const FileUploadModal = ({
|
|||||||
<div className="px-6 py-5 space-y-5">
|
<div className="px-6 py-5 space-y-5">
|
||||||
{/* Drop Zone */}
|
{/* Drop Zone */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold text-[#0e1b2a] mb-2 uppercase tracking-wide">Attach Files</p>
|
<p className="text-[13px] font-medium text-[#0e1b2a] mb-2">Attach Files <span className="text-[#e02424]">*</span></p>
|
||||||
{files.length === 0 ? (
|
{fileEntries.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
||||||
onDragLeave={() => setIsDragging(false)}
|
onDragLeave={() => setIsDragging(false)}
|
||||||
@ -384,7 +420,7 @@ export const FileUploadModal = ({
|
|||||||
onClick={() => inputRef.current?.click()}
|
onClick={() => inputRef.current?.click()}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-2 border-dashed rounded-xl flex flex-col items-center justify-center gap-3 py-8 cursor-pointer transition-all",
|
"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-[#084cc8] bg-[#084cc8]/5"
|
||||||
: "border-[rgba(0,0,0,0.12)] hover:border-[#084cc8]/50 hover:bg-gray-50"
|
: "border-[rgba(0,0,0,0.12)] hover:border-[#084cc8]/50 hover:bg-gray-50"
|
||||||
)}
|
)}
|
||||||
@ -407,7 +443,7 @@ export const FileUploadModal = ({
|
|||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-2 border-dashed rounded-xl transition-all",
|
"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 */}
|
{/* Add files button */}
|
||||||
@ -423,7 +459,7 @@ export const FileUploadModal = ({
|
|||||||
|
|
||||||
{/* File list */}
|
{/* File list */}
|
||||||
<div className="divide-y divide-[rgba(0,0,0,0.06)]">
|
<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 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="shrink-0">{getFileIcon(entry.file.type, entry.file.name)}</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@ -480,6 +516,11 @@ export const FileUploadModal = ({
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<p className="text-[11px] text-[#9aa6b2] mt-2">Up to {MAX_FILES} files allowed</p>
|
||||||
|
|
||||||
{uploadSuccess && (
|
{uploadSuccess && (
|
||||||
@ -500,30 +541,43 @@ export const FileUploadModal = ({
|
|||||||
|
|
||||||
{/* Fields: Entity Type + Entity ID */}
|
{/* Fields: Entity Type + Entity ID */}
|
||||||
<div className="grid grid-cols-2 gap-4 pb-0">
|
<div className="grid grid-cols-2 gap-4 pb-0">
|
||||||
<FormSelect
|
<Controller
|
||||||
label="Entity Type"
|
name="entity_type"
|
||||||
required
|
control={control}
|
||||||
value={entityType}
|
render={({ field }) => (
|
||||||
onValueChange={setEntityType}
|
<FormSelect
|
||||||
disabled={!!defaultEntityType || isUploading}
|
label="Entity Type"
|
||||||
options={ENTITY_TYPES.map((t) => ({
|
required
|
||||||
value: t,
|
value={field.value}
|
||||||
label: t.charAt(0).toUpperCase() + t.slice(1),
|
onValueChange={field.onChange}
|
||||||
}))}
|
disabled={!!defaultEntityType || isUploading}
|
||||||
placeholder="Select type"
|
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">
|
<div className="relative">
|
||||||
<FormField
|
<Controller
|
||||||
label="Entity ID"
|
name="entity_id"
|
||||||
required
|
control={control}
|
||||||
value={entityId}
|
render={({ field }) => (
|
||||||
onChange={(e) => setEntityId(e.target.value)}
|
<FormField
|
||||||
disabled={!!defaultEntityId || isUploading}
|
label="Entity ID"
|
||||||
placeholder="e.g. PRJ-1204"
|
required
|
||||||
|
{...field}
|
||||||
|
disabled={!!defaultEntityId || isUploading}
|
||||||
|
placeholder="e.g. PRJ-1204"
|
||||||
|
error={errors.entity_id?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
{!defaultEntityId && !isUploading && isTenantAdmin && (
|
{!defaultEntityId && !isUploading && isTenantAdmin && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setEntityId(generateUUID())}
|
onClick={() => setValue("entity_id", generateUUID(), { shouldValidate: true })}
|
||||||
title="Regenerate ID"
|
title="Regenerate ID"
|
||||||
className="absolute right-3 top-[38px] p-1 text-[#9aa6b2] hover:text-[#084cc8] transition-colors"
|
className="absolute right-3 top-[38px] p-1 text-[#9aa6b2] hover:text-[#084cc8] transition-colors"
|
||||||
type="button"
|
type="button"
|
||||||
@ -536,39 +590,65 @@ export const FileUploadModal = ({
|
|||||||
|
|
||||||
{/* Category Name (Editable Combobox) */}
|
{/* Category Name (Editable Combobox) */}
|
||||||
<div className="relative pb-4" ref={categoryDropdownRef}>
|
<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>
|
Category Name <span className="text-[#9aa6b2] font-normal">(Select or Enter New)</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<Controller
|
||||||
type="text"
|
name="category"
|
||||||
value={categoryInput}
|
control={control}
|
||||||
onChange={(e) => {
|
render={({ field }) => (
|
||||||
setCategoryInput(e.target.value);
|
<Input
|
||||||
setCategorySearch(e.target.value);
|
type="text"
|
||||||
setShowCategoryDropdown(true);
|
{...field}
|
||||||
}}
|
onChange={(e) => {
|
||||||
onFocus={() => setShowCategoryDropdown(true)}
|
field.onChange(e.target.value);
|
||||||
disabled={isUploading}
|
setCategorySearch(e.target.value);
|
||||||
placeholder="Type or select a category..."
|
setShowCategoryDropdown(true);
|
||||||
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]"
|
}}
|
||||||
|
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 && (
|
{categoryInput && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setCategoryInput(""); setCategorySearch(""); }}
|
onClick={() => { setValue("category", ""); setCategorySearch(""); }}
|
||||||
className="p-1 hover:text-red-500"
|
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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
{errors.category && (
|
||||||
|
<p className="text-xs text-[#ef4444] mt-1">{errors.category.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{showCategoryDropdown && !isUploading && (
|
{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())).length > 0 ? (
|
||||||
categories
|
categories
|
||||||
.filter(c => c.category.toLowerCase().includes(categorySearch.toLowerCase()))
|
.filter(c => c.category.toLowerCase().includes(categorySearch.toLowerCase()))
|
||||||
@ -577,18 +657,28 @@ export const FileUploadModal = ({
|
|||||||
key={cat.category_id ?? cat.category}
|
key={cat.category_id ?? cat.category}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCategoryInput(cat.category);
|
setValue("category", cat.category, { shouldValidate: true });
|
||||||
setShowCategoryDropdown(false);
|
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}
|
{cat.category}
|
||||||
{categoryInput === cat.category && <CheckCircle2 className="w-3.5 h-3.5" />}
|
{categoryInput === cat.category && <CheckCircle2 className="w-3.5 h-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="px-4 py-2 text-xs text-[#9aa6b2] italic">
|
<div className="px-4 py-3 text-center">
|
||||||
{categorySearch ? `Press enter to use "${categorySearch}"` : "No categories found"}
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -597,50 +687,36 @@ export const FileUploadModal = ({
|
|||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<div className="pb-4">
|
<div className="pb-4">
|
||||||
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">Tags</label>
|
<Controller
|
||||||
<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]">
|
name="tags"
|
||||||
{tags.map((tag) => (
|
control={control}
|
||||||
<span
|
render={({ field }) => (
|
||||||
key={tag}
|
<FormTagInput
|
||||||
className="inline-flex items-center gap-1 bg-gray-100 text-[#0e1b2a] text-xs font-medium rounded-md px-2 py-0.5"
|
label="Tags"
|
||||||
>
|
value={field.value || []}
|
||||||
{tag}
|
onChange={field.onChange}
|
||||||
<button
|
error={errors.tags?.message}
|
||||||
type="button"
|
placeholder="Add a tag..."
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<FormTextArea
|
<Controller
|
||||||
label="Description"
|
name="description"
|
||||||
value={description}
|
control={control}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
render={({ field }) => (
|
||||||
disabled={isUploading}
|
<FormTextArea
|
||||||
maxLength={500}
|
label="Description"
|
||||||
rows={3}
|
{...field}
|
||||||
placeholder="Description of this file..."
|
disabled={isUploading}
|
||||||
|
maxLength={500}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Description of this file..."
|
||||||
|
error={errors.description?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Upload error */}
|
{/* Upload error */}
|
||||||
|
|||||||
@ -11,9 +11,9 @@ import {
|
|||||||
type ChangeEvent,
|
type ChangeEvent,
|
||||||
type DragEvent,
|
type DragEvent,
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
|
useEffect,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
X,
|
|
||||||
Upload,
|
Upload,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Loader2,
|
Loader2,
|
||||||
@ -25,7 +25,11 @@ import {
|
|||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
SecondaryButton,
|
SecondaryButton,
|
||||||
FormTextArea,
|
FormTextArea,
|
||||||
|
FormTagInput,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
|
import { useForm, Controller } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
import fileAttachmentService, {
|
import fileAttachmentService, {
|
||||||
type FileAttachment,
|
type FileAttachment,
|
||||||
} from "@/services/file-attachment-service";
|
} from "@/services/file-attachment-service";
|
||||||
@ -37,29 +41,60 @@ interface FileVersionUploadModalProps {
|
|||||||
onUploaded?: (newFile: FileAttachment) => void;
|
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 = ({
|
export const FileVersionUploadModal = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
file,
|
file,
|
||||||
onUploaded,
|
onUploaded,
|
||||||
}: FileVersionUploadModalProps): ReactElement | null => {
|
}: FileVersionUploadModalProps): ReactElement | null => {
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
// ── Form configuration ──
|
||||||
const [description, setDescription] = useState("");
|
const {
|
||||||
const [tagInput, setTagInput] = useState("");
|
control,
|
||||||
const [tags, setTags] = useState<string[]>(file.tags || []);
|
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 [isUploading, setIsUploading] = useState(false);
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Sync tags if file prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setValue("tags", file.tags || []);
|
||||||
|
}
|
||||||
|
}, [isOpen, file.tags, setValue]);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
if (isUploading) return;
|
if (isUploading) return;
|
||||||
setSelectedFile(null);
|
reset({
|
||||||
setDescription("");
|
description: "",
|
||||||
setTags(file.tags || []);
|
tags: file.tags || [],
|
||||||
setTagInput("");
|
file: null,
|
||||||
|
});
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@ -69,26 +104,24 @@ export const FileVersionUploadModal = ({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
const dropped = e.dataTransfer.files[0];
|
const dropped = e.dataTransfer.files[0];
|
||||||
if (dropped) setSelectedFile(dropped);
|
if (dropped) setValue("file", dropped, { shouldValidate: true });
|
||||||
},
|
},
|
||||||
[]
|
[setValue]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
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 = "";
|
e.target.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpload = async () => {
|
const handleUpload = async (data: FileVersionFormData) => {
|
||||||
if (!selectedFile) return;
|
|
||||||
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fileAttachmentService.uploadVersion(file.id, selectedFile, {
|
const res = await fileAttachmentService.uploadVersion(file.id, data.file, {
|
||||||
description: description.trim() || undefined,
|
description: data.description?.trim() || undefined,
|
||||||
tags: tags.length ? tags : undefined,
|
tags: data.tags?.length ? data.tags : undefined,
|
||||||
category: file.category,
|
category: file.category,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -117,8 +150,8 @@ export const FileVersionUploadModal = ({
|
|||||||
Cancel
|
Cancel
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={handleUpload}
|
onClick={handleSubmit(handleUpload as any)}
|
||||||
disabled={isUploading || !selectedFile}
|
disabled={isUploading}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
{isUploading ? (
|
{isUploading ? (
|
||||||
@ -138,7 +171,7 @@ export const FileVersionUploadModal = ({
|
|||||||
>
|
>
|
||||||
<div className="px-6 py-5 space-y-5">
|
<div className="px-6 py-5 space-y-5">
|
||||||
<div>
|
<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
|
<div
|
||||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
||||||
onDragLeave={() => setIsDragging(false)}
|
onDragLeave={() => setIsDragging(false)}
|
||||||
@ -146,10 +179,10 @@ export const FileVersionUploadModal = ({
|
|||||||
onClick={() => inputRef.current?.click()}
|
onClick={() => inputRef.current?.click()}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-2 border-dashed rounded-xl flex flex-col items-center justify-center gap-3 py-8 cursor-pointer transition-all",
|
"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-[#084cc8] bg-[#084cc8]/5"
|
||||||
: "border-[rgba(0,0,0,0.12)] hover:border-[#084cc8]/50 hover:bg-gray-50",
|
: "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 ? (
|
{selectedFile ? (
|
||||||
@ -164,7 +197,7 @@ export const FileVersionUploadModal = ({
|
|||||||
<p className="text-xs text-[#9aa6b2] mt-0.5">
|
<p className="text-xs text-[#9aa6b2] mt-0.5">
|
||||||
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
|
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
|
||||||
</p>
|
</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
|
Change file
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -183,6 +216,11 @@ export const FileVersionUploadModal = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@ -192,51 +230,35 @@ export const FileVersionUploadModal = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">Tags</label>
|
<Controller
|
||||||
<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]">
|
name="tags"
|
||||||
{tags.map((tag) => (
|
control={control}
|
||||||
<span
|
render={({ field }) => (
|
||||||
key={tag}
|
<FormTagInput
|
||||||
className="inline-flex items-center gap-1 bg-gray-100 text-[#0e1b2a] text-xs font-medium rounded-md px-2 py-0.5"
|
label="Tags"
|
||||||
>
|
value={field.value || []}
|
||||||
{tag}
|
onChange={field.onChange}
|
||||||
<button
|
error={errors.tags?.message}
|
||||||
type="button"
|
placeholder="Add a tag..."
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormTextArea
|
<Controller
|
||||||
label="Version Notes / Changes"
|
name="description"
|
||||||
value={description}
|
control={control}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
render={({ field }) => (
|
||||||
disabled={isUploading}
|
<FormTextArea
|
||||||
maxLength={500}
|
label="Version Notes / Changes"
|
||||||
rows={3}
|
{...field}
|
||||||
placeholder="What's new in this version?"
|
disabled={isUploading}
|
||||||
|
maxLength={500}
|
||||||
|
rows={3}
|
||||||
|
placeholder="What's new in this version?"
|
||||||
|
error={errors.description?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{uploadError && (
|
{uploadError && (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user