diff --git a/src/views/components/SubTask.jsx b/src/views/components/SubTask.jsx
index 01a505f..673ca74 100644
--- a/src/views/components/SubTask.jsx
+++ b/src/views/components/SubTask.jsx
@@ -7,7 +7,6 @@ import {
} from '@dnd-kit/core'
import {
SortableContext,
- arrayMove,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
@@ -16,10 +15,8 @@ import {
ChevronRight,
Delete,
DragIndicator,
- Edit,
ExpandMore,
KeyboardReturn,
- PlaylistAdd,
} from '@mui/icons-material'
import {
Box,
@@ -31,47 +28,56 @@ import {
ListItem,
Typography,
} from '@mui/joy'
-import { useState } from 'react'
+import { useCallback, useRef, useState } from 'react'
+import { flushSync } from 'react-dom'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext'
import { useUserProfile } from '../../queries/UserQueries'
import { CompleteSubTask } from '../../utils/Fetcher'
+function getVisibleOrder(tasks, expandedIds) {
+ const result = []
+ const addTask = task => {
+ result.push(task)
+ if (expandedIds.has(task.id)) {
+ tasks
+ .filter(t => t.parentId === task.id)
+ .sort((a, b) => a.orderId - b.orderId)
+ .forEach(addTask)
+ }
+ }
+ tasks
+ .filter(t => t.parentId === null)
+ .sort((a, b) => a.orderId - b.orderId)
+ .forEach(addTask)
+ return result
+}
+
+function nextTempId(tasks) {
+ return Math.min(0, ...tasks.map(t => t.id)) - 1
+}
+
function SortableItem({
task,
- index,
- handleToggle,
- handleDelete,
- handleAddSubtask,
allTasks,
setTasks,
- level = 0,
+ level,
editMode,
- performers = [],
+ expandedIds,
+ onToggleExpand,
+ handleToggle,
+ inputRefs,
+ onKeyDown,
+ performers,
}) {
const { fmt } = useLocalization()
const { attributes, listeners, setNodeRef, transform, transition } =
- useSortable({
- id: task.id,
- data: { completedAt: task.completedAt, completedBy: task.completedBy },
- // Add touch sensor options for better mobile scrolling
- options: {
- activationConstraint: {
- // Require a small movement before activating drag to allow scrolling
- delay: 250,
- tolerance: 5,
- },
- },
- })
+ 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 expanded = expandedIds.has(task.id)
+ const childTasks = allTasks
+ .filter(t => t.parentId === task.id)
+ .sort((a, b) => a.orderId - b.orderId)
const hasChildren = childTasks.length > 0
const style = {
@@ -79,226 +85,154 @@ function SortableItem({
transition,
display: 'flex',
alignItems: 'center',
- gap: '0.5rem',
- flexDirection: { xs: 'column', sm: 'row' },
- // Enable default touch behavior for scrolling
touchAction: 'auto',
paddingLeft: `${level * 24}px`,
}
- const handleEdit = () => {
- setIsEditing(true)
- }
-
- 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 (
<>
{editMode && (
)}
- {hasChildren && (
+ {hasChildren ? (
onToggleExpand(task.id)}
>
{expanded ? : }
- )}
+ ) : level > 0 ? (
+
+ ) : null}
- {!hasChildren && level > 0 && (
- // Spacer for alignment
- )}
-
-
+
{!editMode && (
handleToggle(task.id)}
/>
)}
- {
- if (!editMode) {
- handleToggle(task.id)
+
+ {editMode ? (
+ {
+ inputRefs.current[task.id] = el
+ },
+ },
+ }}
+ value={task.name}
+ placeholder='Task name...'
+ onChange={e =>
+ setTasks(prev =>
+ prev.map(t =>
+ t.id === task.id ? { ...t, name: e.target.value } : t,
+ ),
+ )
}
- }}
- >
- {isEditing ? (
- setEditedText(e.target.value)}
- onBlur={handleSave}
- onKeyDown={e => {
- if (!(e.metaKey || e.ctrlKey) && e.key === 'Enter') {
- handleSave()
- }
- }}
- autoFocus
- />
- ) : (
+ onKeyDown={e => onKeyDown(e, task)}
+ sx={{
+ flex: 1,
+ border: 'none',
+ backgroundColor: 'transparent',
+ boxShadow: 'none',
+ '--Input-focusedHighlight': 'var(--joy-palette-primary-300)',
+ '&:not(:focus-within)': {
+ boxShadow: 'none',
+ backgroundColor: 'transparent',
+ },
+ }}
+ />
+ ) : (
+ handleToggle(task.id)}
+ >
{task.name}
- )}
- {task.completedAt && (
-
- {fmt.dateTime(task.completedAt)}
- {performers.find(p => p.userId === task.completedBy) ? (
-
- {
- performers.find(p => p.userId === task.completedBy)
- .displayName
- }
-
- ) : null}
-
- )}
-
-
-
-
- {editMode && (
- <>
-
-
-
-
-
-
- handleDelete(task.id)}
- >
-
-
- >
+ {task.completedAt && (
+
+ {fmt.dateTime(task.completedAt)}
+ {performers?.find(p => p.userId === task.completedBy) && (
+
+ {
+ performers.find(p => p.userId === task.completedBy)
+ .displayName
+ }
+
+ )}
+
+ )}
+
)}
-
- {/* Add subtask input field */}
- {showAddSubtask && (
-
-
- setNewSubtask(e.target.value)}
- onKeyPress={handleKeyPress}
- sx={{ flex: 1 }}
- autoFocus
- />
-
-
+ {editMode && (
+
+
+ onKeyDown(
+ {
+ key: 'Backspace',
+ shiftKey: true,
+ preventDefault: () => {},
+ },
+ task,
+ )
+ }
+ >
+
-
- )}
+ )}
+
- {/* Child tasks */}
{hasChildren && expanded && (
-
- {childTasks
- .sort((a, b) => a.orderId - b.orderId)
- .map((childTask, childIndex) => (
-
- ))}
+
+ {childTasks.map(childTask => (
+
+ ))}
)}
>
@@ -314,19 +248,22 @@ const SubTasks = ({
shouldFocus = false,
}) => {
const [newTask, setNewTask] = useState('')
+ const [expandedIds, setExpandedIds] = useState(new Set())
const { data: userProfile } = useUserProfile()
const { impersonatedUser } = useImpersonateUser()
+ const inputRefs = useRef({})
+
+ const focusId = id => {
+ setTimeout(() => {
+ inputRefs.current[id]?.focus()
+ }, 50)
+ }
const topLevelTasks = tasks.filter(task => task.parentId === null)
- // Create sensors for touch handling
const sensors = useSensors(
useSensor(PointerSensor, {
- // Configure for better mobile scrolling
- activationConstraint: {
- delay: 100,
- tolerance: 8,
- },
+ activationConstraint: { delay: 100, tolerance: 8 },
}),
)
@@ -336,7 +273,6 @@ const SubTasks = ({
? null
: new Date().toISOString()
- // Update the task
const updatedTasks = tasks.map(task =>
task.id === taskId
? {
@@ -347,7 +283,6 @@ const SubTasks = ({
: task,
)
- // If completing a task, also complete all child tasks
if (newCompletedAt) {
const completeChildren = parentId => {
const children = updatedTasks.filter(t => t.parentId === parentId)
@@ -358,7 +293,7 @@ const SubTasks = ({
...updatedTasks[index],
completedAt: newCompletedAt,
}
- completeChildren(child.id) // Recursively complete grandchildren
+ completeChildren(child.id)
}
})
}
@@ -366,73 +301,309 @@ const SubTasks = ({
}
CompleteSubTask(taskId, Number(choreId), newCompletedAt).then(res => {
- if (res.status !== 200) {
- console.log('Error updating task')
- return
- }
+ if (res.status !== 200) console.log('Error updating task')
})
setTasks(updatedTasks)
}
- const handleDelete = taskId => {
- // Find all descendant tasks to delete
- const findDescendants = id => {
- const descendants = []
- const children = tasks.filter(t => t.parentId === id)
+ const handleDelete = useCallback(
+ taskId => {
+ const findDescendants = id => {
+ const descendants = []
+ tasks
+ .filter(t => t.parentId === id)
+ .forEach(child => {
+ descendants.push(child.id)
+ descendants.push(...findDescendants(child.id))
+ })
+ return descendants
+ }
+ const idsToDelete = [taskId, ...findDescendants(taskId)]
+ setTasks(
+ tasks
+ .filter(task => !idsToDelete.includes(task.id))
+ .map((task, index) => ({
+ ...task,
+ orderId: task.parentId === null ? index : task.orderId,
+ })),
+ )
+ },
+ [tasks, setTasks],
+ )
- children.forEach(child => {
- descendants.push(child.id)
- descendants.push(...findDescendants(child.id))
- })
+ const handleToggleExpand = useCallback(taskId => {
+ setExpandedIds(prev => {
+ const next = new Set(prev)
+ next.has(taskId) ? next.delete(taskId) : next.add(taskId)
+ return next
+ })
+ }, [])
- return descendants
- }
+ const handleKeyDown = useCallback(
+ (e, task) => {
+ const input = inputRefs.current[task.id]
+ const selStart = input?.selectionStart ?? 0
+ const selEnd = input?.selectionEnd ?? 0
+ const valLen = input?.value?.length ?? 0
+ const cursorAtStart = selStart === 0 && selEnd === 0
+ const cursorAtEnd = selStart === valLen && selEnd === valLen
- const descendantIds = findDescendants(taskId)
- const idsToDelete = [taskId, ...descendantIds]
+ // Enter → add sibling after current task at same level
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ const newId = nextTempId(tasks)
+ const newTaskObj = {
+ id: newId,
+ name: '',
+ completedAt: null,
+ parentId: task.parentId,
+ orderId: task.orderId + 1,
+ }
+ setTasks(prev => [
+ ...prev.map(t =>
+ t.parentId === task.parentId && t.orderId > task.orderId
+ ? { ...t, orderId: t.orderId + 1 }
+ : t,
+ ),
+ newTaskObj,
+ ])
+ focusId(newId)
+ return
+ }
- // 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,
- }))
+ // Shift+Enter → add child subtask nested under current
+ if (e.key === 'Enter' && e.shiftKey) {
+ e.preventDefault()
+ const newId = nextTempId(tasks)
+ const childCount = tasks.filter(t => t.parentId === task.id).length
+ const newTaskObj = {
+ id: newId,
+ name: '',
+ completedAt: null,
+ parentId: task.id,
+ orderId: childCount,
+ }
+ setExpandedIds(prev => new Set([...prev, task.id]))
+ setTasks(prev => [...prev, newTaskObj])
+ focusId(newId)
+ return
+ }
- setTasks(updatedTasks)
- }
+ // ArrowUp → focus previous visible task
+ if (e.key === 'ArrowUp' && !e.shiftKey) {
+ e.preventDefault()
+ const visible = getVisibleOrder(tasks, expandedIds)
+ const idx = visible.findIndex(t => t.id === task.id)
+ if (idx > 0) inputRefs.current[visible[idx - 1].id]?.focus()
+ return
+ }
+
+ // ArrowDown → focus next visible task
+ if (e.key === 'ArrowDown' && !e.shiftKey) {
+ e.preventDefault()
+ const visible = getVisibleOrder(tasks, expandedIds)
+ const idx = visible.findIndex(t => t.id === task.id)
+ if (idx < visible.length - 1)
+ inputRefs.current[visible[idx + 1].id]?.focus()
+ return
+ }
+
+ // Shift+ArrowUp → move task up among siblings; at top, promote before parent
+ if (e.key === 'ArrowUp' && e.shiftKey) {
+ e.preventDefault()
+ const siblings = tasks
+ .filter(t => t.parentId === task.parentId)
+ .sort((a, b) => a.orderId - b.orderId)
+ const idx = siblings.findIndex(t => t.id === task.id)
+ if (idx <= 0) {
+ // Already first sibling — promote to parent level, insert before parent
+ if (task.parentId === null) return
+ const parent = tasks.find(t => t.id === task.parentId)
+ if (!parent) return
+ setTasks(prev =>
+ prev.map(t => {
+ // Shift items at parent's orderId and above to make room
+ if (t.id === task.id)
+ return {
+ ...t,
+ parentId: parent.parentId,
+ orderId: parent.orderId,
+ }
+ if (
+ t.parentId === parent.parentId &&
+ t.orderId >= parent.orderId &&
+ t.id !== task.id
+ )
+ return { ...t, orderId: t.orderId + 1 }
+ return t
+ }),
+ )
+ focusId(task.id)
+ return
+ }
+ const prev = siblings[idx - 1]
+ setTasks(all =>
+ all.map(t => {
+ if (t.id === task.id) return { ...t, orderId: prev.orderId }
+ if (t.id === prev.id) return { ...t, orderId: task.orderId }
+ return t
+ }),
+ )
+ focusId(task.id)
+ return
+ }
+
+ // Shift+ArrowDown → move task down among siblings; at bottom, promote after parent
+ if (e.key === 'ArrowDown' && e.shiftKey) {
+ e.preventDefault()
+ const siblings = tasks
+ .filter(t => t.parentId === task.parentId)
+ .sort((a, b) => a.orderId - b.orderId)
+ const idx = siblings.findIndex(t => t.id === task.id)
+ if (idx >= siblings.length - 1) {
+ // Already last sibling — promote to parent level, insert after parent
+ if (task.parentId === null) return
+ const parent = tasks.find(t => t.id === task.parentId)
+ if (!parent) return
+ setTasks(prev =>
+ prev.map(t => {
+ if (t.id === task.id)
+ return {
+ ...t,
+ parentId: parent.parentId,
+ orderId: parent.orderId + 1,
+ }
+ if (
+ t.parentId === parent.parentId &&
+ t.orderId > parent.orderId &&
+ t.id !== task.id
+ )
+ return { ...t, orderId: t.orderId + 1 }
+ return t
+ }),
+ )
+ focusId(task.id)
+ return
+ }
+ const next = siblings[idx + 1]
+ setTasks(all =>
+ all.map(t => {
+ if (t.id === task.id) return { ...t, orderId: next.orderId }
+ if (t.id === next.id) return { ...t, orderId: task.orderId }
+ return t
+ }),
+ )
+ focusId(task.id)
+ return
+ }
+
+ // Shift+ArrowLeft (at cursor start) or Shift+Tab → outdent one level
+ const shouldOutdent =
+ (e.key === 'ArrowLeft' && e.shiftKey && cursorAtStart) ||
+ (e.key === 'Tab' && e.shiftKey)
+
+ if (shouldOutdent) {
+ e.preventDefault()
+ if (task.parentId === null) return
+ const parent = tasks.find(t => t.id === task.parentId)
+ if (!parent) return
+ const newOrderId = parent.orderId + 1
+ setTasks(prev =>
+ prev.map(t => {
+ if (t.id === task.id)
+ return { ...t, parentId: parent.parentId, orderId: newOrderId }
+ if (
+ t.parentId === parent.parentId &&
+ t.orderId >= newOrderId &&
+ t.id !== task.id
+ )
+ return { ...t, orderId: t.orderId + 1 }
+ return t
+ }),
+ )
+ focusId(task.id)
+ return
+ }
+
+ // Shift+ArrowRight (at cursor end) or Tab → indent under previous sibling
+ const shouldIndent =
+ (e.key === 'ArrowRight' && e.shiftKey && cursorAtEnd) ||
+ (e.key === 'Tab' && !e.shiftKey)
+
+ if (shouldIndent) {
+ e.preventDefault()
+ const siblings = tasks
+ .filter(t => t.parentId === task.parentId)
+ .sort((a, b) => a.orderId - b.orderId)
+ const idx = siblings.findIndex(t => t.id === task.id)
+ if (idx <= 0) return
+ const newParent = siblings[idx - 1]
+ const newChildCount = tasks.filter(
+ t => t.parentId === newParent.id,
+ ).length
+ setExpandedIds(prev => new Set([...prev, newParent.id]))
+ setTasks(prev =>
+ prev.map(t =>
+ t.id === task.id
+ ? { ...t, parentId: newParent.id, orderId: newChildCount }
+ : t,
+ ),
+ )
+ focusId(task.id)
+ return
+ }
+
+ // Backspace on empty task → delete and focus previous
+ if (e.key === 'Backspace' && !e.shiftKey && task.name === '') {
+ e.preventDefault()
+ const visible = getVisibleOrder(tasks, expandedIds)
+ const idx = visible.findIndex(t => t.id === task.id)
+ if (idx > 0) focusId(visible[idx - 1].id)
+ handleDelete(task.id)
+ return
+ }
+
+ // Shift+Backspace or Shift+Delete → delete task and focus nearest
+ if ((e.key === 'Backspace' || e.key === 'Delete') && e.shiftKey) {
+ e.preventDefault()
+ const visible = getVisibleOrder(tasks, expandedIds)
+ const idx = visible.findIndex(t => t.id === task.id)
+ if (idx > 0) focusId(visible[idx - 1].id)
+ else if (idx < visible.length - 1) focusId(visible[idx + 1].id)
+ handleDelete(task.id)
+ return
+ }
+
+ // Escape → blur current input
+ if (e.key === 'Escape') {
+ input?.blur()
+ }
+ },
+ [tasks, expandedIds, setTasks, handleDelete],
+ )
+
+ const addInputRef = useRef(null)
const handleAdd = () => {
if (!newTask.trim()) return
-
- 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 id1 = nextTempId(tasks)
+ const id2 = id1 - 1
+ flushSync(() => {
+ setTasks([
+ ...tasks,
+ {
+ id: id1,
+ name: newTask,
+ completedAt: null,
+ orderId: 0,
+ parentId: null,
+ },
+ { id: id2, name: '', completedAt: null, orderId: 1, parentId: null },
+ ])
+ setNewTask('')
+ })
+ inputRefs.current[id2]?.focus()
}
const onDragEnd = event => {
@@ -442,21 +613,21 @@ const SubTasks = ({
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 reordered = [...items]
+ reordered.splice(oldIndex, 1)
+ reordered.splice(newIndex, 0, activeItem)
const parentId = overItem.parentId
- const siblings = reorderedItems.filter(item => item.parentId === parentId)
+ const siblings = reordered.filter(item => item.parentId === parentId)
- return reorderedItems.map(item => {
- if (item.id === activeItem.id) {
+ return reordered.map(item => {
+ if (item.id === activeItem.id)
return { ...item, parentId, orderId: siblings.indexOf(item) }
- }
return item.parentId === parentId
? { ...item, orderId: siblings.indexOf(item) }
: item
@@ -464,64 +635,64 @@ const SubTasks = ({
})
}
- const handleKeyPress = event => {
- if (event.key === 'Enter') {
- handleAdd()
- }
- }
-
return (
- <>
-
-
-
- {topLevelTasks
- .sort((a, b) => a.orderId - b.orderId)
- .map((task, index) => (
-
- ))}
- {editMode && (
-
- setNewTask(e.target.value)}
- onKeyPress={handleKeyPress}
- sx={{ flex: 1 }}
- />
-
-
-
-
- )}
-
-
-
- >
+
+
+
+ {topLevelTasks
+ .sort((a, b) => a.orderId - b.orderId)
+ .map(task => (
+
+ ))}
+
+ {editMode && tasks.length === 0 && (
+
+ setNewTask(e.target.value)}
+ onKeyDown={e => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ handleAdd()
+ }
+ }}
+ sx={{ flex: 1 }}
+ />
+
+
+
+
+ )}
+
+
+
)
}