- Support nest sub tasks
- Support filters in ChoreGrouper - completion window only available if due date selected - Add SortAndGrouping Component : support Filter by Assignee - update Notification Switch to align left of the text - Support caching filters
This commit is contained in:
parent
bbea27d380
commit
a16309c69a
9 changed files with 679 additions and 224 deletions
|
@ -3,7 +3,11 @@ import { TASK_COLOR } from './Colors.jsx'
|
|||
|
||||
const priorityOrder = [1, 2, 3, 4, 0]
|
||||
|
||||
export const ChoresGrouper = (groupBy, chores) => {
|
||||
export const ChoresGrouper = (groupBy, chores, filter) => {
|
||||
if (filter) {
|
||||
chores = chores.filter(chore => filter(chore))
|
||||
}
|
||||
|
||||
// sort by priority then due date:
|
||||
chores.sort(ChoreSorter)
|
||||
var groups = []
|
||||
|
@ -172,3 +176,12 @@ export const notInCompletionWindow = chore => {
|
|||
moment().add(chore.completionWindow, 'hours') < moment(chore.nextDueDate)
|
||||
)
|
||||
}
|
||||
export const ChoreFilters = userProfile => ({
|
||||
anyone: () => true,
|
||||
assigned_to_me: chore => {
|
||||
return chore.assignedTo && chore.assignedTo === userProfile.id
|
||||
},
|
||||
assigned_to_others: chore => {
|
||||
return chore.assignedTo && chore.assignedTo !== userProfile.id
|
||||
},
|
||||
})
|
||||
|
|
|
@ -208,7 +208,9 @@ const ChoreEdit = () => {
|
|||
notificationMetadata: notificationMetadata,
|
||||
thingTrigger: thingTrigger,
|
||||
points: points < 0 ? null : points,
|
||||
completionWindow: completionWindow < 0 ? null : completionWindow,
|
||||
completionWindow:
|
||||
// if completionWindow is -1 then set it to null or dueDate is null
|
||||
completionWindow < 0 || dueDate === null ? null : completionWindow,
|
||||
priority: priority,
|
||||
}
|
||||
let SaveFunction = CreateChore
|
||||
|
@ -596,7 +598,8 @@ const ChoreEdit = () => {
|
|||
<FormHelperText>{errors.dueDate}</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{dueDate && (
|
||||
<>
|
||||
<FormControl orientation='horizontal'>
|
||||
<Switch
|
||||
checked={completionWindow != -1}
|
||||
|
@ -653,6 +656,8 @@ const ChoreEdit = () => {
|
|||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
{!['once', 'no_repeat'].includes(frequencyType) && (
|
||||
<Box mt={2}>
|
||||
|
@ -975,7 +980,12 @@ const ChoreEdit = () => {
|
|||
p: 1,
|
||||
}}
|
||||
>
|
||||
<SubTasks editMode={true} tasks={subTasks} setTasks={setSubTasks} />
|
||||
<SubTasks
|
||||
editMode={true}
|
||||
tasks={subTasks}
|
||||
setTasks={setSubTasks}
|
||||
choreId={choreId}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
<FormControl sx={{ mt: 1 }}>
|
||||
|
|
|
@ -44,7 +44,7 @@ import { useLabels } from '../Labels/LabelQueries'
|
|||
import ChoreCard from './ChoreCard'
|
||||
import IconButtonWithMenu from './IconButtonWithMenu'
|
||||
|
||||
import { ChoresGrouper, ChoreSorter } from '../../utils/Chores'
|
||||
import { ChoreFilters, ChoresGrouper, ChoreSorter } from '../../utils/Chores'
|
||||
import TaskInput from '../components/AddTaskModal'
|
||||
import {
|
||||
canScheduleNotification,
|
||||
|
@ -52,6 +52,7 @@ import {
|
|||
} from './LocalNotificationScheduler'
|
||||
import NotificationAccessSnackbar from './NotificationAccessSnackbar'
|
||||
import Sidepanel from './Sidepanel'
|
||||
import SortAndGrouping from './SortAndGrouping'
|
||||
|
||||
const MyChores = () => {
|
||||
const { userProfile, setUserProfile } = useContext(UserContext)
|
||||
|
@ -60,7 +61,7 @@ const MyChores = () => {
|
|||
const [chores, setChores] = useState([])
|
||||
const [archivedChores, setArchivedChores] = useState(null)
|
||||
const [filteredChores, setFilteredChores] = useState([])
|
||||
const [selectedFilter, setSelectedFilter] = useState('All')
|
||||
const [searchFilter, setSearchFilter] = useState('All')
|
||||
const [choreSections, setChoreSections] = useState([])
|
||||
const [activeTextField, setActiveTextField] = useState('task')
|
||||
const [taskInputFocus, setTaskInputFocus] = useState(0)
|
||||
|
@ -72,6 +73,9 @@ const MyChores = () => {
|
|||
const [openChoreSections, setOpenChoreSections] = useState(
|
||||
JSON.parse(localStorage.getItem('openChoreSections')) || {},
|
||||
)
|
||||
const [selectedChoreFilter, setSelectedChoreFilter] = useState(
|
||||
JSON.parse(localStorage.getItem('selectedChoreFilter')) || 'anyone',
|
||||
)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [performers, setPerformers] = useState([])
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
|
@ -172,6 +176,10 @@ const MyChores = () => {
|
|||
setOpenChoreSections(value)
|
||||
localStorage.setItem('openChoreSections', JSON.stringify(value))
|
||||
}
|
||||
const setSelectedChoreFilterWithCache = value => {
|
||||
setSelectedChoreFilter(value)
|
||||
localStorage.setItem('selectedChoreFilter', JSON.stringify(value))
|
||||
}
|
||||
|
||||
const updateChores = newChore => {
|
||||
const newChores = chores
|
||||
|
@ -179,7 +187,7 @@ const MyChores = () => {
|
|||
setChores(newChores)
|
||||
setFilteredChores(newChores)
|
||||
setChoreSections(ChoresGrouper(selectedChoreSection, newChores))
|
||||
setSelectedFilter('All')
|
||||
setSearchFilter('All')
|
||||
}
|
||||
const handleMenuOutsideClick = event => {
|
||||
if (
|
||||
|
@ -208,14 +216,14 @@ const MyChores = () => {
|
|||
),
|
||||
)
|
||||
setFilteredChores(labelFiltered)
|
||||
setSelectedFilter('Label: ' + label.name)
|
||||
setSearchFilter('Label: ' + label.name)
|
||||
} else if (chipClicked.priority) {
|
||||
const priority = chipClicked.priority
|
||||
const priorityFiltered = chores.filter(
|
||||
chore => chore.priority === priority,
|
||||
)
|
||||
setFilteredChores(priorityFiltered)
|
||||
setSelectedFilter('Priority: ' + priority)
|
||||
setSearchFilter('Priority: ' + priority)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -305,8 +313,8 @@ const MyChores = () => {
|
|||
)
|
||||
|
||||
const handleSearchChange = e => {
|
||||
if (selectedFilter !== 'All') {
|
||||
setSelectedFilter('All')
|
||||
if (searchFilter !== 'All') {
|
||||
setSearchFilter('All')
|
||||
}
|
||||
const search = e.target.value
|
||||
if (search === '') {
|
||||
|
@ -418,17 +426,28 @@ const MyChores = () => {
|
|||
<Search />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
<IconButtonWithMenu
|
||||
<SortAndGrouping
|
||||
title='Group by'
|
||||
k={'icon-menu-group-by'}
|
||||
icon={<Sort />}
|
||||
options={[
|
||||
{ name: 'Due Date', value: 'due_date' },
|
||||
{ name: 'Priority', value: 'priority' },
|
||||
{ name: 'Labels', value: 'labels' },
|
||||
]}
|
||||
selectedItem={selectedChoreSection}
|
||||
selectedFilter={selectedChoreFilter}
|
||||
setFilter={filter => {
|
||||
setSelectedChoreFilterWithCache(filter)
|
||||
const section = ChoresGrouper(
|
||||
selectedChoreSection,
|
||||
chores,
|
||||
ChoreFilters(userProfile)[filter],
|
||||
)
|
||||
setChoreSections(section)
|
||||
setOpenChoreSectionsWithCache(
|
||||
// open all sections by default
|
||||
Object.keys(section).reduce((acc, key) => {
|
||||
acc[key] = true
|
||||
return acc
|
||||
}, {}),
|
||||
)
|
||||
}}
|
||||
onItemSelect={selected => {
|
||||
const section = ChoresGrouper(selected.value, chores)
|
||||
setChoreSections(section)
|
||||
|
@ -441,7 +460,7 @@ const MyChores = () => {
|
|||
}, {}),
|
||||
)
|
||||
setFilteredChores(chores)
|
||||
setSelectedFilter('All')
|
||||
setSearchFilter('All')
|
||||
}}
|
||||
mouseClickHandler={handleMenuOutsideClick}
|
||||
/>
|
||||
|
@ -454,12 +473,12 @@ const MyChores = () => {
|
|||
k={'icon-menu-priority-filter'}
|
||||
icon={<PriorityHigh />}
|
||||
options={Priorities}
|
||||
selectedItem={selectedFilter}
|
||||
selectedItem={searchFilter}
|
||||
onItemSelect={selected => {
|
||||
handleLabelFiltering({ priority: selected.value })
|
||||
}}
|
||||
mouseClickHandler={handleMenuOutsideClick}
|
||||
isActive={selectedFilter.startsWith('Priority: ')}
|
||||
isActive={searchFilter.startsWith('Priority: ')}
|
||||
/>
|
||||
|
||||
<IconButtonWithMenu
|
||||
|
@ -467,11 +486,11 @@ const MyChores = () => {
|
|||
label={' Labels'}
|
||||
icon={<Style />}
|
||||
options={userLabels}
|
||||
selectedItem={selectedFilter}
|
||||
selectedItem={searchFilter}
|
||||
onItemSelect={selected => {
|
||||
handleLabelFiltering({ label: selected })
|
||||
}}
|
||||
isActive={selectedFilter.startsWith('Label: ')}
|
||||
isActive={searchFilter.startsWith('Label: ')}
|
||||
mouseClickHandler={handleMenuOutsideClick}
|
||||
useChips
|
||||
/>
|
||||
|
@ -481,9 +500,7 @@ const MyChores = () => {
|
|||
variant='outlined'
|
||||
startDecorator={<Grain />}
|
||||
color={
|
||||
selectedFilter &&
|
||||
FILTERS[selectedFilter] &&
|
||||
selectedFilter != 'All'
|
||||
searchFilter && FILTERS[searchFilter] && searchFilter != 'All'
|
||||
? 'primary'
|
||||
: 'neutral'
|
||||
}
|
||||
|
@ -519,15 +536,13 @@ const MyChores = () => {
|
|||
? filterFunction(chores, userProfile.id)
|
||||
: filterFunction(chores)
|
||||
setFilteredChores(filteredChores)
|
||||
setSelectedFilter(filter)
|
||||
setSearchFilter(filter)
|
||||
handleFilterMenuClose()
|
||||
}}
|
||||
>
|
||||
{filter}
|
||||
<Chip
|
||||
color={
|
||||
selectedFilter === filter ? 'primary' : 'neutral'
|
||||
}
|
||||
color={searchFilter === filter ? 'primary' : 'neutral'}
|
||||
>
|
||||
{FILTERS[filter].length === 2
|
||||
? FILTERS[filter](chores, userProfile.id).length
|
||||
|
@ -536,13 +551,13 @@ const MyChores = () => {
|
|||
</MenuItem>
|
||||
))}
|
||||
|
||||
{selectedFilter.startsWith('Label: ') ||
|
||||
(selectedFilter.startsWith('Priority: ') && (
|
||||
{searchFilter.startsWith('Label: ') ||
|
||||
(searchFilter.startsWith('Priority: ') && (
|
||||
<MenuItem
|
||||
key={`filter-list-cancel-all-filters`}
|
||||
onClick={() => {
|
||||
setFilteredChores(chores)
|
||||
setSelectedFilter('All')
|
||||
setSearchFilter('All')
|
||||
}}
|
||||
>
|
||||
Cancel All Filters
|
||||
|
@ -553,23 +568,23 @@ const MyChores = () => {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedFilter !== 'All' && (
|
||||
{searchFilter !== 'All' && (
|
||||
<Chip
|
||||
level='title-md'
|
||||
gutterBottom
|
||||
color='warning'
|
||||
label={selectedFilter}
|
||||
label={searchFilter}
|
||||
onDelete={() => {
|
||||
setFilteredChores(chores)
|
||||
setSelectedFilter('All')
|
||||
setSearchFilter('All')
|
||||
}}
|
||||
endDecorator={<CancelRounded />}
|
||||
onClick={() => {
|
||||
setFilteredChores(chores)
|
||||
setSelectedFilter('All')
|
||||
setSearchFilter('All')
|
||||
}}
|
||||
>
|
||||
Current Filter: {selectedFilter}
|
||||
Current Filter: {searchFilter}
|
||||
</Chip>
|
||||
)}
|
||||
{filteredChores.length === 0 && (
|
||||
|
@ -608,7 +623,7 @@ const MyChores = () => {
|
|||
)}
|
||||
</Box>
|
||||
)}
|
||||
{(searchTerm?.length > 0 || selectedFilter !== 'All') &&
|
||||
{(searchTerm?.length > 0 || searchFilter !== 'All') &&
|
||||
filteredChores.map(chore => (
|
||||
<ChoreCard
|
||||
key={`filtered-${chore.id} `}
|
||||
|
@ -620,7 +635,7 @@ const MyChores = () => {
|
|||
onChipClick={handleLabelFiltering}
|
||||
/>
|
||||
))}
|
||||
{searchTerm.length === 0 && selectedFilter === 'All' && (
|
||||
{searchTerm.length === 0 && searchFilter === 'All' && (
|
||||
<AccordionGroup transition='0.2s ease' disableDivider>
|
||||
{choreSections.map((section, index) => {
|
||||
if (section.content.length === 0) return null
|
||||
|
|
|
@ -13,7 +13,7 @@ const Sidepanel = ({ chores }) => {
|
|||
}, [])
|
||||
|
||||
const generateChoreDuePieChartData = chores => {
|
||||
const groups = ChoresGrouper('due_date', chores)
|
||||
const groups = ChoresGrouper('due_date', chores, null)
|
||||
return groups
|
||||
.map(group => {
|
||||
return {
|
||||
|
|
194
src/views/Chores/SortAndGrouping.jsx
Normal file
194
src/views/Chores/SortAndGrouping.jsx
Normal file
|
@ -0,0 +1,194 @@
|
|||
import {
|
||||
Button,
|
||||
Chip,
|
||||
Divider,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Radio,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import IconButton from '@mui/joy/IconButton'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
|
||||
const SortAndGrouping = ({
|
||||
label,
|
||||
k,
|
||||
icon,
|
||||
onItemSelect,
|
||||
selectedItem,
|
||||
setSelectedItem,
|
||||
selectedFilter,
|
||||
setFilter,
|
||||
isActive,
|
||||
useChips,
|
||||
title,
|
||||
}) => {
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
const menuRef = useRef(null)
|
||||
|
||||
const handleMenuOpen = event => {
|
||||
setAnchorEl(event.currentTarget)
|
||||
}
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleMenuOutsideClick = event => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||
handleMenuClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleMenuOutsideClick)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleMenuOutsideClick)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
{!label && (
|
||||
<IconButton
|
||||
onClick={handleMenuOpen}
|
||||
variant='outlined'
|
||||
color={isActive ? 'primary' : 'neutral'}
|
||||
size='sm'
|
||||
sx={{
|
||||
height: 24,
|
||||
borderRadius: 24,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label ? label : null}
|
||||
</IconButton>
|
||||
)}
|
||||
{label && (
|
||||
<Button
|
||||
onClick={handleMenuOpen}
|
||||
variant='outlined'
|
||||
color={isActive ? 'primary' : 'neutral'}
|
||||
size='sm'
|
||||
startDecorator={icon}
|
||||
sx={{
|
||||
height: 24,
|
||||
borderRadius: 24,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Menu
|
||||
key={k}
|
||||
ref={menuRef}
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
sx={{
|
||||
'& .MuiMenuItem-root': {
|
||||
padding: '8px 16px', // Consistent padding for menu items
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem key={`${k}-title`} disabled>
|
||||
<Typography level='body-sm' fontWeight='lg'>
|
||||
Group By
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
|
||||
{[
|
||||
{ name: 'Due Date', value: 'due_date' },
|
||||
{ name: 'Priority', value: 'priority' },
|
||||
{ name: 'Labels', value: 'labels' },
|
||||
].map(item => (
|
||||
<MenuItem
|
||||
key={`${k}-${item?.value}`}
|
||||
onClick={() => {
|
||||
onItemSelect(item)
|
||||
setSelectedItem?.(item.name)
|
||||
handleMenuClose()
|
||||
}}
|
||||
>
|
||||
{useChips ? (
|
||||
<Chip
|
||||
size='sm'
|
||||
sx={{
|
||||
backgroundColor: item.color ? item.color : null,
|
||||
color: getTextColorFromBackgroundColor(item.color),
|
||||
fontWeight: 'md',
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</Chip>
|
||||
) : (
|
||||
<>
|
||||
{item?.icon}
|
||||
<Typography level='body-sm' sx={{ ml: 1 }}>
|
||||
{item.name}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</MenuItem>
|
||||
))}
|
||||
|
||||
<Divider />
|
||||
|
||||
<MenuItem key={`${k}-quick-filter`} disabled>
|
||||
<Typography level='body-sm' fontWeight='lg'>
|
||||
Quick Filters
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem key={`${k}-assignee-title`} disabled>
|
||||
<Typography level='body-xs' fontWeight='md'>
|
||||
Assigned to:
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
key={`${k}-assignee-anyone`}
|
||||
onClick={() => {
|
||||
setFilter('anyone')
|
||||
handleMenuClose()
|
||||
}}
|
||||
>
|
||||
<Radio checked={selectedFilter === 'anyone'} variant='outlined' />
|
||||
<Typography level='body-sm'>Anyone</Typography>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
key={`${k}-assignee-assigned-to-me`}
|
||||
onClick={() => {
|
||||
setFilter('assigned_to_me')
|
||||
handleMenuClose()
|
||||
}}
|
||||
>
|
||||
<Radio
|
||||
checked={selectedFilter === 'assigned_to_me'}
|
||||
variant='outlined'
|
||||
/>
|
||||
<Typography level='body-sm'>Assigned to me</Typography>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
key={`${k}-assignee-assigned-to-others`}
|
||||
onClick={() => {
|
||||
setFilter('assigned_to_others')
|
||||
handleMenuClose()
|
||||
}}
|
||||
>
|
||||
<Radio
|
||||
checked={selectedFilter === 'assigned_to_others'}
|
||||
variant='outlined'
|
||||
/>
|
||||
<Typography level='body-sm'>Assigned to others</Typography>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SortAndGrouping
|
|
@ -173,7 +173,14 @@ const NotificationSetting = () => {
|
|||
}}
|
||||
color={deviceNotification ? 'success' : 'neutral'}
|
||||
variant={deviceNotification ? 'solid' : 'outlined'}
|
||||
sx={{ mr: 1 }}
|
||||
slotProps={{
|
||||
endDecorator: {
|
||||
sx: {
|
||||
minWidth: 24,
|
||||
},
|
||||
},
|
||||
}}
|
||||
sx={{ mr: 2 }}
|
||||
/>
|
||||
<div>
|
||||
<FormLabel>Device Notification</FormLabel>
|
||||
|
@ -321,7 +328,6 @@ const NotificationSetting = () => {
|
|||
|
||||
<FormControl orientation='horizontal'>
|
||||
<Switch
|
||||
sx={{ mr: 1 }}
|
||||
checked={chatID !== 0}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
|
@ -342,12 +348,14 @@ const NotificationSetting = () => {
|
|||
}}
|
||||
color={chatID !== 0 ? 'success' : 'neutral'}
|
||||
variant={chatID !== 0 ? 'solid' : 'outlined'}
|
||||
// endDecorator={chatID !== 0 ? 'On' : 'Off'}
|
||||
slotProps={{
|
||||
endDecorator: {
|
||||
sx: {},
|
||||
sx: {
|
||||
minWidth: 24,
|
||||
},
|
||||
},
|
||||
}}
|
||||
sx={{ mr: 2 }}
|
||||
/>
|
||||
<div>
|
||||
<FormLabel>Custom Notification</FormLabel>
|
||||
|
@ -372,7 +380,7 @@ const NotificationSetting = () => {
|
|||
<Option value='0'>None</Option>
|
||||
<Option value='1'>Telegram</Option>
|
||||
<Option value='2'>Pushover</Option>
|
||||
<Option value='3'>Webhook</Option>
|
||||
<Option value='3'>Webhooks</Option>
|
||||
</Select>
|
||||
{notificationTarget === '1' && (
|
||||
<>
|
||||
|
|
|
@ -232,7 +232,7 @@ const UserActivites = () => {
|
|||
}
|
||||
|
||||
const generateChoreDuePieChartData = chores => {
|
||||
const groups = ChoresGrouper('due_date', chores)
|
||||
const groups = ChoresGrouper('due_date', chores, null)
|
||||
return groups
|
||||
.map(group => {
|
||||
return {
|
||||
|
@ -245,7 +245,7 @@ const UserActivites = () => {
|
|||
.filter(item => item.value > 0)
|
||||
}
|
||||
const generateChorePriorityPieChartData = chores => {
|
||||
const groups = ChoresGrouper('priority', chores)
|
||||
const groups = ChoresGrouper('priority', chores, null)
|
||||
return groups
|
||||
.map(group => {
|
||||
return {
|
||||
|
|
|
@ -76,12 +76,12 @@ const TaskInput = ({ autoFocus, onChoreUpdate }) => {
|
|||
const { userProfile } = useContext(UserContext)
|
||||
const navigate = useNavigate()
|
||||
const [taskText, setTaskText] = useState('')
|
||||
const debounceParsing = useDebounce(taskText, 300)
|
||||
const debounceParsing = useDebounce(taskText, 30)
|
||||
const [taskTitle, setTaskTitle] = useState('')
|
||||
const [openModal, setOpenModal] = useState(false)
|
||||
const textareaRef = useRef(null)
|
||||
const mainInputRef = useRef(null)
|
||||
const [priority, setPriority] = useState('0')
|
||||
const [priority, setPriority] = useState(0)
|
||||
const [dueDate, setDueDate] = useState(null)
|
||||
const [description, setDescription] = useState(null)
|
||||
const [frequency, setFrequency] = useState(null)
|
||||
|
@ -436,7 +436,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate }) => {
|
|||
priority: priority || 0,
|
||||
status: 0,
|
||||
frequencyType: 'once',
|
||||
notificationMetadata: {},
|
||||
}
|
||||
|
||||
if (frequency) {
|
||||
|
@ -451,7 +450,12 @@ const TaskInput = ({ autoFocus, onChoreUpdate }) => {
|
|||
|
||||
CreateChore(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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -6,7 +6,15 @@ import {
|
|||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { Add, Delete, DragIndicator, Edit } from '@mui/icons-material'
|
||||
import {
|
||||
ChevronRight,
|
||||
Delete,
|
||||
DragIndicator,
|
||||
Edit,
|
||||
ExpandMore,
|
||||
KeyboardReturn,
|
||||
PlaylistAdd,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Checkbox,
|
||||
|
@ -19,23 +27,41 @@ import {
|
|||
import React, { useState } from 'react'
|
||||
import { CompleteSubTask } from '../../utils/Fetcher'
|
||||
|
||||
function SortableItem({ task, index, handleToggle, handleDelete, editMode }) {
|
||||
function SortableItem({
|
||||
task,
|
||||
index,
|
||||
handleToggle,
|
||||
handleDelete,
|
||||
handleAddSubtask,
|
||||
allTasks,
|
||||
setTasks,
|
||||
level = 0,
|
||||
editMode,
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
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 hasChildren = childTasks.length > 0
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
flexDirection: { xs: 'column', sm: 'row' }, // Responsive style
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
touchAction: 'none',
|
||||
paddingLeft: `${level * 24}px`,
|
||||
}
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editedText, setEditedText] = useState(task.name)
|
||||
|
||||
const handleEdit = () => {
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
@ -43,15 +69,59 @@ function SortableItem({ task, index, handleToggle, handleDelete, editMode }) {
|
|||
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}>
|
||||
<IconButton {...listeners} {...attributes} size='sm'>
|
||||
<DragIndicator />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{hasChildren && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={handleExpandClick}
|
||||
>
|
||||
{expanded ? <ExpandMore /> : <ChevronRight />}
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{!hasChildren && level > 0 && (
|
||||
<Box sx={{ width: 28 }} /> // Spacer for alignment not sure of better way for now it's good
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
|
@ -62,9 +132,8 @@ function SortableItem({ task, index, handleToggle, handleDelete, editMode }) {
|
|||
>
|
||||
{!editMode && (
|
||||
<Checkbox
|
||||
checked={task.completedAt}
|
||||
checked={!!task.completedAt}
|
||||
onChange={() => handleToggle(task.id)}
|
||||
overlay={!editMode}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
|
@ -75,6 +144,11 @@ function SortableItem({ task, index, handleToggle, handleDelete, editMode }) {
|
|||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!editMode) {
|
||||
handleToggle(task.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
|
@ -101,8 +175,9 @@ function SortableItem({ task, index, handleToggle, handleDelete, editMode }) {
|
|||
{task.completedAt && (
|
||||
<Typography
|
||||
sx={{
|
||||
display: { xs: 'block', sm: 'inline' }, // Responsive style
|
||||
display: { xs: 'block', sm: 'inline' },
|
||||
color: 'text.secondary',
|
||||
fontSize: 'sm',
|
||||
}}
|
||||
>
|
||||
{new Date(task.completedAt).toLocaleString()}
|
||||
|
@ -110,79 +185,216 @@ function SortableItem({ task, index, handleToggle, handleDelete, editMode }) {
|
|||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{editMode && (
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<IconButton variant='soft' onClick={handleEdit}>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</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 />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</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}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</ListItem>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SubTasks = ({ editMode = true, choreId = 0, tasks, setTasks }) => {
|
||||
const SubTasks = ({ editMode = true, choreId = 0, tasks = [], setTasks }) => {
|
||||
const [newTask, setNewTask] = useState('')
|
||||
|
||||
const topLevelTasks = tasks.filter(task => task.parentId === null)
|
||||
|
||||
const handleToggle = taskId => {
|
||||
const updatedTask = tasks.find(task => task.id === taskId)
|
||||
updatedTask.completedAt = updatedTask.completedAt
|
||||
const newCompletedAt = updatedTask.completedAt
|
||||
? null
|
||||
: new Date().toISOString()
|
||||
|
||||
// Update the task
|
||||
const updatedTasks = tasks.map(task =>
|
||||
task.id === taskId ? updatedTask : task,
|
||||
task.id === taskId ? { ...task, completedAt: newCompletedAt } : task,
|
||||
)
|
||||
CompleteSubTask(
|
||||
taskId,
|
||||
Number(choreId),
|
||||
updatedTask.completedAt ? new Date().toISOString() : null,
|
||||
).then(res => {
|
||||
|
||||
// If completing a task, also complete all child tasks
|
||||
if (newCompletedAt) {
|
||||
const completeChildren = parentId => {
|
||||
const children = updatedTasks.filter(t => t.parentId === parentId)
|
||||
children.forEach(child => {
|
||||
const index = updatedTasks.findIndex(t => t.id === child.id)
|
||||
if (index !== -1) {
|
||||
updatedTasks[index] = {
|
||||
...updatedTasks[index],
|
||||
completedAt: newCompletedAt,
|
||||
}
|
||||
completeChildren(child.id) // Recursively complete grandchildren
|
||||
}
|
||||
})
|
||||
}
|
||||
completeChildren(taskId)
|
||||
}
|
||||
|
||||
CompleteSubTask(taskId, Number(choreId), newCompletedAt).then(res => {
|
||||
if (res.status !== 200) {
|
||||
console.log('Error updating task')
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
setTasks(updatedTasks)
|
||||
}
|
||||
|
||||
const handleDelete = taskId => {
|
||||
setTasks(tasks.filter(task => task.id !== taskId))
|
||||
// Find all descendant tasks to delete
|
||||
const findDescendants = id => {
|
||||
const descendants = []
|
||||
const children = tasks.filter(t => t.parentId === id)
|
||||
|
||||
children.forEach(child => {
|
||||
descendants.push(child.id)
|
||||
descendants.push(...findDescendants(child.id))
|
||||
})
|
||||
|
||||
return descendants
|
||||
}
|
||||
|
||||
const descendantIds = findDescendants(taskId)
|
||||
const idsToDelete = [taskId, ...descendantIds]
|
||||
|
||||
// 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,
|
||||
}))
|
||||
|
||||
setTasks(updatedTasks)
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!newTask.trim()) return
|
||||
setTasks([
|
||||
...tasks,
|
||||
{
|
||||
|
||||
const newTaskObj = {
|
||||
name: newTask,
|
||||
completedAt: null,
|
||||
orderId: tasks.length,
|
||||
},
|
||||
])
|
||||
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 onDragEnd = event => {
|
||||
const { active, over } = event
|
||||
if (active.id !== over.id) {
|
||||
if (!over || active.id === over.id) return
|
||||
|
||||
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)
|
||||
return reorderedItems.map((item, index) => ({
|
||||
...item,
|
||||
orderId: index,
|
||||
}))
|
||||
})
|
||||
|
||||
const parentId = overItem.parentId
|
||||
const siblings = reorderedItems.filter(item => item.parentId === parentId)
|
||||
|
||||
return reorderedItems.map(item => {
|
||||
if (item.id === activeItem.id) {
|
||||
return { ...item, parentId, orderId: siblings.indexOf(item) }
|
||||
}
|
||||
return item.parentId === parentId
|
||||
? { ...item, orderId: siblings.indexOf(item) }
|
||||
: item
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const handleKeyPress = event => {
|
||||
|
@ -191,38 +403,37 @@ const SubTasks = ({ editMode = true, choreId = 0, tasks, setTasks }) => {
|
|||
}
|
||||
}
|
||||
|
||||
// Sort tasks by orderId before rendering
|
||||
const sortedTasks = [...tasks].sort((a, b) => a.orderId - b.orderId)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<SortableContext
|
||||
items={sortedTasks}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<SortableContext items={tasks} strategy={verticalListSortingStrategy}>
|
||||
<List sx={{ padding: 0 }}>
|
||||
{sortedTasks.map((task, index) => (
|
||||
{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}
|
||||
/>
|
||||
))}
|
||||
{editMode && (
|
||||
<ListItem sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Input
|
||||
placeholder='Add new...'
|
||||
placeholder='Add new task...'
|
||||
value={newTask}
|
||||
onChange={e => setNewTask(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<IconButton onClick={handleAdd}>
|
||||
<Add />
|
||||
<KeyboardReturn />
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
)}
|
||||
|
|
Loading…
Add table
Reference in a new issue