- Support nest sub tasks

- Support filters in ChoreGrouper
- completion window only available if due date selected
- Add SortAndGrouping Component : support Filter by Assignee
- update Notification Switch to align left of the text
- Support caching filters
This commit is contained in:
Mo Tarbin
2025-03-05 19:43:32 -05:00
parent bbea27d380
commit a16309c69a
9 changed files with 679 additions and 224 deletions

View File

@@ -6,7 +6,15 @@ import {
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Add, Delete, DragIndicator, Edit } from '@mui/icons-material'
import {
ChevronRight,
Delete,
DragIndicator,
Edit,
ExpandMore,
KeyboardReturn,
PlaylistAdd,
} from '@mui/icons-material'
import {
Box,
Checkbox,
@@ -19,23 +27,41 @@ import {
import React, { useState } from 'react'
import { CompleteSubTask } from '../../utils/Fetcher'
function SortableItem({ task, index, handleToggle, handleDelete, editMode }) {
function SortableItem({
task,
index,
handleToggle,
handleDelete,
handleAddSubtask,
allTasks,
setTasks,
level = 0,
editMode,
}) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: task.id })
const [isEditing, setIsEditing] = useState(false)
const [editedText, setEditedText] = useState(task.name)
const [expanded, setExpanded] = useState(false)
const [showAddSubtask, setShowAddSubtask] = useState(false)
const [newSubtask, setNewSubtask] = useState('')
// Find child tasks
const childTasks = allTasks.filter(t => t.parentId === task.id)
const hasChildren = childTasks.length > 0
const style = {
transform: CSS.Transform.toString(transform),
transition,
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
flexDirection: { xs: 'column', sm: 'row' }, // Responsive style
flexDirection: { xs: 'column', sm: 'row' },
touchAction: 'none',
paddingLeft: `${level * 24}px`,
}
const [isEditing, setIsEditing] = useState(false)
const [editedText, setEditedText] = useState(task.name)
const handleEdit = () => {
setIsEditing(true)
}
@@ -43,146 +69,332 @@ function SortableItem({ task, index, handleToggle, handleDelete, editMode }) {
const handleSave = () => {
setIsEditing(false)
task.name = editedText
// Update the task in the parent component
setTasks(prevTasks =>
prevTasks.map(t => (t.id === task.id ? { ...t, name: editedText } : t)),
)
}
const handleExpandClick = () => {
setExpanded(!expanded)
}
const handleAddSubtaskClick = () => {
setShowAddSubtask(!showAddSubtask)
}
const submitNewSubtask = () => {
if (!newSubtask.trim()) return
handleAddSubtask(task.id, newSubtask)
setNewSubtask('')
setShowAddSubtask(false)
setExpanded(true) // Auto-expand to show the new subtask
}
const handleKeyPress = event => {
if (event.key === 'Enter') {
submitNewSubtask()
}
}
return (
<ListItem ref={setNodeRef} style={style} {...attributes}>
{editMode && (
<IconButton {...listeners} {...attributes}>
<DragIndicator />
</IconButton>
)}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flex: 1,
}}
>
{!editMode && (
<Checkbox
checked={task.completedAt}
onChange={() => handleToggle(task.id)}
overlay={!editMode}
/>
<>
<ListItem ref={setNodeRef} style={style} {...attributes}>
{editMode && (
<IconButton {...listeners} {...attributes} size='sm'>
<DragIndicator />
</IconButton>
)}
{hasChildren && (
<IconButton
size='sm'
variant='plain'
color='neutral'
onClick={handleExpandClick}
>
{expanded ? <ExpandMore /> : <ChevronRight />}
</IconButton>
)}
{!hasChildren && level > 0 && (
<Box sx={{ width: 28 }} /> // Spacer for alignment not sure of better way for now it's good
)}
<Box
sx={{
flex: 1,
minHeight: 50,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
gap: 1,
flex: 1,
}}
>
{isEditing ? (
{!editMode && (
<Checkbox
checked={!!task.completedAt}
onChange={() => handleToggle(task.id)}
/>
)}
<Box
sx={{
flex: 1,
minHeight: 50,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
onClick={() => {
if (!editMode) {
handleToggle(task.id)
}
}}
>
{isEditing ? (
<Input
value={editedText}
onChange={e => setEditedText(e.target.value)}
onBlur={handleSave}
onKeyPress={e => {
if (e.key === 'Enter') {
handleSave()
}
}}
autoFocus
/>
) : (
<Typography
sx={{
textDecoration: task.completedAt ? 'line-through' : 'none',
}}
onDoubleClick={handleEdit}
>
{task.name}
</Typography>
)}
{task.completedAt && (
<Typography
sx={{
display: { xs: 'block', sm: 'inline' },
color: 'text.secondary',
fontSize: 'sm',
}}
>
{new Date(task.completedAt).toLocaleString()}
</Typography>
)}
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
{editMode && (
<>
<IconButton
variant='soft'
color='primary'
size='sm'
onClick={handleAddSubtaskClick}
title='Add subtask'
>
<PlaylistAdd />
</IconButton>
<IconButton variant='soft' size='sm' onClick={handleEdit}>
<Edit />
</IconButton>
<IconButton
variant='soft'
color='danger'
size='sm'
onClick={() => handleDelete(task.id)}
>
<Delete />
</IconButton>
</>
)}
</Box>
</ListItem>
{/* Add subtask input field */}
{showAddSubtask && (
<ListItem
sx={{
paddingLeft: `${(level + 1) * 24}px`,
paddingTop: 0,
paddingBottom: 1,
}}
>
<Box sx={{ display: 'flex', width: '100%', gap: 1 }}>
<Input
value={editedText}
onChange={e => setEditedText(e.target.value)}
onBlur={handleSave}
onKeyPress={e => {
if (e.key === 'Enter') {
handleSave()
}
}}
placeholder='Add new subtask...'
value={newSubtask}
onChange={e => setNewSubtask(e.target.value)}
onKeyPress={handleKeyPress}
sx={{ flex: 1 }}
autoFocus
/>
) : (
<Typography
sx={{
textDecoration: task.completedAt ? 'line-through' : 'none',
}}
onDoubleClick={handleEdit}
>
{task.name}
</Typography>
)}
{task.completedAt && (
<Typography
sx={{
display: { xs: 'block', sm: 'inline' }, // Responsive style
color: 'text.secondary',
}}
>
{new Date(task.completedAt).toLocaleString()}
</Typography>
)}
</Box>
</Box>
{editMode && (
<Box sx={{ display: 'flex', gap: 1 }}>
<IconButton variant='soft' onClick={handleEdit}>
<Edit />
</IconButton>
<IconButton
variant='soft'
color='danger'
onClick={() => handleDelete(task.id)}
>
<Delete />
</IconButton>
<IconButton onClick={submitNewSubtask} size='sm'>
<KeyboardReturn />
</IconButton>
</Box>
</ListItem>
)}
{/* Child tasks */}
{hasChildren && expanded && (
<Box sx={{ paddingLeft: `${level * 24}px` }}>
{childTasks
.sort((a, b) => a.orderId - b.orderId)
.map((childTask, childIndex) => (
<SortableItem
key={childTask.id}
task={childTask}
index={childIndex}
handleToggle={handleToggle}
handleDelete={handleDelete}
handleAddSubtask={handleAddSubtask}
allTasks={allTasks}
setTasks={setTasks}
level={level + 1}
editMode={editMode}
/>
))}
</Box>
)}
</ListItem>
</>
)
}
const SubTasks = ({ editMode = true, choreId = 0, tasks, setTasks }) => {
const SubTasks = ({ editMode = true, choreId = 0, tasks = [], setTasks }) => {
const [newTask, setNewTask] = useState('')
const topLevelTasks = tasks.filter(task => task.parentId === null)
const handleToggle = taskId => {
const updatedTask = tasks.find(task => task.id === taskId)
updatedTask.completedAt = updatedTask.completedAt
const newCompletedAt = updatedTask.completedAt
? null
: new Date().toISOString()
// Update the task
const updatedTasks = tasks.map(task =>
task.id === taskId ? updatedTask : task,
task.id === taskId ? { ...task, completedAt: newCompletedAt } : task,
)
CompleteSubTask(
taskId,
Number(choreId),
updatedTask.completedAt ? new Date().toISOString() : null,
).then(res => {
// If completing a task, also complete all child tasks
if (newCompletedAt) {
const completeChildren = parentId => {
const children = updatedTasks.filter(t => t.parentId === parentId)
children.forEach(child => {
const index = updatedTasks.findIndex(t => t.id === child.id)
if (index !== -1) {
updatedTasks[index] = {
...updatedTasks[index],
completedAt: newCompletedAt,
}
completeChildren(child.id) // Recursively complete grandchildren
}
})
}
completeChildren(taskId)
}
CompleteSubTask(taskId, Number(choreId), newCompletedAt).then(res => {
if (res.status !== 200) {
console.log('Error updating task')
return
}
})
setTasks(updatedTasks)
}
const handleDelete = taskId => {
setTasks(tasks.filter(task => task.id !== taskId))
// Find all descendant tasks to delete
const findDescendants = id => {
const descendants = []
const children = tasks.filter(t => t.parentId === id)
children.forEach(child => {
descendants.push(child.id)
descendants.push(...findDescendants(child.id))
})
return descendants
}
const descendantIds = findDescendants(taskId)
const idsToDelete = [taskId, ...descendantIds]
// Filter out the task and all its descendants
const updatedTasks = tasks
.filter(task => !idsToDelete.includes(task.id))
.map((task, index) => ({
...task,
orderId: task.parentId === null ? index : task.orderId,
}))
setTasks(updatedTasks)
}
const handleAdd = () => {
if (!newTask.trim()) return
setTasks([
...tasks,
{
name: newTask,
completedAt: null,
orderId: tasks.length,
},
])
const newTaskObj = {
name: newTask,
completedAt: null,
orderId: topLevelTasks.length,
parentId: null,
id: (tasks.length + 1) * -1, // Temporary negative ID
}
setTasks([...tasks, newTaskObj])
setNewTask('')
}
const handleAddSubtask = (parentId, name) => {
if (!name.trim()) return
// Find siblings to determine orderId
const siblings = tasks.filter(t => t.parentId === parentId)
const newSubtask = {
name,
completedAt: null,
orderId: siblings.length,
parentId,
id: (tasks.length + 1) * -1, // Temporary negative ID
}
setTasks([...tasks, newSubtask])
}
const onDragEnd = event => {
const { active, over } = event
if (active.id !== over.id) {
setTasks(items => {
const oldIndex = items.findIndex(item => item.id === active.id)
const newIndex = items.findIndex(item => item.id === over.id)
const reorderedItems = arrayMove(items, oldIndex, newIndex)
return reorderedItems.map((item, index) => ({
...item,
orderId: index,
}))
if (!over || active.id === over.id) return
setTasks(items => {
const oldIndex = items.findIndex(item => item.id === active.id)
const newIndex = items.findIndex(item => item.id === over.id)
if (oldIndex === -1 || newIndex === -1) return items
const activeItem = items[oldIndex]
const overItem = items[newIndex]
const reorderedItems = arrayMove(items, oldIndex, newIndex)
const parentId = overItem.parentId
const siblings = reorderedItems.filter(item => item.parentId === parentId)
return reorderedItems.map(item => {
if (item.id === activeItem.id) {
return { ...item, parentId, orderId: siblings.indexOf(item) }
}
return item.parentId === parentId
? { ...item, orderId: siblings.indexOf(item) }
: item
})
}
})
}
const handleKeyPress = event => {
@@ -191,38 +403,37 @@ const SubTasks = ({ editMode = true, choreId = 0, tasks, setTasks }) => {
}
}
// Sort tasks by orderId before rendering
const sortedTasks = [...tasks].sort((a, b) => a.orderId - b.orderId)
return (
<>
<DndContext collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext
items={sortedTasks}
strategy={verticalListSortingStrategy}
>
<SortableContext items={tasks} strategy={verticalListSortingStrategy}>
<List sx={{ padding: 0 }}>
{sortedTasks.map((task, index) => (
<SortableItem
key={task.id}
task={task}
index={index}
handleToggle={handleToggle}
handleDelete={handleDelete}
editMode={editMode}
/>
))}
{topLevelTasks
.sort((a, b) => a.orderId - b.orderId)
.map((task, index) => (
<SortableItem
key={task.id}
task={task}
index={index}
handleToggle={handleToggle}
handleDelete={handleDelete}
handleAddSubtask={handleAddSubtask}
allTasks={tasks}
setTasks={setTasks}
editMode={editMode}
/>
))}
{editMode && (
<ListItem sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Input
placeholder='Add new...'
placeholder='Add new task...'
value={newTask}
onChange={e => setNewTask(e.target.value)}
onKeyPress={handleKeyPress}
sx={{ flex: 1 }}
/>
<IconButton onClick={handleAdd}>
<Add />
<KeyboardReturn />
</IconButton>
</ListItem>
)}