import React, { useState, useEffect, useRef, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { Area } from '../../entities/Area'; import { Project } from '../../entities/Project'; import ConfirmDialog from '../Shared/ConfirmDialog'; import DiscardChangesDialog from '../Shared/DiscardChangesDialog'; import { useToast } from '../Shared/ToastContext'; import TagInput from '../Tag/TagInput'; import PriorityDropdown from '../Shared/PriorityDropdown'; import AreaDropdown from '../Shared/AreaDropdown'; import DatePicker from '../Shared/DatePicker'; import ProjectStateDropdown from '../Shared/ProjectStateDropdown'; import { PriorityType } from '../../entities/Task'; import { useStore } from '../../store/useStore'; import { useTranslation } from 'react-i18next'; import { TagIcon, Squares2X2Icon, TrashIcon, CameraIcon, CalendarIcon, ExclamationTriangleIcon, PlayIcon, } from '@heroicons/react/24/outline'; import { getApiPath } from '../../config/paths'; interface ProjectModalProps { isOpen: boolean; onClose: () => void; onSave: (project: Project) => void; onDelete?: (projectUid: string) => Promise; project?: Project; areas: Area[]; } const ProjectModal: React.FC = ({ isOpen, onClose, onSave, onDelete, project, areas, }) => { const [modalJustOpened, setModalJustOpened] = useState(false); const [formData, setFormData] = useState( project || { name: '', description: '', area_id: null, state: 'idea', tags: [], priority: null, due_date_at: null, image_url: '', } ); const [tags, setTags] = useState( project?.tags?.map((tag) => tag.name) || [] ); const [imageFile, setImageFile] = useState(null); const [imagePreview, setImagePreview] = useState( project?.image_url || '' ); const [isUploading, setIsUploading] = useState(false); const [isSaving, setIsSaving] = useState(false); const { tagsStore } = useStore(); // Avoid calling getTags() during component initialization to prevent remounting const availableTags = tagsStore.tags; const { addNewTags } = tagsStore; const modalRef = useRef(null); const fileInputRef = useRef(null); const nameInputRef = useRef(null); const [isClosing, setIsClosing] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [showDiscardDialog, setShowDiscardDialog] = useState(false); const [error, setError] = useState(null); // Collapsible sections state const [expandedSections, setExpandedSections] = useState({ state: false, tags: false, area: false, image: false, priority: false, dueDate: false, }); const { showSuccessToast, showErrorToast } = useToast(); const { t } = useTranslation(); // Auto-focus on the name input when modal opens useEffect(() => { if (isOpen) { setTimeout(() => { nameInputRef.current?.focus(); }, 200); } }, [isOpen]); // Load tags only when user actually interacts with tag input to prevent refresh const handleTagInputFocus = () => { if (!tagsStore.hasLoaded && !tagsStore.isLoading) { tagsStore.loadTags(); } }; // Manage body scroll when modal is open useEffect(() => { if (isOpen) { document.body.style.overflow = 'hidden'; } else { document.body.style.overflow = 'unset'; } return () => { document.body.style.overflow = 'unset'; }; }, [isOpen]); useEffect(() => { if (project) { // Convert ISO date to YYYY-MM-DD format if needed let dueDateValue = project.due_date_at; if (dueDateValue && dueDateValue.includes('T')) { dueDateValue = dueDateValue.split('T')[0]; } setFormData({ ...project, tags: project.tags || [], due_date_at: dueDateValue || null, image_url: project.image_url || '', }); setTags(project.tags?.map((tag) => tag.name) || []); setImagePreview(project.image_url || ''); } else { setFormData({ name: '', description: '', area_id: null, state: 'idea', tags: [], priority: null, due_date_at: null, image_url: '', }); setTags([]); setImagePreview(''); } setImageFile(null); setError(null); }, [project]); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node; // Check if click is inside modal if (modalRef.current && modalRef.current.contains(target)) { return; } // Check if click is on priority dropdown (which is portaled to document.body) const clickedElement = target as Element; if ( clickedElement && clickedElement.closest && clickedElement.closest( '.fixed.z-50.bg-white, .fixed.z-50.bg-gray-700' ) ) { return; } handleClose(); }; if (isOpen && !modalJustOpened) { document.addEventListener('mousedown', handleClickOutside); } return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, [isOpen, modalJustOpened]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { // Don't show discard dialog if already showing a dialog if (showConfirmDialog || showDiscardDialog) { // Let the dialog handle its own Escape return; } event.preventDefault(); event.stopPropagation(); // Check for unsaved changes using ref to get current value if (hasUnsavedChangesRef.current()) { setShowDiscardDialog(true); } else { handleClose(); } } }; if (isOpen) { document.addEventListener('keydown', handleKeyDown); } return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [isOpen, showConfirmDialog, showDiscardDialog]); const handleChange = ( e: React.ChangeEvent< HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement > ) => { const target = e.target; const { name, type, value } = target; // Clear error when user starts typing in the name field if (name === 'name' && error) { setError(null); } if (type === 'checkbox') { if (target instanceof HTMLInputElement) { const checked = target.checked; setFormData((prev) => ({ ...prev, [name]: checked, })); } } else { // Handle empty date values by converting to null let processedValue: any = value; if (name === 'due_date_at' && value === '') { processedValue = null; } setFormData((prev) => ({ ...prev, [name]: processedValue, })); } }; const handleTagsChange = useCallback((newTags: string[]) => { setTags(newTags); setFormData((prev) => ({ ...prev, tags: newTags.map((name) => ({ name })), })); }, []); // Track when modal opens to prevent immediate backdrop clicks useEffect(() => { if (isOpen) { setModalJustOpened(true); const timer = setTimeout(() => { setModalJustOpened(false); }, 200); // Prevent backdrop clicks for 200ms after opening return () => clearTimeout(timer); } }, [isOpen]); const handleDueDateChange = (value: string) => { setFormData((prev) => ({ ...prev, due_date_at: value || null, })); }; const handleImageSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; // Simple client-side guard (10MB max) const maxSizeBytes = 10 * 1024 * 1024; if (file.size > maxSizeBytes) { setError( t( 'errors.projectImageTooLarge', 'Image is too large. Please choose a file under 10MB.' ) ); if (fileInputRef.current) { fileInputRef.current.value = ''; } return; } setImageFile(file); // Create preview const reader = new FileReader(); reader.onload = (ev) => { setImagePreview(ev.target?.result as string); }; reader.readAsDataURL(file); }; const handleImageUpload = async (): Promise => { if (!imageFile) return null; setIsUploading(true); try { const formData = new FormData(); formData.append('image', imageFile); const response = await fetch(getApiPath('upload/project-image'), { method: 'POST', credentials: 'include', body: formData, }); if (!response.ok) { let serverMessage = 'Failed to upload image'; try { const errData = await response.json(); if (errData?.error) serverMessage = errData.error; } catch { // ignore parse errors } throw new Error(serverMessage); } const result = await response.json(); if (result?.imageUrl) { return result.imageUrl; } throw new Error('Image URL missing from upload response'); } catch (error) { console.error('Error uploading image:', error); setError( t( 'errors.projectImageUpload', 'Failed to upload image. Please try a smaller file or a different format.' ) ); return null; } finally { setIsUploading(false); } }; const handleRemoveImage = () => { setImageFile(null); setImagePreview(''); setFormData((prev) => ({ ...prev, image_url: '', })); if (fileInputRef.current) { fileInputRef.current.value = ''; } }; const handleSubmit = async () => { // Validate required fields if (!formData.name.trim()) { setError( t('errors.projectNameRequired', 'Project name is required') ); return; } setIsSaving(true); try { // Add new tags to the global store const existingTagNames = availableTags.map((tag: any) => tag.name); const newTagNames = tags.filter( (tag) => !existingTagNames.includes(tag) ); if (newTagNames.length > 0) { addNewTags(newTagNames); } let imageUrl = formData.image_url; // Upload image if a new one was selected if (imageFile) { const uploadedImageUrl = await handleImageUpload(); if (uploadedImageUrl) { imageUrl = uploadedImageUrl; } else { setIsSaving(false); return; } } const projectData = { ...formData, image_url: imageUrl, tags: tags.map((name) => ({ name })), }; // Save the project and wait for it to complete await onSave(projectData); showSuccessToast( project ? 'Project updated successfully!' : 'Project created successfully!' ); handleClose(); } catch (error) { console.error('Error saving project:', error); setError(t('errors.projectSaveFailed', 'Failed to save project')); } finally { setIsSaving(false); } }; const handleDeleteClick = () => { setShowConfirmDialog(true); }; const handleDeleteConfirm = async () => { if (project && project.uid && onDelete) { try { await onDelete(project.uid); showSuccessToast(t('success.projectDeleted')); setShowConfirmDialog(false); handleClose(); } catch (error) { console.error('Error deleting project:', error); showErrorToast(t('errors.failedToDeleteProject')); } } }; // Check if there are unsaved changes const hasUnsavedChanges = () => { if (!project) { // New project - check if any field has been filled return ( formData.name.trim() !== '' || formData.description?.trim() !== '' || formData.area_id !== null || formData.state !== 'idea' || tags.length > 0 || formData.priority !== null || formData.due_date_at !== null || imageFile !== null || imagePreview !== '' ); } // Existing project - compare with original const formChanged = formData.name !== project.name || formData.description !== project.description || formData.area_id !== project.area_id || formData.state !== project.state || formData.priority !== project.priority || formData.due_date_at !== project.due_date_at || imageFile !== null; // Compare tags const originalTags = project.tags?.map((tag) => tag.name) || []; const tagsChanged = tags.length !== originalTags.length || tags.some((tag, index) => tag !== originalTags[index]); return formChanged || tagsChanged; }; // Use ref to store hasUnsavedChanges so it's always current in the event handler const hasUnsavedChangesRef = useRef(hasUnsavedChanges); useEffect(() => { hasUnsavedChangesRef.current = hasUnsavedChanges; }); const handleClose = () => { setIsClosing(true); setTimeout(() => { onClose(); setIsClosing(false); setShowDiscardDialog(false); }, 300); }; const handleDiscardChanges = () => { setShowDiscardDialog(false); handleClose(); }; const handleCancelDiscard = () => { setShowDiscardDialog(false); }; const toggleSection = useCallback( (section: keyof typeof expandedSections) => { setExpandedSections((prev) => { const newExpanded = { ...prev, [section]: !prev[section], }; // Auto-scroll to show the expanded section if (newExpanded[section]) { setTimeout(() => { // Try multiple selectors to find the scroll container const scrollContainer = modalRef.current?.querySelector( '.absolute.inset-0.overflow-y-auto' ) || modalRef.current?.querySelector( '[style*="overflow-y"]' ) || modalRef.current?.querySelector( '.overflow-y-auto' ) || document.querySelector( '.absolute.inset-0.overflow-y-auto' ); if (scrollContainer) { scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth', }); } }, 250); // Increased delay to ensure DOM is updated } return newExpanded; }); }, [] ); if (!isOpen) return null; // Don't render if areas aren't loaded yet (prevents race condition) if (!areas || !Array.isArray(areas)) return null; return createPortal( <>
{ // Close modal when clicking on backdrop, but not on the modal content // Use mousedown instead of onClick to prevent issues with text selection dragging // Also prevent immediate closes after modal opens if (e.target === e.currentTarget && !modalJustOpened) { handleClose(); } }} >
{/* Main Form Section */}
{ e.preventDefault(); handleSubmit(); }} >
{/* Project Title Section - Always Visible */}
{ if (e.key === 'Enter') { e.preventDefault(); handleSubmit(); } }} required className={`block w-full text-xl font-semibold bg-transparent text-black dark:text-white border-none focus:outline-none py-2`} placeholder={t( 'project.name', 'Enter project name' )} data-testid="project-name-input" /> {error && (
{error}
)}
{/* Description Section - Always Visible */}