import React, { useState, useEffect, useRef, useCallback, useMemo, } from 'react'; import { Note } from '../../entities/Note'; import { Project } from '../../entities/Project'; import { useToast } from '../Shared/ToastContext'; import TagInput from '../Tag/TagInput'; import MarkdownRenderer from '../Shared/MarkdownRenderer'; import { Tag } from '../../entities/Tag'; import { useStore } from '../../store/useStore'; import { useTranslation } from 'react-i18next'; import ProjectDropdown from '../Shared/ProjectDropdown'; import ConfirmDialog from '../Shared/ConfirmDialog'; import DiscardChangesDialog from '../Shared/DiscardChangesDialog'; import { EyeIcon, PencilIcon, FolderIcon, TagIcon, TrashIcon, } from '@heroicons/react/24/outline'; interface NoteModalProps { isOpen: boolean; onClose: () => void; note?: Note | null; onSave: (noteData: Note) => Promise; onDelete?: (noteUid: string) => Promise; projects?: Project[]; onCreateProject?: (name: string) => Promise; } const NoteModal: React.FC = ({ isOpen, onClose, note, onSave, onDelete, projects = [], onCreateProject, }) => { const { t } = useTranslation(); const { tagsStore } = useStore(); const availableTagsStore = tagsStore.getTags(); const { addNewTags } = tagsStore; const [formData, setFormData] = useState( note || { title: '', content: '', tags: [], } ); const [tags, setTags] = useState( (note?.tags || note?.Tags)?.map((tag) => tag.name) || [] ); const [error, setError] = useState(null); const modalRef = useRef(null); const titleInputRef = useRef(null); const [isSubmitting, setIsSubmitting] = useState(false); const [isClosing, setIsClosing] = useState(false); const [activeTab, setActiveTab] = useState<'edit' | 'preview'>('edit'); const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [showDiscardDialog, setShowDiscardDialog] = useState(false); // Project-related state const [filteredProjects, setFilteredProjects] = useState([]); const [newProjectName, setNewProjectName] = useState(''); const [isCreatingProject, setIsCreatingProject] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false); // Collapsible sections state const [expandedSections, setExpandedSections] = useState({ tags: false, project: false, }); const { showSuccessToast, showErrorToast } = useToast(); // Memoize projects to prevent infinite re-renders const memoizedProjects = useMemo(() => projects || [], [projects]); useEffect(() => { if (!isOpen) return; // Auto-focus on the title input when modal opens setTimeout(() => { titleInputRef.current?.focus(); }, 100); }, [isOpen]); // Initialize filtered projects from props - like TaskModal useEffect(() => { setFilteredProjects(memoizedProjects); }, [memoizedProjects]); // Initialize form data when modal opens - exactly like TaskModal useEffect(() => { if (isOpen) { if (note) { setFormData({ ...note, project_id: note?.project_id || note?.project?.id || note?.Project?.id, }); const tagNames = (note?.tags || note?.Tags)?.map((tag) => tag.name) || []; setTags(tagNames); setError(null); // Initialize project name from note - exactly like TaskModal const projectIdToFind = note?.project?.id || note?.Project?.id || note?.project_id; const currentProject = memoizedProjects.find( (project) => project.id === projectIdToFind ); setNewProjectName(currentProject ? currentProject.name : ''); // Auto-expand sections if they have content from existing note, editing existing note, or creating new note with pre-filled project const shouldExpandTags = tagNames.length > 0 || !!note.uid; // Expand if has tags OR editing existing note const shouldExpandProject = !!currentProject || !!note.uid || !!projectIdToFind; // Expand if has project OR editing existing note OR has project_id setExpandedSections({ tags: shouldExpandTags, project: shouldExpandProject, }); } else { // Reset for new note setFormData({ title: '', content: '', tags: [], }); setTags([]); setNewProjectName(''); setError(null); setExpandedSections({ tags: false, project: false, }); } } }, [isOpen, note, memoizedProjects]); // Tags are now loaded automatically via getTags() when needed // Check if there are unsaved changes const hasUnsavedChanges = () => { if (!note) { // New note - check if any field has been filled return ( formData.title.trim() !== '' || formData.content?.trim() !== '' || tags.length > 0 || formData.project_id !== null ); } // Existing note - compare with original const formChanged = formData.title !== note.title || formData.content !== note.content || formData.project_id !== note.project_id; // Compare tags const originalTags = (note.tags || note.Tags)?.map((tag) => tag.name) || []; const tagsChanged = tags.length !== originalTags.length || tags.some((tag, index) => tag !== originalTags[index]); return formChanged || tagsChanged; }; const handleClose = useCallback(() => { setIsClosing(true); setTimeout(() => { onClose(); setIsClosing(false); setShowDiscardDialog(false); // Reset expanded sections when closing setExpandedSections({ tags: false, project: false, }); }, 300); }, [onClose]); const handleDiscardChanges = () => { setShowDiscardDialog(false); handleClose(); }; const handleCancelDiscard = () => { setShowDiscardDialog(false); }; useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( modalRef.current && !modalRef.current.contains(event.target as Node) ) { handleClose(); } }; if (isOpen) { // Add a small delay to prevent the opening click from closing the modal const timer = setTimeout(() => { document.addEventListener('mousedown', handleClickOutside); }, 100); return () => { clearTimeout(timer); document.removeEventListener('mousedown', handleClickOutside); }; } return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, [isOpen, handleClose]); // Use ref to store hasUnsavedChanges so it's always current in the event handler const hasUnsavedChangesRef = useRef(hasUnsavedChanges); useEffect(() => { hasUnsavedChangesRef.current = hasUnsavedChanges; }); 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, handleClose, showConfirmDialog, showDiscardDialog]); // Handle body scroll when modal opens/closes useEffect(() => { if (isOpen) { // Disable body scroll when modal is open document.body.style.overflow = 'hidden'; } else { // Re-enable body scroll when modal is closed document.body.style.overflow = 'unset'; } return () => { // Clean up: re-enable body scroll document.body.style.overflow = 'unset'; }; }, [isOpen]); const handleChange = ( e: React.ChangeEvent ) => { const { name, value } = e.target; setFormData((prev) => ({ ...prev, [name]: value, })); }; const handleTagsChange = useCallback((newTags: string[]) => { setTags(newTags); setFormData((prev) => ({ ...prev, tags: newTags.map((name) => ({ name })), })); }, []); 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(() => { const scrollContainer = document.querySelector( '.absolute.inset-0.overflow-y-auto' ); if (scrollContainer) { scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth', }); } }, 100); // Small delay to ensure DOM is updated } return newExpanded; }); }, [] ); const handleProjectSearch = (query: string) => { setNewProjectName(query); setDropdownOpen(true); if (!projects || projects.length === 0) { setFilteredProjects([]); return; } const searchQuery = query.toLowerCase(); const filtered = projects.filter((project) => project.name.toLowerCase().includes(searchQuery) ); setFilteredProjects(filtered); // If the user clears the project name, also clear the project_id in form data if (query.trim() === '') { setFormData((prev) => ({ ...prev, project_id: null })); } }; const handleProjectSelection = (project: Project) => { setFormData((prev) => ({ ...prev, project: { id: project.id!, name: project.name, uid: project.uid }, project_uid: project.uid, })); setNewProjectName(project.name); setDropdownOpen(false); }; const handleCreateProject = async (name: string) => { if (name.trim() !== '' && onCreateProject) { setIsCreatingProject(true); try { const newProject = await onCreateProject(name.trim()); setFormData((prev) => ({ ...prev, project: { id: newProject.id!, name: newProject.name, uid: newProject.uid, }, project_uid: newProject.uid, })); setFilteredProjects([...filteredProjects, newProject]); setNewProjectName(newProject.name); setDropdownOpen(false); showSuccessToast(t('success.projectCreated')); } catch (error) { showErrorToast(t('errors.projectCreationFailed')); console.error('Error creating project:', error); } finally { setIsCreatingProject(false); } } }; const handleShowAllProjects = () => { setNewProjectName(''); setFilteredProjects(memoizedProjects); setDropdownOpen(!dropdownOpen); }; const handleSubmit = async () => { if (!formData.title.trim()) { setError(t('errors.noteTitleRequired')); return; } setIsSubmitting(true); setError(null); try { // Add new tags to the global store const existingTagNames = availableTagsStore.map( (tag: any) => tag.name ); const newTagNames = tags.filter( (tag) => !existingTagNames.includes(tag) ); if (newTagNames.length > 0) { addNewTags(newTagNames); } // Convert string tags to tag objects const noteTags: Tag[] = tags.map((tagName) => ({ name: tagName })); // Create final form data with the tags // If project name is empty, clear the project_id const finalFormData = { ...formData, project_id: newProjectName.trim() === '' ? null : formData.project_id, tags: noteTags, }; await onSave(finalFormData); showSuccessToast( formData.id && formData.id !== 0 ? t('success.noteUpdated') : t('success.noteCreated') ); handleClose(); } catch (err) { setError((err as Error).message); showErrorToast(t('errors.failedToSaveNote')); } finally { setIsSubmitting(false); } }; const handleDeleteNote = async () => { if (formData.uid && onDelete) { try { await onDelete(formData.uid); showSuccessToast(t('success.noteDeleted')); handleClose(); } catch (err) { setError((err as Error).message); showErrorToast(t('errors.failedToDeleteNote')); } } }; if (!isOpen) return null; return ( <>
{ // Close modal when clicking on backdrop, but not on the modal content if (e.target === e.currentTarget) { handleClose(); } }} >
{/* Main Form Section */}
{ e.preventDefault(); handleSubmit(); }} >
{/* Note 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 shadow-sm py-2" placeholder={t( 'forms.noteTitlePlaceholder' )} autoComplete="off" data-testid="note-title-input" />
{/* Content Section - Always Visible */}
{activeTab === 'edit' ? (