import React, { useState, useEffect, useRef, useMemo, useCallback, } from 'react'; import { useParams, useNavigate, Link, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { TrashIcon, TagIcon, QueueListIcon, StarIcon, InformationCircleIcon, PencilSquareIcon, MagnifyingGlassIcon, CheckIcon, } from '@heroicons/react/24/outline'; import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid'; import { Task } from '../entities/Task'; import { Note } from '../entities/Note'; import { Project } from '../entities/Project'; import TaskList from './Task/TaskList'; import GroupedTaskList from './Task/GroupedTaskList'; import ProjectItem from './Project/ProjectItem'; import ConfirmDialog from './Shared/ConfirmDialog'; import { searchUniversal } from '../utils/searchService'; import { getApiPath } from '../config/paths'; import { SortOption } from './Shared/SortFilterButton'; import IconSortDropdown from './Shared/IconSortDropdown'; import { useStore } from '../store/useStore'; interface View { id: number; uid: string; name: string; search_query: string | null; filters: string[]; priority: string | null; due: string | null; defer: string | null; tags: string[]; extras: string[] | null; is_pinned: boolean; } const ViewDetail: React.FC = () => { const { t } = useTranslation(); const { uid } = useParams<{ uid: string }>(); const navigate = useNavigate(); const location = useLocation(); const [view, setView] = useState(null); const [tasks, setTasks] = useState([]); const [notes, setNotes] = useState([]); const [projects, setProjects] = useState([]); const globalProjects = useStore((state) => state.projectsStore.projects); const updateQueryParams = useCallback( (updates: Record) => { const params = new URLSearchParams(location.search); let shouldNavigate = false; Object.entries(updates).forEach(([key, value]) => { if (value === null || value === '') { if (params.has(key)) { params.delete(key); shouldNavigate = true; } } else if (params.get(key) !== value) { params.set(key, value); shouldNavigate = true; } }); if (shouldNavigate) { const searchString = params.toString(); navigate( { pathname: location.pathname, search: searchString ? `?${searchString}` : '', }, { replace: true } ); } }, [location.pathname, location.search, navigate] ); const [isLoading, setIsLoading] = useState(true); const [isEditingName, setIsEditingName] = useState(false); const [editedName, setEditedName] = useState(''); const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); const [showCriteriaDropdown, setShowCriteriaDropdown] = useState(false); // Search, filter, and sort state const [taskSearchQuery, setTaskSearchQuery] = useState(''); const [isSearchExpanded, setIsSearchExpanded] = useState(false); const [taskStatusFilter, setTaskStatusFilter] = useState< 'all' | 'active' | 'completed' >('active'); const [orderBy, setOrderBy] = useState('created_at:desc'); const [groupBy, setGroupBy] = useState<'none' | 'project'>('none'); // Pagination state const [offset, setOffset] = useState(0); const [hasMore, setHasMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); const [totalCount, setTotalCount] = useState(0); const limit = 20; // State for ProjectItem and Note components const [activeDropdown, setActiveDropdown] = useState(null); const [hoveredNoteId, setHoveredNoteId] = useState(null); const [, setProjectToDelete] = useState(null); // Ref for dropdown and title edit const criteriaDropdownRef = useRef(null); const titleInputRef = useRef(null); // Sort options for tasks const sortOptions: SortOption[] = [ { value: 'due_date:asc', label: t('sort.due_date', 'Due Date') }, { value: 'name:asc', label: t('sort.name', 'Name') }, { value: 'priority:desc', label: t('sort.priority', 'Priority') }, { value: 'status:desc', label: t('sort.status', 'Status') }, { value: 'created_at:desc', label: t('sort.created_at', 'Created At') }, ]; const projectLookupList = useMemo(() => { const map = new Map(); const addProject = (project?: Project | null) => { if (!project) return; const key = (project.uid && `uid-${project.uid}`) ?? (project.id !== undefined && project.id !== null ? `id-${project.id}` : undefined); if (!key) return; if (!map.has(key)) { map.set(key, project); } }; globalProjects.forEach(addProject); projects.forEach(addProject); return Array.from(map.values()); }, [globalProjects, projects]); const projectLookupMap = useMemo(() => { const byId = new Map(); const byUid = new Map(); projectLookupList.forEach((project) => { if (project.id !== undefined && project.id !== null) { const numericId = typeof project.id === 'string' ? Number(project.id) : project.id; if (!Number.isNaN(numericId)) { byId.set(numericId, project); } } if (project.uid) { byUid.set(project.uid, project); } }); return { byId, byUid }; }, [projectLookupList]); // Filter and sort tasks const displayTasks = useMemo(() => { let filteredTasks: Task[]; // Filter by completion status if (taskStatusFilter === 'completed') { filteredTasks = tasks.filter( (task: Task) => task.status === 'done' || task.status === 'archived' || task.status === 2 || task.status === 3 ); } else if (taskStatusFilter === 'active') { filteredTasks = tasks.filter( (task: Task) => task.status !== 'done' && task.status !== 'archived' && task.status !== 2 && task.status !== 3 ); } else { // taskStatusFilter === 'all' filteredTasks = tasks; } // Filter by search query if (taskSearchQuery.trim()) { const query = taskSearchQuery.toLowerCase(); filteredTasks = filteredTasks.filter( (task: Task) => task.name.toLowerCase().includes(query) || task.original_name?.toLowerCase().includes(query) || task.note?.toLowerCase().includes(query) ); } // Sort tasks const sortedTasks = [...filteredTasks].sort((a, b) => { const [field, direction] = orderBy.split(':'); const isAsc = direction === 'asc'; let valueA, valueB; switch (field) { case 'name': valueA = a.name?.toLowerCase() || ''; valueB = b.name?.toLowerCase() || ''; break; case 'due_date': valueA = a.due_date ? new Date(a.due_date).getTime() : 0; valueB = b.due_date ? new Date(b.due_date).getTime() : 0; break; case 'priority': { const priorityMap = { high: 2, medium: 1, low: 0 }; valueA = typeof a.priority === 'string' ? priorityMap[a.priority] || 0 : a.priority || 0; valueB = typeof b.priority === 'string' ? priorityMap[b.priority] || 0 : b.priority || 0; break; } case 'status': valueA = typeof a.status === 'string' ? a.status : a.status || 0; valueB = typeof b.status === 'string' ? b.status : b.status || 0; break; case 'created_at': default: valueA = a.created_at ? new Date(a.created_at).getTime() : 0; valueB = b.created_at ? new Date(b.created_at).getTime() : 0; break; } if (valueA < valueB) return isAsc ? -1 : 1; if (valueA > valueB) return isAsc ? 1 : -1; return 0; }); return sortedTasks.map((task) => { if (task.Project) { return task; } let matchedProject: Project | undefined; if ( task.project_id !== undefined && task.project_id !== null && typeof task.project_id !== 'string' ) { matchedProject = projectLookupMap.byId.get(task.project_id); } if (!matchedProject && task.Project?.uid) { matchedProject = projectLookupMap.byUid.get(task.Project.uid); } if (!matchedProject && typeof task.project_id === 'string') { const numericId = Number(task.project_id); if (!Number.isNaN(numericId)) { matchedProject = projectLookupMap.byId.get(numericId); } } return matchedProject ? { ...task, Project: matchedProject, } : task; }); }, [ tasks, taskStatusFilter, taskSearchQuery, orderBy, t, projectLookupMap, ]); const handleSortChange = useCallback( (newOrderBy: string, options?: { skipUrlUpdate?: boolean }) => { setOrderBy(newOrderBy); if (!options?.skipUrlUpdate) { updateQueryParams({ order_by: newOrderBy === 'created_at:desc' ? null : newOrderBy, }); } }, [updateQueryParams] ); const handleStatusChange = useCallback( ( status: 'all' | 'active' | 'completed', options?: { skipUrlUpdate?: boolean } ) => { setTaskStatusFilter(status); if (!options?.skipUrlUpdate) { updateQueryParams({ status: status === 'active' ? null : status, }); } }, [updateQueryParams] ); const handleGroupByChange = useCallback( (value: 'none' | 'project', options?: { skipUrlUpdate?: boolean }) => { setGroupBy(value); if (uid) { localStorage.setItem(`view_${uid}_group_by`, value); } if (!options?.skipUrlUpdate) { updateQueryParams({ group_by: value === 'none' ? null : value, }); } }, [uid, updateQueryParams] ); const handleSearchChange = useCallback( (value: string, options?: { skipUrlUpdate?: boolean }) => { setTaskSearchQuery(value); if (!options?.skipUrlUpdate) { updateQueryParams({ search: value.trim() ? value : null, }); } }, [updateQueryParams] ); const showCompletedTasks = taskStatusFilter !== 'active'; useEffect(() => { fetchViewAndResults(); }, [uid]); useEffect(() => { const params = new URLSearchParams(location.search); const urlOrderBy = params.get('order_by') || 'created_at:desc'; handleSortChange(urlOrderBy, { skipUrlUpdate: true }); const urlStatus = params.get('status'); const normalizedStatus = urlStatus === 'completed' ? 'completed' : urlStatus === 'all' ? 'all' : 'active'; handleStatusChange(normalizedStatus, { skipUrlUpdate: true }); const urlGroup = params.get('group_by') === 'project' ? 'project' : 'none'; setGroupBy((prev) => { if (prev !== urlGroup) { if (uid) { localStorage.setItem(`view_${uid}_group_by`, urlGroup); } return urlGroup; } return prev; }); const urlSearch = params.get('search') || ''; if (urlSearch) { setIsSearchExpanded(true); } handleSearchChange(urlSearch, { skipUrlUpdate: true }); }, [ location.search, uid, handleSortChange, handleStatusChange, handleSearchChange, ]); // Close dropdown when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( criteriaDropdownRef.current && !criteriaDropdownRef.current.contains(event.target as Node) ) { setShowCriteriaDropdown(false); } }; if (showCriteriaDropdown) { document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); }; } }, [showCriteriaDropdown]); // Save title when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( titleInputRef.current && !titleInputRef.current.contains(event.target as Node) ) { handleSaveName(); } }; if (isEditingName) { document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); }; } }, [isEditingName, editedName]); const fetchViewAndResults = async (resetPagination = true) => { if (!uid) return; try { // Fetch view details const viewResponse = await fetch(getApiPath(`views/${uid}`), { credentials: 'include', }); if (!viewResponse.ok) { navigate('/views'); return; } const viewData = await viewResponse.json(); const normalizedView: View = { ...viewData, tags: viewData.tags || [], extras: viewData.extras || [], defer: viewData.defer || null, }; setView(normalizedView); const currentOffset = resetPagination ? 0 : offset; // Fetch search results with pagination and exclude subtasks const response = await searchUniversal({ query: normalizedView.search_query || '', filters: normalizedView.filters, priority: normalizedView.priority || undefined, due: normalizedView.due || undefined, defer: normalizedView.defer || undefined, tags: normalizedView.tags && normalizedView.tags.length > 0 ? normalizedView.tags : undefined, extras: normalizedView.extras && normalizedView.extras.length > 0 ? normalizedView.extras : undefined, limit: limit, offset: currentOffset, excludeSubtasks: true, }); // Separate results by type const taskResults: Task[] = []; const noteResults: Note[] = []; const projectResults: Project[] = []; response.results.forEach((result) => { if (result.type === 'Task') { taskResults.push(result as any); } else if (result.type === 'Note') { noteResults.push(result as any); } else if (result.type === 'Project') { projectResults.push(result as any); } }); if (resetPagination) { setTasks(taskResults); setNotes(noteResults); setProjects(projectResults); setOffset(limit); } else { setTasks((prev) => [...prev, ...taskResults]); setNotes((prev) => [...prev, ...noteResults]); setProjects((prev) => [...prev, ...projectResults]); setOffset((prev) => prev + limit); } setHasMore(response.pagination?.hasMore || false); if (response.pagination) { setTotalCount(response.pagination.total); } } catch (error) { console.error('Error fetching view:', error); navigate('/views'); } finally { setIsLoading(false); setIsLoadingMore(false); } }; const loadMore = async () => { if (!hasMore || isLoadingMore) return; setIsLoadingMore(true); await fetchViewAndResults(false); }; // Task handlers const handleTaskUpdate = async (updatedTask: Task) => { try { const response = await fetch( getApiPath(`task/${updatedTask.uid}`), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updatedTask), } ); if (response.ok) { setTasks((prevTasks) => prevTasks.map((task) => task.id === updatedTask.id ? updatedTask : task ) ); } } catch (error) { console.error('Error updating task:', error); } }; const handleTaskDelete = async (taskUid: string) => { try { const response = await fetch( getApiPath(`task/${encodeURIComponent(taskUid)}`), { method: 'DELETE', } ); if (response.ok) { setTasks((prevTasks) => prevTasks.filter((task) => task.uid !== taskUid) ); } } catch (error) { console.error('Error deleting task:', error); } }; const handleTaskCompletionToggle = (updatedTask: Task) => { setTasks((prevTasks) => prevTasks.map((task) => task.id === updatedTask.id ? updatedTask : task ) ); }; const getCompletionPercentage = (project: Project) => { return (project as any).completion_percentage || 0; }; const handleEditProject = (project: Project) => { if (project.uid) { const slug = project.name .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, ''); navigate(`/project/${project.uid}-${slug}/edit`); } else { navigate(`/project/${project.id}/edit`); } }; const handleEditName = () => { if (view) { setEditedName(view.name); setIsEditingName(true); } }; const handleSaveName = async () => { if (!view || !editedName.trim()) return; try { const response = await fetch(getApiPath(`views/${view.uid}`), { method: 'PATCH', headers: { 'Content-Type': 'application/json', }, credentials: 'include', body: JSON.stringify({ name: editedName.trim(), }), }); if (response.ok) { setView({ ...view, name: editedName.trim() }); setIsEditingName(false); window.dispatchEvent(new CustomEvent('viewUpdated')); } } catch (error) { console.error('Error updating view name:', error); } }; const handleCancelEdit = () => { setIsEditingName(false); setEditedName(''); }; const togglePin = async () => { if (!view) return; try { const response = await fetch(getApiPath(`views/${view.uid}`), { method: 'PATCH', headers: { 'Content-Type': 'application/json', }, credentials: 'include', body: JSON.stringify({ is_pinned: !view.is_pinned, }), }); if (response.ok) { setView({ ...view, is_pinned: !view.is_pinned }); window.dispatchEvent(new CustomEvent('viewUpdated')); } } catch (error) { console.error('Error toggling pin:', error); } }; const handleDeleteView = async () => { if (!view) return; try { const response = await fetch(getApiPath(`views/${view.uid}`), { method: 'DELETE', credentials: 'include', }); if (response.ok) { window.dispatchEvent(new CustomEvent('viewUpdated')); navigate('/views'); } } catch (error) { console.error('Error deleting view:', error); } finally { setIsConfirmDialogOpen(false); } }; const openConfirmDialog = () => { setIsConfirmDialogOpen(true); }; const closeConfirmDialog = () => { setIsConfirmDialogOpen(false); }; if (isLoading) { return (
Loading view...
); } if (!view) { return null; } return (
{/* Header */}
{isEditingName ? ( setEditedName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { handleSaveName(); } else if (e.key === 'Escape') { handleCancelEdit(); } }} className="text-2xl font-light text-gray-900 dark:text-white bg-transparent border-b-2 border-blue-500 focus:outline-none w-full max-w-2xl" autoFocus /> ) : (

{view.name}

)}
{t('tasks.groupBy', 'Group by')}
{['none', 'project'].map((val) => ( ))}
{t('tasks.show', 'Show')}
{[ { key: 'active', label: t( 'tasks.open', 'Open' ), }, { key: 'all', label: t( 'tasks.all', 'All' ), }, { key: 'completed', label: t( 'tasks.completed', 'Completed' ), }, ].map((opt) => { const isActive = taskStatusFilter === opt.key; return ( ); })}
{t('tasks.direction', 'Direction')}
{[ { key: 'asc', label: t( 'tasks.ascending', 'Ascending' ), }, { key: 'desc', label: t( 'tasks.descending', 'Descending' ), }, ].map((dir) => { const currentDirection = orderBy.split(':')[1] || 'asc'; const isActive = currentDirection === dir.key; return ( ); })}
} />
{showCriteriaDropdown && (

{t('views.searchCriteria')}

{view.filters.length > 0 && (

{t('views.entityTypes')}

{view.filters.map( (filter) => ( {filter} ) )}
)} {view.search_query && (

{t('views.searchText')}

" {view.search_query} "

)} {view.priority && (

{t('views.priority')}

{view.priority}
)} {view.due && (

{t('views.dueDate')}

{view.due.replace( /_/g, ' ' )}
)} {view.defer && (

{t('search.deferUntil')}

{view.defer.replace( /_/g, ' ' )}
)} {view.tags && view.tags.length > 0 && (

{t('views.tags')}

{view.tags.map( (tag) => ( {tag} ) )}
)} {view.extras && view.extras.length > 0 && (

{t('search.extras')}

{view.extras.map( ( extra, index ) => ( {extra.replace( /_/g, ' ' )} ) )}
)} {!view.filters.length && !view.search_query && !view.priority && !view.due && (!view.tags || view.tags.length === 0) && (!view.extras || view.extras.length === 0) && (

{t( 'views.noCriteriaSet' )}

)}
)}
{/* Search input section, collapsible */}
handleSearchChange(e.target.value)} className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white" />
{/* Tasks Section */} {displayTasks.length > 0 && (

{t('tasks.title')} ({displayTasks.length})

{groupBy === 'project' ? ( ) : ( )} {/* Load more button */} {hasMore && (
)} {/* Pagination info */} {displayTasks.length > 0 && (
{t( 'tasks.showingItems', 'Showing {{current}} of {{total}} tasks', { current: displayTasks.length, total: totalCount, } )}
)}
)} {/* Notes Section */} {notes.length > 0 && (

{t('notes.title')} ({notes.length})

    {notes.map((note) => (
  • setHoveredNoteId(note.uid || null) } onMouseLeave={() => setHoveredNoteId(null)} >
    {note.title} {/* Tags */} {((note.tags && note.tags.length > 0) || (note.Tags && note.Tags.length > 0)) && ( <> {( note.tags || note.Tags || [] ).map((noteTag) => ( ))} )}
  • ))}
)} {/* Projects Section */} {projects.length > 0 && (

{t('projects.title')} ({projects.length})

{projects.map((project) => { return ( getCompletionPercentage(project) } activeDropdown={activeDropdown} setActiveDropdown={setActiveDropdown} handleEditProject={handleEditProject} setProjectToDelete={setProjectToDelete} setIsConfirmDialogOpen={() => {}} onOpenShare={() => { /* noop in view detail */ }} /> ); })}
)} {/* Empty State */} {displayTasks.length === 0 && notes.length === 0 && projects.length === 0 && (

{taskSearchQuery.trim() ? t( 'tasks.noTasksAvailable', 'No tasks available.' ) : 'No items found matching the view criteria'}

)} {/* Confirm Delete Dialog */} {isConfirmDialogOpen && ( )}
); }; export default ViewDetail;