From 5332d0ac62e87be6ea200138b9c34ec5c1434d3f Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 12 Jul 2026 23:34:19 -0400 Subject: [PATCH] streamline task management in SubTask component. have better shortcut and keyboard movement --- src/views/components/SubTask.jsx | 831 +++++++++++++++++++------------ 1 file changed, 501 insertions(+), 330 deletions(-) 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 }} + /> + + + + + )} + + + ) }