Merge pull request #135 from donetick/bugfixes-07-09-2026
improve task management
This commit is contained in:
@@ -28,6 +28,7 @@ export const AVAILABLE_LANGUAGES = [
|
||||
{ code: 'nl', name: 'Dutch', nativeName: 'Nederlands' },
|
||||
{ code: 'ja', name: 'Japanese', nativeName: '日本語' },
|
||||
{ code: 'pt', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' },
|
||||
{ code: 'ja', name: 'Japanese', nativeName: '日本語' },
|
||||
]
|
||||
|
||||
export const LocalizationProvider = ({ children }) => {
|
||||
|
||||
@@ -83,6 +83,8 @@ const SortAndGrouping = ({
|
||||
{ name: 'Due Date', value: 'due_date' },
|
||||
{ name: 'Priority', value: 'priority' },
|
||||
{ name: 'Labels', value: 'labels' },
|
||||
{ name: 'Created Date', value: 'created_date' },
|
||||
{ name: 'Updated Date', value: 'updated_date' },
|
||||
]
|
||||
|
||||
const filterItems = [
|
||||
@@ -332,6 +334,8 @@ const SortAndGrouping = ({
|
||||
{ name: 'Due Date', value: 'due_date' },
|
||||
{ name: 'Priority', value: 'priority' },
|
||||
{ name: 'Labels', value: 'labels' },
|
||||
{ name: 'Created Date', value: 'created_date' },
|
||||
{ name: 'Updated Date', value: 'updated_date' },
|
||||
].map((item, index) => (
|
||||
<MenuItem
|
||||
key={`${k}-${item?.value}`}
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
Option,
|
||||
Select,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Box, Button, FormControl, Input, Typography } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
@@ -125,39 +117,30 @@ function LabelModal({ isOpen, onClose, label }) {
|
||||
<Typography gutterBottom level='body-sm' alignSelf='start'>
|
||||
Color
|
||||
</Typography>
|
||||
<Select
|
||||
value={color}
|
||||
onChange={(e, value) => value && setColor(value)}
|
||||
renderValue={selected => (
|
||||
<Typography
|
||||
startDecorator={
|
||||
<Box
|
||||
className='size-4'
|
||||
borderRadius={10}
|
||||
sx={{ background: selected.value }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{selected.label}
|
||||
</Typography>
|
||||
)}
|
||||
>
|
||||
{LABEL_COLORS.map(val => (
|
||||
<Option key={val.value} value={val.value}>
|
||||
<Box className='flex items-center justify-between'>
|
||||
<Box
|
||||
width={20}
|
||||
height={20}
|
||||
borderRadius={10}
|
||||
sx={{ background: val.value }}
|
||||
/>
|
||||
<Typography sx={{ ml: 1 }} variant='caption'>
|
||||
{val.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Option>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{LABEL_COLORS.map(colorOption => (
|
||||
<Box
|
||||
key={colorOption.value}
|
||||
title={colorOption.name}
|
||||
onClick={() => setColor(colorOption.value)}
|
||||
sx={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: '50%',
|
||||
background: colorOption.value,
|
||||
cursor: 'pointer',
|
||||
outline:
|
||||
color === colorOption.value
|
||||
? '3px solid var(--joy-palette-primary-500)'
|
||||
: '2px solid transparent',
|
||||
outlineOffset: '2px',
|
||||
transition: 'all 0.15s ease',
|
||||
flexShrink: 0,
|
||||
'&:hover': { transform: 'scale(1.2)' },
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Select>
|
||||
</Box>
|
||||
</FormControl>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Option,
|
||||
Select,
|
||||
Stack,
|
||||
Textarea,
|
||||
Typography,
|
||||
@@ -223,39 +221,30 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
||||
{/* Color Selection */}
|
||||
<FormControl>
|
||||
<FormLabel>Project Color</FormLabel>
|
||||
<Select
|
||||
value={projectColor}
|
||||
onChange={(e, value) => value && setProjectColor(value)}
|
||||
renderValue={selected => (
|
||||
<Typography
|
||||
startDecorator={
|
||||
<Box
|
||||
className='size-4'
|
||||
borderRadius={10}
|
||||
sx={{ background: selected.value }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{selected.label}
|
||||
</Typography>
|
||||
)}
|
||||
>
|
||||
{PROJECT_COLORS.map(color => (
|
||||
<Option key={color.value} value={color.value}>
|
||||
<Box className='flex items-center justify-between'>
|
||||
<Box
|
||||
width={20}
|
||||
height={20}
|
||||
borderRadius={10}
|
||||
sx={{ background: color.value }}
|
||||
/>
|
||||
<Typography sx={{ ml: 1 }} variant='caption'>
|
||||
{color.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Option>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{PROJECT_COLORS.map(colorOption => (
|
||||
<Box
|
||||
key={colorOption.value}
|
||||
title={colorOption.name}
|
||||
onClick={() => setProjectColor(colorOption.value)}
|
||||
sx={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: '50%',
|
||||
background: colorOption.value,
|
||||
cursor: 'pointer',
|
||||
outline:
|
||||
projectColor === colorOption.value
|
||||
? '3px solid var(--joy-palette-primary-500)'
|
||||
: '2px solid transparent',
|
||||
outlineOffset: '2px',
|
||||
transition: 'all 0.15s ease',
|
||||
flexShrink: 0,
|
||||
'&:hover': { transform: 'scale(1.2)' },
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Select>
|
||||
</Box>
|
||||
</FormControl>
|
||||
|
||||
{/* Error Message */}
|
||||
|
||||
@@ -1,23 +1,125 @@
|
||||
import { CopyAll } from '@mui/icons-material'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import {
|
||||
CheckCircle,
|
||||
ContentCopy,
|
||||
ErrorOutline,
|
||||
Nfc,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Input,
|
||||
ListItem,
|
||||
Switch,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { startNativeNFCWrite } from '../../../service/NFCWriter'
|
||||
|
||||
const pulseKeyframes = `
|
||||
@keyframes nfc-pulse {
|
||||
0% { transform: scale(1); opacity: 0.6; }
|
||||
70% { transform: scale(1.6); opacity: 0; }
|
||||
100% { transform: scale(1.6); opacity: 0; }
|
||||
}
|
||||
@keyframes nfc-pulse-2 {
|
||||
0% { transform: scale(1); opacity: 0.4; }
|
||||
70% { transform: scale(2.1); opacity: 0; }
|
||||
100% { transform: scale(2.1); opacity: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nfc-pulse-ring { animation: none !important; }
|
||||
}
|
||||
`
|
||||
|
||||
function NFCIcon({ status }) {
|
||||
const isWaiting = status === 'waiting_for_tag'
|
||||
const isSuccess = status === 'success'
|
||||
const isError = status === 'error'
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 96,
|
||||
height: 96,
|
||||
mx: 'auto',
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
{isWaiting && (
|
||||
<>
|
||||
<Box
|
||||
className='nfc-pulse-ring'
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: '50%',
|
||||
border: '2px solid',
|
||||
borderColor: 'primary.400',
|
||||
animation: 'nfc-pulse 1.8s ease-out infinite',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
className='nfc-pulse-ring'
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: '50%',
|
||||
border: '2px solid',
|
||||
borderColor: 'primary.300',
|
||||
animation: 'nfc-pulse-2 1.8s ease-out infinite 0.4s',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: isSuccess
|
||||
? 'success.softBg'
|
||||
: isError
|
||||
? 'danger.softBg'
|
||||
: isWaiting
|
||||
? 'primary.softBg'
|
||||
: 'neutral.100',
|
||||
transition: 'background-color 0.25s ease',
|
||||
}}
|
||||
>
|
||||
{isSuccess ? (
|
||||
<CheckCircle sx={{ fontSize: 40, color: 'success.500' }} />
|
||||
) : isError ? (
|
||||
<ErrorOutline sx={{ fontSize: 40, color: 'danger.500' }} />
|
||||
) : (
|
||||
<Nfc
|
||||
sx={{
|
||||
fontSize: 40,
|
||||
color: isWaiting ? 'primary.500' : 'neutral.500',
|
||||
transition: 'color 0.25s ease',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function WriteNFCModal({ config }) {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const [nfcStatus, setNfcStatus] = useState('idle') // 'idle' | 'writing' | 'waiting_for_tag' | 'success' | 'error'
|
||||
const [nfcStatus, setNfcStatus] = useState('idle')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [isAutoCompleteWhenScan, setIsAutoCompleteWhenScan] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const cancelScanRef = useRef(null)
|
||||
const isNative = Capacitor.isNativePlatform()
|
||||
|
||||
@@ -45,6 +147,12 @@ function WriteNFCModal({ config }) {
|
||||
setNfcStatus('idle')
|
||||
}
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(getURL())
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const writeToNFC = async () => {
|
||||
const url = getURL()
|
||||
|
||||
@@ -78,99 +186,182 @@ function WriteNFCModal({ config }) {
|
||||
} else {
|
||||
setNfcStatus('error')
|
||||
setErrorMessage(
|
||||
'NFC is not supported by this browser. You can still copy the URL and write it to an NFC tag using a compatible device.',
|
||||
'NFC is not supported by this browser. Copy the URL and write it to an NFC tag using a compatible device.',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const renderBody = () => {
|
||||
if (nfcStatus === 'success') {
|
||||
return (
|
||||
<Typography level='body-md' gutterBottom>
|
||||
URL written to NFC tag successfully!
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
const isWaiting = nfcStatus === 'waiting_for_tag' || nfcStatus === 'writing'
|
||||
const isSuccess = nfcStatus === 'success'
|
||||
const isError = nfcStatus === 'error'
|
||||
|
||||
if (nfcStatus === 'waiting_for_tag') {
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
display='flex'
|
||||
flexDirection='column'
|
||||
alignItems='center'
|
||||
gap={2}
|
||||
py={3}
|
||||
>
|
||||
<CircularProgress size='lg' />
|
||||
<Typography level='body-md' textAlign='center'>
|
||||
Hold your device near the NFC tag
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
fullWidth
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
const title = isSuccess
|
||||
? 'Tag written!'
|
||||
: isError
|
||||
? 'Something went wrong'
|
||||
: isWaiting
|
||||
? 'Hold near NFC tag'
|
||||
: 'Write to NFC'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography level='body-md' gutterBottom>
|
||||
{nfcStatus === 'error'
|
||||
? errorMessage
|
||||
: 'Press the button below to write to NFC.'}
|
||||
</Typography>
|
||||
<Input
|
||||
value={getURL()}
|
||||
fullWidth
|
||||
readOnly
|
||||
label='URL'
|
||||
sx={{ mt: 1 }}
|
||||
endDecorator={
|
||||
<CopyAll
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(getURL())
|
||||
alert('URL copied to clipboard!')
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem>
|
||||
<Checkbox
|
||||
checked={isAutoCompleteWhenScan}
|
||||
onChange={e => setIsAutoCompleteWhenScan(e.target.checked)}
|
||||
label='Auto-complete when scanned'
|
||||
/>
|
||||
</ListItem>
|
||||
<Box display='flex' justifyContent='space-around' mt={1}>
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={writeToNFC}
|
||||
fullWidth
|
||||
disabled={nfcStatus === 'writing'}
|
||||
>
|
||||
Write NFC
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
const subtitle = isSuccess
|
||||
? 'Your NFC tag is ready to use.'
|
||||
: isError
|
||||
? errorMessage
|
||||
: isWaiting
|
||||
? 'Keep your device near the tag until complete.'
|
||||
: 'Encode this task link onto any NFC tag.'
|
||||
|
||||
return (
|
||||
<ResponsiveModal open={config?.isOpen} onClose={handleClose}>
|
||||
<Typography level='h4' mb={1}>
|
||||
{nfcStatus === 'success' ? 'Success!' : 'Write to NFC'}
|
||||
</Typography>
|
||||
{renderBody()}
|
||||
</ResponsiveModal>
|
||||
<>
|
||||
<style>{pulseKeyframes}</style>
|
||||
<ResponsiveModal open={config?.isOpen} onClose={handleClose}>
|
||||
<Box sx={{ px: 0.5, pb: 1 }}>
|
||||
{/* Icon */}
|
||||
<NFCIcon status={nfcStatus} />
|
||||
|
||||
{/* Heading */}
|
||||
<Typography
|
||||
level='title-lg'
|
||||
textAlign='center'
|
||||
sx={{ mb: 0.75, fontWeight: 600 }}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
textAlign='center'
|
||||
sx={{ color: 'text.secondary', mb: 3, px: 2 }}
|
||||
>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
|
||||
{/* Idle / Error: URL + toggle + CTA */}
|
||||
{!isWaiting && !isSuccess && (
|
||||
<>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
mb: 0.75,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em',
|
||||
color: 'text.tertiary',
|
||||
}}
|
||||
>
|
||||
Tag URL
|
||||
</Typography>
|
||||
<Input
|
||||
value={getURL()}
|
||||
readOnly
|
||||
size='sm'
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
'--Input-focusedHighlight': 'transparent',
|
||||
bgcolor: 'neutral.50',
|
||||
}}
|
||||
endDecorator={
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color={copied ? 'success' : 'neutral'}
|
||||
onClick={handleCopy}
|
||||
title='Copy URL'
|
||||
>
|
||||
<ContentCopy sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
{copied && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'success.500', mt: 0.5, textAlign: 'right' }}
|
||||
>
|
||||
Copied!
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
py: 1.5,
|
||||
px: 2,
|
||||
borderRadius: 'md',
|
||||
bgcolor: 'neutral.50',
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography level='body-sm' fontWeight={500}>
|
||||
Auto-complete on scan
|
||||
</Typography>
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||
Mark task done when tag is tapped
|
||||
</Typography>
|
||||
</Box>
|
||||
<Switch
|
||||
checked={isAutoCompleteWhenScan}
|
||||
onChange={e => setIsAutoCompleteWhenScan(e.target.checked)}
|
||||
size='sm'
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1.5 }}>
|
||||
<Button
|
||||
size='lg'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
sx={{ flex: 1 }}
|
||||
onClick={isError ? handleClose : handleClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size='lg'
|
||||
sx={{ flex: 1 }}
|
||||
onClick={writeToNFC}
|
||||
disabled={nfcStatus === 'writing'}
|
||||
startDecorator={
|
||||
nfcStatus === 'writing' ? (
|
||||
<CircularProgress size='sm' />
|
||||
) : (
|
||||
<Nfc />
|
||||
)
|
||||
}
|
||||
>
|
||||
{nfcStatus === 'writing' ? 'Starting…' : 'Write tag'}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Waiting state */}
|
||||
{isWaiting && (
|
||||
<Button
|
||||
size='lg'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
fullWidth
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Success state */}
|
||||
{isSuccess && (
|
||||
<Button size='lg' fullWidth onClick={handleClose}>
|
||||
Done
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
import SmartTaskTitleInput from './SmartTaskTitleInput'
|
||||
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import { localAIService } from '../../service/LocalAIService'
|
||||
import { TASK_COLOR } from '../../utils/Colors'
|
||||
import AssigneePickerField from './AssigneePickerField'
|
||||
import AttachmentPickerField from './AttachmentPickerField'
|
||||
@@ -29,12 +31,10 @@ import DueDatePickerField from './DueDatePickerField'
|
||||
import LabelsPickerField from './LabelsPickerField'
|
||||
import LearnMoreButton from './LearnMore'
|
||||
import NotificationPickerField from './NotificationPickerField'
|
||||
import ScanPanel from './ScanToTask/ScanPanel'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import { localAIService } from '../../service/LocalAIService'
|
||||
import PriorityPickerField from './PriorityPickerField'
|
||||
import RepeatPickerField from './RepeatPickerField'
|
||||
import RichTextEditor from './RichTextEditor'
|
||||
import ScanPanel from './ScanToTask/ScanPanel'
|
||||
import SubTasks from './SubTask'
|
||||
const getDefaultNotification = () => {
|
||||
const storedDefault = localStorage.getItem('defaultNotificationTemplate')
|
||||
@@ -558,7 +558,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
||||
createChore()
|
||||
}
|
||||
|
||||
const handleTaskExtracted = ({ taskName, description: extractedDesc, dueDate: extractedDue }) => {
|
||||
const handleTaskExtracted = ({
|
||||
taskName,
|
||||
description: extractedDesc,
|
||||
dueDate: extractedDue,
|
||||
}) => {
|
||||
if (taskName) {
|
||||
processText(taskName)
|
||||
}
|
||||
@@ -700,314 +704,330 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveModal
|
||||
open={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
size='lg'
|
||||
fullWidth={true}
|
||||
title='Create new task'
|
||||
footer={
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'end',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size='lg'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
onClick={handleCloseModal}
|
||||
>
|
||||
Cancel
|
||||
{showKeyboardShortcuts && (
|
||||
<KeyboardShortcutHint
|
||||
shortcut='Esc'
|
||||
sx={{ ml: 1 }}
|
||||
withCtrl={false}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size='lg'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
disabled={!taskTitle.trim()}
|
||||
onClick={createChore}
|
||||
>
|
||||
Create
|
||||
{showKeyboardShortcuts && (
|
||||
<KeyboardShortcutHint shortcut='Enter' sx={{ ml: 1 }} />
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{!showScan && (
|
||||
<>
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>Task in a sentence:</Typography>
|
||||
<LearnMoreButton
|
||||
content={
|
||||
<>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
This feature lets you create a task simply by typing a
|
||||
sentence. It attempt parses the sentence to identify the
|
||||
task's due date, priority, and frequency.
|
||||
</Typography>
|
||||
|
||||
<Typography level='body-sm' sx={{ fontWeight: 'bold', mt: 2 }}>
|
||||
Examples:
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
level='body-sm'
|
||||
component='ul'
|
||||
sx={{ pl: 2, mt: 1, listStyle: 'disc' }}
|
||||
>
|
||||
<li>
|
||||
<strong>Priority:</strong>For highest priority any of the
|
||||
following keyword <em>P1</em>, <em>Urgent</em>,{' '}
|
||||
<em>Important</em>, or <em>ASAP</em>. For lower
|
||||
priorities, use <em>P2</em>, <em>P3</em>, or <em>P4</em>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Due date:</strong> Specify dates with phrases
|
||||
like <em>tomorrow</em>, <em>next week</em>,{' '}
|
||||
<em>Monday</em>, or <em>August 1st at 12pm</em>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Frequency:</strong> Set recurring tasks with
|
||||
terms like <em>daily</em>, <em>weekly</em>,{' '}
|
||||
<em>monthly</em>, <em>yearly</em>, or patterns such as{' '}
|
||||
<em>every Tuesday and Thursday</em>.
|
||||
</li>
|
||||
</Typography>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<SmartTaskTitleInput
|
||||
autoFocus
|
||||
value={taskText}
|
||||
isNativeScanner={isNativeScanner}
|
||||
onScanClick={llmAvailable ? () => {
|
||||
setScanAutoCapture(true)
|
||||
setShowScan(true)
|
||||
} : undefined}
|
||||
onPhotoSelected={llmAvailable ? dataUrl => {
|
||||
setScanAutoCapture(false)
|
||||
setPendingPhotoUrl(dataUrl)
|
||||
setShowScan(true)
|
||||
} : undefined}
|
||||
placeholder='Type your task...'
|
||||
onChange={text => {
|
||||
setTaskText(text)
|
||||
if (!text) setTaskTitle('')
|
||||
}}
|
||||
customRenderer={renderedParts}
|
||||
onEnterPressed={handleEnterPressed}
|
||||
suggestions={{
|
||||
'#': {
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: userLabels ? userLabels : [],
|
||||
},
|
||||
'!': {
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: [
|
||||
{ id: '1', name: 'P1' },
|
||||
{ id: '2', name: 'P2' },
|
||||
{ id: '3', name: 'P3' },
|
||||
{ id: '4', name: 'P4' },
|
||||
],
|
||||
},
|
||||
'@': {
|
||||
value: 'userId',
|
||||
display: 'displayName',
|
||||
options: [
|
||||
{ userId: 'anyone', displayName: 'Anyone' },
|
||||
...(circleMembers?.res || []),
|
||||
],
|
||||
},
|
||||
'*': {
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: [
|
||||
{ id: '1', name: '1 point' },
|
||||
{ id: '5', name: '5 points' },
|
||||
{ id: '10', name: '10 points' },
|
||||
{ id: '25', name: '25 points' },
|
||||
{ id: '50', name: '50 points' },
|
||||
{ id: '100', name: '100 points' },
|
||||
],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<ResponsiveModal
|
||||
open={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
size='lg'
|
||||
fullWidth={true}
|
||||
title='Create new task'
|
||||
footer={
|
||||
<Box
|
||||
sx={{
|
||||
paddingTop: 2,
|
||||
paddingBottom: 1,
|
||||
marginTop: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 1.5,
|
||||
overflowX: 'auto',
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
flexWrap: isMobile ? 'nowrap' : 'wrap',
|
||||
justifyContent: 'end',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<DueDatePickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
dueDateOnly={dueDateOnly}
|
||||
dueTime={dueTime}
|
||||
useCustomTime={useCustomTime}
|
||||
onDueDateChange={handleDueDateChange}
|
||||
onDueTimeChange={handleDueTimeChange}
|
||||
onUseCustomTimeChange={handleUseCustomTimeChange}
|
||||
onClear={() => {
|
||||
setDueDate(null)
|
||||
setDueDateOnly(null)
|
||||
setDueTime(null)
|
||||
setUseCustomTime(false)
|
||||
}}
|
||||
/>
|
||||
<RepeatPickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
value={frequency}
|
||||
onChange={setFrequency}
|
||||
onClear={() => setFrequency(null)}
|
||||
/>
|
||||
<PriorityPickerField
|
||||
value={priority}
|
||||
onChange={setPriority}
|
||||
onClear={() => setPriority(0)}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
priorityColors={priorityColors}
|
||||
priorityLabels={priorityLabels}
|
||||
/>
|
||||
<AssigneePickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
value={assignees?.[0]?.userId || null}
|
||||
onChange={userId => {
|
||||
if (!userId) {
|
||||
setAssignees([])
|
||||
} else {
|
||||
setAssignees([{ userId }])
|
||||
}
|
||||
}}
|
||||
onClear={() => setAssignees([])}
|
||||
currentUserId={userProfile?.id}
|
||||
members={circleMembers?.res || []}
|
||||
/>
|
||||
<LabelsPickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
values={labelsV2 || []}
|
||||
onChange={setLabelsV2}
|
||||
onClear={() => setLabelsV2([])}
|
||||
labels={userLabels || []}
|
||||
/>
|
||||
<AttachmentPickerField
|
||||
attachments={attachments}
|
||||
onChange={setAttachments}
|
||||
onClear={() => setAttachments([])}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
entityType='chore_attachment_draft'
|
||||
draftId={draftId}
|
||||
/>
|
||||
<NotificationPickerField
|
||||
value={notificationMetadata}
|
||||
onChange={setNotificationMetadata}
|
||||
onClear={() => setNotificationMetadata({ templates: [] })}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box mt={2} sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
|
||||
{!hasDescription && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='md'
|
||||
onClick={() => setHasDescription(true)}
|
||||
endDecorator={
|
||||
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='E' />
|
||||
}
|
||||
>
|
||||
Description
|
||||
</Button>
|
||||
)}
|
||||
{!hasSubTasks && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='md'
|
||||
onClick={() => setHasSubTasks(true)}
|
||||
endDecorator={
|
||||
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='J' />
|
||||
}
|
||||
>
|
||||
Subtasks
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{hasDescription && (
|
||||
<Box>
|
||||
<Typography level='body-sm'>Description:</Typography>
|
||||
<div>
|
||||
<RichTextEditor
|
||||
ref={richTextEditorRef}
|
||||
onChange={setDescription}
|
||||
value={description || ''}
|
||||
entityType={'chore_description'}
|
||||
<Button
|
||||
size='lg'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
onClick={handleCloseModal}
|
||||
>
|
||||
Cancel
|
||||
{showKeyboardShortcuts && (
|
||||
<KeyboardShortcutHint
|
||||
shortcut='Esc'
|
||||
sx={{ ml: 1 }}
|
||||
withCtrl={false}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
{hasSubTasks && (
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size='lg'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
disabled={!taskTitle.trim()}
|
||||
onClick={createChore}
|
||||
>
|
||||
Create
|
||||
{showKeyboardShortcuts && (
|
||||
<KeyboardShortcutHint shortcut='Enter' sx={{ ml: 1 }} />
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{!showScan && (
|
||||
<>
|
||||
<Box>
|
||||
<Typography level='body-sm'>Subtasks:</Typography>
|
||||
<SubTasks
|
||||
editMode={true}
|
||||
tasks={subTasks ? subTasks : []}
|
||||
setTasks={setSubTasks}
|
||||
shouldFocus={true}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>Task in a sentence:</Typography>
|
||||
<LearnMoreButton
|
||||
content={
|
||||
<>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
This feature lets you create a task simply by typing a
|
||||
sentence. It attempt parses the sentence to identify the
|
||||
task's due date, priority, and frequency.
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ fontWeight: 'bold', mt: 2 }}
|
||||
>
|
||||
Examples:
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
level='body-sm'
|
||||
component='ul'
|
||||
sx={{ pl: 2, mt: 1, listStyle: 'disc' }}
|
||||
>
|
||||
<li>
|
||||
<strong>Priority:</strong>For highest priority any of
|
||||
the following keyword <em>P1</em>, <em>Urgent</em>,{' '}
|
||||
<em>Important</em>, or <em>ASAP</em>. For lower
|
||||
priorities, use <em>P2</em>, <em>P3</em>, or{' '}
|
||||
<em>P4</em>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Due date:</strong> Specify dates with phrases
|
||||
like <em>tomorrow</em>, <em>next week</em>,{' '}
|
||||
<em>Monday</em>, or <em>August 1st at 12pm</em>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Frequency:</strong> Set recurring tasks with
|
||||
terms like <em>daily</em>, <em>weekly</em>,{' '}
|
||||
<em>monthly</em>, <em>yearly</em>, or patterns such as{' '}
|
||||
<em>every Tuesday and Thursday</em>.
|
||||
</li>
|
||||
</Typography>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<SmartTaskTitleInput
|
||||
autoFocus
|
||||
value={taskText}
|
||||
isNativeScanner={isNativeScanner}
|
||||
onScanClick={
|
||||
llmAvailable
|
||||
? () => {
|
||||
setScanAutoCapture(true)
|
||||
setShowScan(true)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPhotoSelected={
|
||||
llmAvailable
|
||||
? dataUrl => {
|
||||
setScanAutoCapture(false)
|
||||
setPendingPhotoUrl(dataUrl)
|
||||
setShowScan(true)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
placeholder='Type your task...'
|
||||
onChange={text => {
|
||||
setTaskText(text)
|
||||
if (!text) setTaskTitle('')
|
||||
}}
|
||||
customRenderer={renderedParts}
|
||||
onEnterPressed={handleEnterPressed}
|
||||
suggestions={{
|
||||
'#': {
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: userLabels ? userLabels : [],
|
||||
},
|
||||
'!': {
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: [
|
||||
{ id: '1', name: 'P1' },
|
||||
{ id: '2', name: 'P2' },
|
||||
{ id: '3', name: 'P3' },
|
||||
{ id: '4', name: 'P4' },
|
||||
],
|
||||
},
|
||||
'@': {
|
||||
value: 'userId',
|
||||
display: 'displayName',
|
||||
options: [
|
||||
{ userId: 'anyone', displayName: 'Anyone' },
|
||||
...(circleMembers?.res || []),
|
||||
],
|
||||
},
|
||||
'*': {
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: [
|
||||
{ id: '1', name: '1 point' },
|
||||
{ id: '5', name: '5 points' },
|
||||
{ id: '10', name: '10 points' },
|
||||
{ id: '25', name: '25 points' },
|
||||
{ id: '50', name: '50 points' },
|
||||
{ id: '100', name: '100 points' },
|
||||
],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showScan && (
|
||||
<ScanPanel
|
||||
open
|
||||
autoCapture={scanAutoCapture}
|
||||
onTaskExtracted={handleTaskExtracted}
|
||||
initialImageUrl={pendingPhotoUrl}
|
||||
onClose={() => {
|
||||
setShowScan(false)
|
||||
setScanAutoCapture(false)
|
||||
setPendingPhotoUrl(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ResponsiveModal>
|
||||
<Box
|
||||
sx={{
|
||||
paddingTop: 2,
|
||||
paddingBottom: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 1.5,
|
||||
overflowX: 'auto',
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
flexWrap: isMobile ? 'nowrap' : 'wrap',
|
||||
}}
|
||||
>
|
||||
<DueDatePickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
dueDateOnly={dueDateOnly}
|
||||
dueTime={dueTime}
|
||||
useCustomTime={useCustomTime}
|
||||
onDueDateChange={handleDueDateChange}
|
||||
onDueTimeChange={handleDueTimeChange}
|
||||
onUseCustomTimeChange={handleUseCustomTimeChange}
|
||||
onClear={() => {
|
||||
setDueDate(null)
|
||||
setDueDateOnly(null)
|
||||
setDueTime(null)
|
||||
setUseCustomTime(false)
|
||||
}}
|
||||
/>
|
||||
<RepeatPickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
value={frequency}
|
||||
onChange={setFrequency}
|
||||
onClear={() => setFrequency(null)}
|
||||
/>
|
||||
<PriorityPickerField
|
||||
value={priority}
|
||||
onChange={setPriority}
|
||||
onClear={() => setPriority(0)}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
priorityColors={priorityColors}
|
||||
priorityLabels={priorityLabels}
|
||||
/>
|
||||
<AssigneePickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
value={assignees?.[0]?.userId || null}
|
||||
onChange={userId => {
|
||||
if (!userId) {
|
||||
setAssignees([])
|
||||
} else {
|
||||
setAssignees([{ userId }])
|
||||
}
|
||||
}}
|
||||
onClear={() => setAssignees([])}
|
||||
currentUserId={userProfile?.id}
|
||||
members={circleMembers?.res || []}
|
||||
/>
|
||||
<LabelsPickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
values={labelsV2 || []}
|
||||
onChange={setLabelsV2}
|
||||
onClear={() => setLabelsV2([])}
|
||||
labels={userLabels || []}
|
||||
/>
|
||||
<AttachmentPickerField
|
||||
attachments={attachments}
|
||||
onChange={setAttachments}
|
||||
onClear={() => setAttachments([])}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
entityType='chore_attachment_draft'
|
||||
draftId={draftId}
|
||||
/>
|
||||
<NotificationPickerField
|
||||
value={notificationMetadata}
|
||||
onChange={setNotificationMetadata}
|
||||
onClear={() => setNotificationMetadata({ templates: [] })}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box mt={2} sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
|
||||
{!hasDescription && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='md'
|
||||
onClick={() => setHasDescription(true)}
|
||||
endDecorator={
|
||||
showKeyboardShortcuts && (
|
||||
<KeyboardShortcutHint shortcut='E' />
|
||||
)
|
||||
}
|
||||
>
|
||||
Description
|
||||
</Button>
|
||||
)}
|
||||
{!hasSubTasks && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='md'
|
||||
onClick={() => setHasSubTasks(true)}
|
||||
endDecorator={
|
||||
showKeyboardShortcuts && (
|
||||
<KeyboardShortcutHint shortcut='J' />
|
||||
)
|
||||
}
|
||||
>
|
||||
Subtasks
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{hasDescription && (
|
||||
<Box>
|
||||
<Typography level='body-sm'>Description:</Typography>
|
||||
<div>
|
||||
<RichTextEditor
|
||||
ref={richTextEditorRef}
|
||||
onChange={setDescription}
|
||||
value={description || ''}
|
||||
entityType={'chore_description'}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
{hasSubTasks && (
|
||||
<Box>
|
||||
<Typography level='body-sm'>Subtasks:</Typography>
|
||||
<SubTasks
|
||||
editMode={true}
|
||||
tasks={subTasks ? subTasks : []}
|
||||
setTasks={setSubTasks}
|
||||
shouldFocus={true}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showScan && (
|
||||
<ScanPanel
|
||||
open
|
||||
autoCapture={scanAutoCapture}
|
||||
onTaskExtracted={handleTaskExtracted}
|
||||
initialImageUrl={pendingPhotoUrl}
|
||||
onClose={() => {
|
||||
setShowScan(false)
|
||||
setScanAutoCapture(false)
|
||||
setPendingPhotoUrl(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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