streamline task management in SubTask component. have better shortcut and keyboard movement
This commit is contained in:
@@ -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 (
|
||||
<>
|
||||
<ListItem ref={setNodeRef} style={style} {...attributes}>
|
||||
{editMode && (
|
||||
<IconButton
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
size='sm'
|
||||
// Add data attribute for selective activation
|
||||
data-drag-handle='true'
|
||||
// Only restrict touch actions on the drag handle
|
||||
sx={{ touchAction: 'none' }}
|
||||
sx={{ touchAction: 'none', cursor: 'grab' }}
|
||||
>
|
||||
<DragIndicator />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{hasChildren && (
|
||||
{hasChildren ? (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={handleExpandClick}
|
||||
onClick={() => onToggleExpand(task.id)}
|
||||
>
|
||||
{expanded ? <ExpandMore /> : <ChevronRight />}
|
||||
</IconButton>
|
||||
)}
|
||||
) : level > 0 ? (
|
||||
<Box sx={{ width: 28 }} />
|
||||
) : null}
|
||||
|
||||
{!hasChildren && level > 0 && (
|
||||
<Box sx={{ width: 28 }} /> // Spacer for alignment
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1 }}>
|
||||
{!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)
|
||||
|
||||
{editMode ? (
|
||||
<Input
|
||||
slotProps={{
|
||||
input: {
|
||||
ref: el => {
|
||||
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 ? (
|
||||
<Input
|
||||
value={editedText}
|
||||
onChange={e => 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',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minHeight: 50,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => handleToggle(task.id)}
|
||||
>
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
{fmt.dateTime(task.completedAt)}
|
||||
{performers.find(p => p.userId === task.completedBy) ? (
|
||||
<Chip>
|
||||
{
|
||||
performers.find(p => p.userId === task.completedBy)
|
||||
.displayName
|
||||
}
|
||||
</Chip>
|
||||
) : null}
|
||||
</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>
|
||||
</>
|
||||
{task.completedAt && (
|
||||
<Typography sx={{ color: 'text.secondary', fontSize: 'sm' }}>
|
||||
{fmt.dateTime(task.completedAt)}
|
||||
{performers?.find(p => p.userId === task.completedBy) && (
|
||||
<Chip>
|
||||
{
|
||||
performers.find(p => p.userId === task.completedBy)
|
||||
.displayName
|
||||
}
|
||||
</Chip>
|
||||
)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</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
|
||||
placeholder='Add new subtask...'
|
||||
value={newSubtask}
|
||||
onChange={e => setNewSubtask(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
sx={{ flex: 1 }}
|
||||
autoFocus
|
||||
/>
|
||||
<IconButton onClick={submitNewSubtask} size='sm'>
|
||||
<KeyboardReturn />
|
||||
{editMode && (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<IconButton
|
||||
variant='soft'
|
||||
color='danger'
|
||||
size='sm'
|
||||
title='Delete (Shift+Backspace)'
|
||||
onClick={() =>
|
||||
onKeyDown(
|
||||
{
|
||||
key: 'Backspace',
|
||||
shiftKey: true,
|
||||
preventDefault: () => {},
|
||||
},
|
||||
task,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</ListItem>
|
||||
)}
|
||||
)}
|
||||
</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}
|
||||
performers={performers}
|
||||
/>
|
||||
))}
|
||||
<Box>
|
||||
{childTasks.map(childTask => (
|
||||
<SortableItem
|
||||
key={childTask.id}
|
||||
task={childTask}
|
||||
allTasks={allTasks}
|
||||
setTasks={setTasks}
|
||||
level={level + 1}
|
||||
editMode={editMode}
|
||||
expandedIds={expandedIds}
|
||||
onToggleExpand={onToggleExpand}
|
||||
handleToggle={handleToggle}
|
||||
inputRefs={inputRefs}
|
||||
onKeyDown={onKeyDown}
|
||||
performers={performers}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
@@ -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 (
|
||||
<>
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onDragEnd}
|
||||
sensors={sensors}
|
||||
>
|
||||
<SortableContext items={tasks} strategy={verticalListSortingStrategy}>
|
||||
<List
|
||||
sx={{
|
||||
padding: 0,
|
||||
// Improve scrolling behavior on mobile
|
||||
maxHeight: 'inherit',
|
||||
overflow: 'visible',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
{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}
|
||||
performers={performers}
|
||||
/>
|
||||
))}
|
||||
{editMode && (
|
||||
<ListItem sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Input
|
||||
autoFocus={shouldFocus}
|
||||
placeholder='Add new task...'
|
||||
value={newTask}
|
||||
onChange={e => setNewTask(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<IconButton onClick={handleAdd}>
|
||||
<KeyboardReturn />
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</>
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onDragEnd}
|
||||
sensors={sensors}
|
||||
>
|
||||
<SortableContext items={tasks} strategy={verticalListSortingStrategy}>
|
||||
<List
|
||||
sx={{
|
||||
padding: 0,
|
||||
maxHeight: 'inherit',
|
||||
overflow: 'visible',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
{topLevelTasks
|
||||
.sort((a, b) => a.orderId - b.orderId)
|
||||
.map(task => (
|
||||
<SortableItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
allTasks={tasks}
|
||||
setTasks={setTasks}
|
||||
level={0}
|
||||
editMode={editMode}
|
||||
expandedIds={expandedIds}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
handleToggle={handleToggle}
|
||||
inputRefs={inputRefs}
|
||||
onKeyDown={handleKeyDown}
|
||||
performers={performers}
|
||||
/>
|
||||
))}
|
||||
|
||||
{editMode && tasks.length === 0 && (
|
||||
<ListItem sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Input
|
||||
autoFocus={shouldFocus}
|
||||
placeholder='Add new task... (Enter to add)'
|
||||
value={newTask}
|
||||
slotProps={{ input: { ref: addInputRef } }}
|
||||
onChange={e => setNewTask(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleAdd()
|
||||
}
|
||||
}}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<IconButton onClick={handleAdd}>
|
||||
<KeyboardReturn />
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user