Files
donetick/src/views/components/AddTaskModal.jsx

678 lines
21 KiB
JavaScript

import { Add, EditNotifications } from '@mui/icons-material'
import {
Box,
Button,
Chip,
Input,
Modal,
ModalDialog,
ModalOverflow,
Option,
Select,
Typography,
} from '@mui/joy'
import { FormControl } from '@mui/material'
import * as chrono from 'chrono-node'
import moment from 'moment'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCreateChore } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { isPlusAccount } from '../../utils/Helpers'
import { useLabels } from '../Labels/LabelQueries'
import {
parseDueDate,
parseLabels,
parsePriority,
parseRepeatV2,
} from './CustomParsers'
import SmartTaskTitleInput from './SmartTaskTitleInput'
import NotificationTemplate from '../../components/NotificationTemplate'
import LearnMoreButton from './LearnMore'
import RichTextEditor from './RichTextEditor'
import SubTasks from './SubTask'
const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
const { data: circleMembers, isLoading: isCircleMembersLoading } =
useCircleMembers()
const createChoreMutation = useCreateChore()
const { data: userProfile } = useUserProfile()
const [taskText, setTaskText] = useState('')
const [taskTitle, setTaskTitle] = useState('')
const [renderedParts, setRenderedParts] = useState([])
const textareaRef = useRef(null)
const mainInputRef = useRef(null)
const [priority, setPriority] = useState(0)
const [dueDate, setDueDate] = useState(null)
const [description, setDescription] = useState(null)
const [assignees, setAssignees] = useState([])
const [labelsV2, setLabelsV2] = useState([])
const [frequency, setFrequency] = useState(null)
const [notificationMetadata, setNotificationMetadata] = useState({
templates: [],
})
const [frequencyHumanReadable, setFrequencyHumanReadable] = useState(null)
const [subTasks, setSubTasks] = useState(null)
const [hasDescription, setHasDescription] = useState(false)
const [hasSubTasks, setHasSubTasks] = useState(false)
const [hasNotifications, setHasNotifications] = useState(false)
useEffect(() => {
if (isModalOpen && textareaRef.current) {
textareaRef.current.focus()
textareaRef.current.selectionStart = textareaRef.current.value?.length
textareaRef.current.selectionEnd = textareaRef.current.value?.length
}
}, [isModalOpen])
useEffect(() => {
if (autoFocus > 0 && mainInputRef.current) {
mainInputRef.current.focus()
mainInputRef.current.selectionStart = mainInputRef.current.value?.length
mainInputRef.current.selectionEnd = mainInputRef.current.value?.length
}
}, [autoFocus])
const renderHighlightedSentence = useCallback(
(
sentence,
repeatHighlight,
priorityHighlight,
labelsHighlight,
dueDateHighlight,
) => {
const parts = []
let lastIndex = 0
let plainText = ''
// Combine all highlight ranges and sort them by their start index
const allHighlights = []
if (repeatHighlight) {
repeatHighlight.forEach(h =>
allHighlights.push({ ...h, type: 'repeat', priority: 40 }),
)
}
if (priorityHighlight) {
priorityHighlight.forEach(h =>
allHighlights.push({ ...h, type: 'priority', priority: 30 }),
)
}
if (labelsHighlight) {
labelsHighlight.forEach(h =>
allHighlights.push({ ...h, type: 'label', priority: 20 }),
)
}
if (dueDateHighlight) {
allHighlights.push({
...dueDateHighlight,
type: 'dueDate',
priority: 10,
})
}
allHighlights.sort((a, b) => a.start - b.start)
const resolvedHighlights = []
for (let i = 0; i < allHighlights.length; i++) {
const current = allHighlights[i]
const previous = resolvedHighlights[resolvedHighlights.length - 1]
if (previous && current.start < previous.end) {
if (current.priority > previous.priority) {
resolvedHighlights.pop()
resolvedHighlights.push(current)
}
} else {
// No overlap, add the current highlight
resolvedHighlights.push(current)
}
}
for (const highlight of resolvedHighlights) {
// Add the text before the highlight
if (highlight.start > lastIndex) {
const textBefore = sentence.substring(lastIndex, highlight.start)
parts.push(textBefore)
plainText += textBefore
}
// Determine the class name based on the highlight type
let className = ''
switch (highlight.type) {
case 'repeat':
className = 'highlight-repeat'
break
case 'priority':
className = 'highlight-priority'
break
case 'label':
className = 'highlight-label'
break
case 'dueDate':
className = 'highlight-date'
break
default:
break
}
// Add the highlighted span
const highlightedText = sentence.substring(
highlight.start,
highlight.end,
)
parts.push(
<span
key={highlight.start}
className={className}
style={{
// text underline:
textDecoration: 'underline',
// textDecorationColor: 'red',
textDecorationThickness: '2px',
textDecorationStyle: 'dashed',
}}
>
{highlightedText}
</span>,
)
// Update the last index to the end of the current highlight
lastIndex = highlight.end
}
// Add any remaining text after the last highlight
if (lastIndex < sentence.length) {
const remainingText = sentence.substring(lastIndex)
parts.push(remainingText)
plainText += remainingText
}
return {
parts,
plainText,
}
},
[],
)
const processText = useCallback(
sentence => {
let cleanedSentence = sentence
const priority = parsePriority(sentence)
if (priority.result) setPriority(priority.result)
cleanedSentence = priority.cleanedSentence
const labels = parseLabels(sentence, userLabels)
if (labels.result) {
cleanedSentence = labels.cleanedSentence
setLabelsV2(labels.result)
}
const repeat = parseRepeatV2(sentence)
if (repeat.result) {
setFrequency(repeat.result)
setFrequencyHumanReadable(repeat.name)
cleanedSentence = repeat.cleanedSentence
}
// Parse assignees using circle members
// const circleMembersList = circleMembers?.res || []
// const assigneesForParsing = circleMembersList.map(member => ({
// userId: member.userId,
// username:
// member.username ||
// member.displayName?.toLowerCase().replace(/\s+/g, ''),
// displayName: member.displayName,
// name: member.displayName,
// id: member.userId,
// }))
// const assigneesResult = parseAssignees(sentence, assigneesForParsing)
// if (assigneesResult.result) {
// cleanedSentence = assigneesResult.cleanedSentence
// setAssignees(
// assigneesResult.result.map(assignee => ({
// userId: assignee.userId,
// })),
// )
// } else {
// setAssignees([
// {
// userId: userProfile.id,
// },
// ])
// }
// Parse due date
const dueDateParsed = parseDueDate(sentence, chrono)
let dueDateHighlight = null
if (dueDateParsed.result) {
setDueDate(moment(dueDateParsed.result).format('YYYY-MM-DDTHH:mm:ss'))
cleanedSentence = dueDateParsed.cleanedSentence
dueDateHighlight = dueDateParsed.highlight[0]
}
if (repeat.result) {
// if repeat has result the cleaned sentence will remove the date related info which mean
// we need to reparse the date again to get the correct due date:
const dueDateParsedAgain = parseDueDate(sentence, chrono)
if (dueDateParsedAgain.result) {
setDueDate(
moment(dueDateParsedAgain.result).format('YYYY-MM-DDTHH:mm:ss'),
)
}
}
setTaskText(sentence)
setTaskTitle(cleanedSentence.trim())
const { parts, plainText } = renderHighlightedSentence(
sentence,
repeat.highlight,
priority.highlight,
labels.highlight,
dueDateHighlight,
)
setRenderedParts(parts)
setTaskTitle(plainText)
},
[userLabels, renderHighlightedSentence],
)
useEffect(() => {
if (!isModalOpen || userLabelsLoading || isCircleMembersLoading) {
return
}
processText(taskText)
}, [
taskText,
userLabelsLoading,
isCircleMembersLoading,
isModalOpen,
processText,
])
const handleEnterPressed = () => {
createChore()
}
const handleCloseModal = forceRefetch => {
onClose(forceRefetch)
setTaskText('')
setTaskTitle('')
setDueDate(null)
setFrequency(null)
setFrequencyHumanReadable(null)
setPriority(0)
setHasDescription(false)
setDescription(null)
setSubTasks(null)
setHasSubTasks(false)
setLabelsV2([])
setAssignees([])
}
const handleSubmit = () => {
console.log('Submitting task:', isPlusAccount(userProfile))
// createChore()
// handleCloseModal()
// setTaskText('')
}
const createChore = () => {
const chore = {
name: taskTitle,
assignees:
assignees.length > 0 ? assignees : [{ userId: userProfile.id }],
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
assignedTo: assignees.length > 0 ? assignees[0].userId : userProfile.id,
assignStrategy: 'random',
isRolling: false,
labelsV2: labelsV2,
priority: priority ? Number(priority) : 0,
status: 0,
frequencyType: 'once',
frequencyMetadata: {},
notificationMetadata: {},
subTasks: subTasks?.length > 0 ? subTasks : null,
}
if (frequency) {
chore.frequencyType = frequency.frequencyType
chore.frequencyMetadata = frequency.frequencyMetadata
chore.frequency = frequency.frequency
if (isPlusAccount(userProfile)) {
chore.notification = true
chore.notificationMetadata = notificationMetadata
}
}
if (!frequency && dueDate) {
// use dueDate converted to UTC:
chore.nextDueDate = new Date(dueDate).toUTCString()
}
createChoreMutation
.mutateAsync(chore)
.then(resp => {
resp.json().then(data => {
if (resp.status !== 200) {
console.error('Error creating chore:', data)
return
} else {
onChoreUpdate({
...chore,
id: data.res,
nextDueDate: chore.dueDate,
})
handleCloseModal(false)
}
})
})
.catch(error => {
if (error?.queued) {
handleCloseModal(true)
}
})
}
if (userLabelsLoading || isCircleMembersLoading) {
return <></>
}
return (
<Modal open={isModalOpen} onClose={handleCloseModal}>
<ModalOverflow>
<ModalDialog size='lg' sx={{ minWidth: '100%' }}>
<Typography level='h4'>Create new task</Typography>
<Chip startDecorator='🚧' variant='soft' color='warning' size='sm'>
Experimental Feature
</Chip>
<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&apos;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}
placeholder='Type your full text here...'
onChange={text => {
setTaskText(text)
}}
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: circleMembers?.res || [],
},
}}
/>
</Box>
{/* <Box>
<Typography level='body-sm'>Title:</Typography>
<Input
value={taskTitle}
onChange={e => setTaskTitle(e.target.value)}
sx={{ width: '100%', fontSize: '16px' }}
/>
</Box> */}
<Box>
{!hasDescription && (
<Button
startDecorator={<Add />}
variant='plain'
size='sm'
onClick={() => setHasDescription(true)}
>
Description
</Button>
)}
{!hasSubTasks && (
<Button
startDecorator={<Add />}
variant='plain'
size='sm'
onClick={() => setHasSubTasks(true)}
>
Subtasks
</Button>
)}
{!dueDate && (
<Button
startDecorator={<Add />}
variant='plain'
size='sm'
onClick={() => {
setDueDate(
moment().add(1, 'day').format('YYYY-MM-DDTHH:00:00'),
)
}}
>
Due Date
</Button>
)}
{!hasNotifications && dueDate && (
<Button
startDecorator={<EditNotifications />}
variant='plain'
size='sm'
onClick={() => {
setHasNotifications(true)
setFrequencyHumanReadable('Once')
setFrequency(null)
setDueDate(
moment().add(1, 'day').format('YYYY-MM-DDTHH:00:00'),
)
}}
>
Edit Notifications
</Button>
)}
</Box>
{hasDescription && (
<Box>
<Typography level='body-sm'>Description:</Typography>
<div>
<RichTextEditor
onChange={setDescription}
entityType={'chore_description'}
/>
</div>
</Box>
)}
{hasSubTasks && (
<Box>
<Typography level='body-sm'>Subtasks:</Typography>
<SubTasks
editMode={true}
tasks={subTasks ? subTasks : []}
setTasks={setSubTasks}
/>
</Box>
)}
<Box
sx={{
marginTop: 2,
display: 'flex',
flexDirection: 'row',
gap: 2,
}}
>
<FormControl>
<Typography level='body-sm'>Priority</Typography>
<Select
defaultValue={0}
value={priority}
onChange={(e, value) => setPriority(value)}
>
<Option value='0'>No Priority</Option>
<Option value='1'>P1</Option>
<Option value='2'>P2</Option>
<Option value='3'>P3</Option>
<Option value='4'>P4</Option>
</Select>
</FormControl>
{dueDate && (
<FormControl>
<Typography level='body-sm'>Due Date</Typography>
<Input
type='datetime-local'
value={dueDate}
onChange={e => setDueDate(e.target.value)}
sx={{ width: '100%', fontSize: '16px' }}
/>
</FormControl>
)}
</Box>
<Box
sx={{
marginTop: 2,
display: 'flex',
flexDirection: 'row',
justifyContent: 'start',
gap: 2,
}}
>
{/* <FormControl>
<Typography level='body-sm'>Assignees</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{assignees.length > 0 ? (
assignees.map((assignee, index) => (
<Chip
key={assignee.userId || index}
variant='soft'
size='lg'
color='primary'
>
{assignee.displayName || assignee.username}
</Chip>
))
) : (
<Chip variant='soft' size='sm' color='neutral'>
{userProfile.displayName}
</Chip>
)}
</Box>
</FormControl> */}
{hasNotifications && dueDate && (
<Box
sx={{
flexDirection: 'column',
alignItems: 'center',
}}
>
<Typography level='body-sm'>Notification Schedule</Typography>
<Box sx={{ p: 0.5 }}>
<NotificationTemplate
onChange={metadata => {
const newNotificaitonMetadata = {
...notificationMetadata,
templates: metadata.notifications,
}
setNotificationMetadata(newNotificaitonMetadata)
}}
value={notificationMetadata}
showTimeline={false}
/>
</Box>
</Box>
)}
</Box>
<Box
sx={{
marginTop: 2,
display: 'flex',
flexDirection: 'row',
justifyContent: 'end',
gap: 1,
}}
>
<Button
variant='outlined'
color='neutral'
onClick={handleCloseModal}
>
Cancel
</Button>
<Button variant='solid' color='primary' onClick={handleSubmit}>
Create
</Button>
</Box>
</ModalDialog>
</ModalOverflow>
</Modal>
)
}
export default TaskInput