feat: enhance chore management with new modals and filter options

This commit is contained in:
Mo Tarbin
2026-08-16 02:46:04 -04:00
parent 5359ae193b
commit 1c99bd1886
5 changed files with 520 additions and 163 deletions

View File

@@ -63,6 +63,8 @@
"skip": "Skip", "skip": "Skip",
"cancel": "Cancel", "cancel": "Cancel",
"noPriority": "No Priority", "noPriority": "No Priority",
"more": "More",
"changeDueDate": "Change due date",
"subtasks": "Subtasks", "subtasks": "Subtasks",
"noDescription": "No description available", "noDescription": "No description available",
"timer": { "timer": {

View File

@@ -147,8 +147,19 @@ const FilterBar = ({
onClearAll, onClearAll,
resultCount, resultCount,
totalCount, totalCount,
// When the host renders its own trigger (e.g. an icon button in a toolbar
// row), it drives the sheet through `open`/`onOpenChange` and hides ours.
open,
onOpenChange,
showTrigger = true,
}) => { }) => {
const [isOpen, setIsOpen] = useState(false) const [internalOpen, setInternalOpen] = useState(false)
const isControlled = open !== undefined
const isOpen = isControlled ? open : internalOpen
const setIsOpen = next => {
if (!isControlled) setInternalOpen(next)
onOpenChange?.(next)
}
// ── Active count ─────────────────────────────────────────────────────────── // ── Active count ───────────────────────────────────────────────────────────
@@ -293,65 +304,75 @@ const FilterBar = ({
// ── Render ───────────────────────────────────────────────────────────────── // ── Render ─────────────────────────────────────────────────────────────────
const activeChips = filterDefs
.map(def => ({ def, label: getActiveChipLabel(def) }))
.filter(({ label }) => !!label)
.map(({ def, label }) => ({
key: def.id,
label,
onClear: () => onSetFilter(def.id, null),
}))
// With the trigger hoisted into a toolbar, the inline row has nothing to show
// until a filter is on — rendering it anyway would leave a phantom gap.
const showInlineBar = showTrigger || activeChips.length > 0
return ( return (
<> <>
{/* ── Inline bar ─────────────────────────────────────── */} {/* ── Inline bar ─────────────────────────────────────── */}
<Box {showInlineBar && (
sx={{ <Box
display: 'flex', sx={{
alignItems: 'center', display: 'flex',
gap: 1, alignItems: 'center',
flexWrap: 'wrap', gap: 1,
mb: 2, flexWrap: 'wrap',
}} mb: 2,
> }}
<Badge
badgeContent={activeFilterCount || null}
color='primary'
size='sm'
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
sx={{ display: 'flex', alignItems: 'center' }}
> >
<Button {showTrigger && (
size='md' <Badge
variant={hasActive ? 'solid' : 'outlined'} badgeContent={activeFilterCount || null}
color={hasActive ? 'primary' : 'neutral'} color='primary'
startDecorator={<FilterList sx={{ fontSize: 16 }} />} size='sm'
onClick={() => setIsOpen(true)} anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
sx={{ sx={{ display: 'flex', alignItems: 'center' }}
borderRadius: 'xl', >
py: 0.5, <Button
px: 1, size='md'
gap: 0.5, variant={hasActive ? 'solid' : 'outlined'}
alignItems: 'center', color={hasActive ? 'primary' : 'neutral'}
'& .MuiButton-startDecorator': { startDecorator={<FilterList sx={{ fontSize: 16 }} />}
display: 'flex', onClick={() => setIsOpen(true)}
alignItems: 'center', sx={{
mr: 0.5, borderRadius: 'xl',
}, py: 0.5,
}} px: 1,
> gap: 0.5,
Filters alignItems: 'center',
</Button> '& .MuiButton-startDecorator': {
</Badge> display: 'flex',
alignItems: 'center',
mr: 0.5,
},
}}
>
Filters
</Button>
</Badge>
)}
<ActiveFilterChips <ActiveFilterChips
chips={filterDefs chips={activeChips}
.map(def => ({ def, label: getActiveChipLabel(def) })) onOpen={() => setIsOpen(true)}
.filter(({ label }) => !!label) onClearAll={hasActive ? onClearAll : undefined}
.map(({ def, label }) => ({ resultCount={hasActive ? resultCount : undefined}
key: def.id, totalCount={hasActive ? totalCount : undefined}
label, maxVisible={2}
onClear: () => onSetFilter(def.id, null), chipSize='md'
}))} />
onOpen={() => setIsOpen(true)} </Box>
onClearAll={hasActive ? onClearAll : undefined} )}
resultCount={hasActive ? resultCount : undefined}
totalCount={hasActive ? totalCount : undefined}
maxVisible={2}
chipSize='md'
/>
</Box>
{/* ── Bottom sheet ────────────────────────────────────── */} {/* ── Bottom sheet ────────────────────────────────────── */}
<AppModal <AppModal

View File

@@ -1,3 +1,4 @@
import { Capacitor } from '@capacitor/core'
import { import {
Archive, Archive,
AttachFile, AttachFile,
@@ -7,7 +8,7 @@ import {
Edit, Edit,
History, History,
HourglassEmpty, HourglassEmpty,
LowPriority, MoreVert,
OpenInFull, OpenInFull,
PeopleAlt, PeopleAlt,
Person, Person,
@@ -25,18 +26,13 @@ import {
Checkbox, Checkbox,
Chip, Chip,
Container, Container,
Dropdown,
FormControl, FormControl,
Grid, Grid,
IconButton, IconButton,
Input, Input,
Menu,
MenuButton,
MenuItem,
Sheet, Sheet,
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { Divider } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import moment from 'moment' import moment from 'moment'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@@ -68,20 +64,33 @@ import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import { commandQueue, CommandType } from '../../utils/CommandQueue' import { commandQueue, CommandType } from '../../utils/CommandQueue'
import { import {
ApproveChore, ApproveChore,
ArchiveChore,
DeleteChore,
GetChoreDetailById, GetChoreDetailById,
MarkChoreComplete, MarkChoreComplete,
NudgeChore,
RejectChore, RejectChore,
SaveChore,
SkipChore, SkipChore,
UnArchiveChore, UnArchiveChore,
UndoChoreAction, UndoChoreAction,
UpdateChoreAssignee,
UpdateChorePriority, UpdateChorePriority,
UpdateDueDate,
} from '../../utils/Fetcher' } from '../../utils/Fetcher'
import { offlineDB } from '../../utils/OfflineDB' import { offlineDB } from '../../utils/OfflineDB'
import Priorities from '../../utils/Priorities'
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js' import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
import AttachmentBrowserModal from '../Modals/Inputs/AttachmentBrowserModal' import AttachmentBrowserModal from '../Modals/Inputs/AttachmentBrowserModal'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal' import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import NudgeModal from '../Modals/Inputs/NudgeModal'
import SelectModal from '../Modals/Inputs/SelectModal'
import WriteNFCModal from '../Modals/Inputs/WriteNFCModal'
import ChoreActionMenu from '../components/ChoreActionMenu'
import DueDatePickerModal, {
combineDueDate,
splitDueDate,
} from '../components/DueDatePickerModal'
import LoadingComponent from '../components/Loading.jsx' import LoadingComponent from '../components/Loading.jsx'
import PendingBadge from '../components/PendingBadge' import PendingBadge from '../components/PendingBadge'
import RichTextEditor from '../components/RichTextEditor.jsx' import RichTextEditor from '../components/RichTextEditor.jsx'
@@ -106,6 +115,11 @@ const decodeHtmlEntities = value => {
const hasHtmlTags = value => /<\/?[a-z][\s\S]*>/i.test(value) const hasHtmlTags = value => /<\/?[a-z][\s\S]*>/i.test(value)
const getNFCUrl = choreId =>
Capacitor.getPlatform() === 'android' || Capacitor.getPlatform() === 'ios'
? `donetick://chores/${choreId}`
: `${window.location.origin}/chores/${choreId}`
const ChoreView = () => { const ChoreView = () => {
const { t } = useTranslation('chores') const { t } = useTranslation('chores')
const { fmt } = useLocalization() const { fmt } = useLocalization()
@@ -124,7 +138,7 @@ const ChoreView = () => {
const [confirmModelConfig, setConfirmModelConfig] = useState({ const [confirmModelConfig, setConfirmModelConfig] = useState({
isOpen: false, isOpen: false,
}) })
const [chorePriority, setChorePriority] = useState(null) const [activeModal, setActiveModal] = useState(null)
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false }) const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
const [timerActionConfig, setTimerActionConfig] = useState({ isOpen: false }) const [timerActionConfig, setTimerActionConfig] = useState({ isOpen: false })
const [attachmentBrowserOpen, setAttachmentBrowserOpen] = useState(false) const [attachmentBrowserOpen, setAttachmentBrowserOpen] = useState(false)
@@ -165,7 +179,6 @@ const ChoreView = () => {
return return
} }
setChore(choreData.res) setChore(choreData.res)
setChorePriority(Priorities.find(p => p.value === choreData.res.priority))
document.title = 'Donetick: ' + choreData.res.name document.title = 'Donetick: ' + choreData.res.name
setPerformers(circleMembersData.res) setPerformers(circleMembersData.res)
@@ -236,7 +249,7 @@ const ChoreView = () => {
UpdateChorePriority(choreId, priority.value).then(response => { UpdateChorePriority(choreId, priority.value).then(response => {
if (response.ok) { if (response.ok) {
response.json().then(() => { response.json().then(() => {
setChorePriority(priority) setChore(prev => ({ ...prev, priority: priority.value }))
queryClient.invalidateQueries(['chores']) queryClient.invalidateQueries(['chores'])
}) })
} }
@@ -583,6 +596,168 @@ const ChoreView = () => {
} }
} }
const confirmSkipTask = () => {
setConfirmModelConfig({
isOpen: true,
title: t('choreView.skipTask'),
message: t('choreView.skipTaskConfirmation'),
confirmText: t('choreView.skip'),
cancelText: t('choreView.cancel'),
onClose: confirmed => {
if (confirmed) {
handleSkippingTask()
}
setConfirmModelConfig({})
},
})
}
const handleArchiveChore = async () => {
try {
const response = await ArchiveChore(choreId)
if (response.ok) {
await offlineDB.saveChores([{ ...chore, isActive: false }])
setChore({ ...chore, isActive: false })
queryClient.invalidateQueries(['chores'])
}
} catch (error) {
showError({
title: 'Failed to archive',
message: error?.message || 'Unable to archive task',
})
}
}
const confirmDeleteChore = () => {
setConfirmModelConfig({
isOpen: true,
title: 'Delete task',
message: 'Are you sure you want to delete this task?',
confirmText: 'Delete',
cancelText: t('choreView.cancel'),
onClose: async confirmed => {
setConfirmModelConfig({})
if (!confirmed) return
try {
const response = await DeleteChore(choreId)
if (response.ok) {
queryClient.invalidateQueries(['chores'])
showSuccess({
title: 'Task Deleted',
message: 'The task has been deleted successfully.',
})
navigate('/chores')
}
} catch (error) {
showError({
title: 'Failed to delete',
message: error?.message || 'Unable to delete task',
})
}
},
})
}
const handleDueDateChange = async newDate => {
try {
const response = await UpdateDueDate(choreId, newDate)
if (response.ok) {
setChore(prev => ({ ...prev, nextDueDate: newDate }))
queryClient.invalidateQueries(['chores'])
}
} catch (error) {
showError({
title: 'Failed to reschedule',
message: error?.message || 'Unable to change the due date',
})
}
}
const handleMoveToProject = async project => {
const projectId = project?.id ?? null
try {
const response = await SaveChore({ ...chore, projectId })
if (response.ok) {
setChore(prev => ({ ...prev, projectId }))
queryClient.invalidateQueries(['chores'])
showSuccess({
title: 'Task Moved',
message: `Task moved to ${project?.name || 'Default Project'}.`,
})
}
} catch (error) {
showError({
title: 'Failed to move task',
message: error?.message || 'Unable to move task to project',
})
}
}
const handleNudge = async ({ message, notifyAllAssignees }) => {
try {
const response = await NudgeChore(choreId, {
message,
notifyAllAssignees,
})
if (!response.ok) {
throw new Error('Failed to send nudge')
}
const data = await response.json()
showSuccess({
title: 'Nudge Sent!',
message: data.message || 'Nudge sent successfully',
})
} catch (error) {
showError({
title: 'Failed to Send Nudge',
message: error?.message || 'Unable to send nudge at this time',
})
}
}
const handleAssigneeChange = async assigneeId => {
try {
const response = await UpdateChoreAssignee(choreId, assigneeId)
if (response.ok) {
const data = await response.json()
setChore(data.res)
queryClient.invalidateQueries(['chores'])
}
} catch (error) {
showError({
title: 'Failed to delegate',
message: error?.message || 'Unable to change the assignee',
})
}
}
// Actions the menu raises that ChoreView owns; the rest of its items either
// navigate on their own or come in through the dedicated callbacks.
const handleMenuAction = (type, _chore, extraData) => {
switch (type) {
case 'skip':
confirmSkipTask()
break
case 'archive':
handleArchiveChore()
break
case 'unarchive':
handleUnarchiveChore()
break
case 'delete':
confirmDeleteChore()
break
case 'changeDueDate':
handleDueDateChange(extraData?.date?.toISOString() ?? null)
break
case 'moveToProject':
handleMoveToProject(extraData?.project)
break
default:
break
}
}
// Check if the current user can approve/reject (admin, manager, or task owner) // Check if the current user can approve/reject (admin, manager, or task owner)
const canApproveReject = () => { const canApproveReject = () => {
if (!circleMembersData?.res || !chore) return false if (!circleMembersData?.res || !chore) return false
@@ -794,64 +969,6 @@ const ChoreView = () => {
mb: 1, mb: 1,
}} }}
> >
<Dropdown>
<MenuButton
disabled={chore.isActive === false}
color={
chorePriority?.name === 'P1'
? 'danger'
: chorePriority?.name === 'P2'
? 'warning'
: 'neutral'
}
sx={{
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 1,
width: '100%',
}}
variant='plain'
>
{chorePriority ? chorePriority.icon : <LowPriority />}
{chorePriority ? chorePriority.name : t('choreView.noPriority')}
</MenuButton>
<Menu>
{Priorities.map((priority, index) => (
<MenuItem
sx={{
pr: 1,
py: 1,
}}
key={index}
onClick={() => {
handleUpdatePriority(priority)
}}
color={priority.color}
>
{priority.icon}
{priority.name}
</MenuItem>
))}
<Divider />
<MenuItem
sx={{
pr: 1,
py: 1,
}}
onClick={() => {
handleUpdatePriority({
name: t('choreView.noPriority'),
value: 0,
})
setChorePriority(null)
}}
>
{t('choreView.noPriority')}
</MenuItem>
</Menu>
</Dropdown>
<Button <Button
size='sm' size='sm'
color='neutral' color='neutral'
@@ -889,6 +1006,38 @@ const ChoreView = () => {
<Edit /> <Edit />
Edit Edit
</Button> </Button>
<ChoreActionMenu
chore={chore}
hiddenActions={['view']}
onAction={handleMenuAction}
onNudge={() => setActiveModal('nudge')}
onWriteNFC={() => setActiveModal('writeNFC')}
onCompleteWithNote={() => setNote('')}
onCompleteWithPastDate={() =>
setCompletedDate(moment(new Date()).format('YYYY-MM-DDTHH:00:00'))
}
onChangeAssignee={() => setActiveModal('changeAssignee')}
onChangeDueDate={() => setActiveModal('changeDueDate')}
onChangePriority={handleUpdatePriority}
onDelete={confirmDeleteChore}
trigger={
<Button
size='sm'
color='neutral'
variant='plain'
fullWidth
sx={{
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 1,
}}
>
<MoreVert />
{t('choreView.more', 'More')}
</Button>
}
/>
</Box> </Box>
{chore.description && ( {chore.description && (
@@ -1261,21 +1410,7 @@ const ChoreView = () => {
<Button <Button
fullWidth fullWidth
size='lg' size='lg'
onClick={() => { onClick={confirmSkipTask}
setConfirmModelConfig({
isOpen: true,
title: t('choreView.skipTask'),
message: t('choreView.skipTaskConfirmation'),
confirmText: t('choreView.skip'),
cancelText: t('choreView.cancel'),
onClose: confirmed => {
if (confirmed) {
handleSkippingTask()
}
setConfirmModelConfig({})
},
})
}}
disabled={ disabled={
notInCompletionWindow(chore) || chore.isActive === false notInCompletionWindow(chore) || chore.isActive === false
} }
@@ -1348,6 +1483,52 @@ const ChoreView = () => {
<ConfirmationModal config={confirmModelConfig} /> <ConfirmationModal config={confirmModelConfig} />
<ConfirmationModal config={timerActionConfig} /> <ConfirmationModal config={timerActionConfig} />
<NoteViewerModal config={noteViewerConfig} /> <NoteViewerModal config={noteViewerConfig} />
{activeModal === 'changeDueDate' && (
<DueDatePickerModal
open={true}
title={t('choreView.changeDueDate', 'Change due date')}
{...splitDueDate(chore.nextDueDate)}
onClose={() => setActiveModal(null)}
onApply={parts => {
handleDueDateChange(combineDueDate(parts)?.toISOString() ?? null)
setActiveModal(null)
}}
onRemove={() => {
handleDueDateChange(null)
setActiveModal(null)
}}
/>
)}
{activeModal === 'changeAssignee' && (
<SelectModal
isOpen={true}
options={performers}
displayKey='displayName'
title='Delegate to someone else'
placeholder='Select a performer'
onClose={() => setActiveModal(null)}
onSave={selected => handleAssigneeChange(selected.id)}
/>
)}
{activeModal === 'nudge' && (
<NudgeModal
config={{
isOpen: true,
choreId: chore.id,
onClose: () => setActiveModal(null),
onConfirm: handleNudge,
}}
/>
)}
{activeModal === 'writeNFC' && (
<WriteNFCModal
config={{
isOpen: true,
url: getNFCUrl(choreId),
onClose: () => setActiveModal(null),
}}
/>
)}
<AttachmentBrowserModal <AttachmentBrowserModal
choreId={choreId} choreId={choreId}
isOpen={attachmentBrowserOpen} isOpen={attachmentBrowserOpen}

View File

@@ -4,6 +4,7 @@ import {
CheckBoxOutlineBlank, CheckBoxOutlineBlank,
Close, Close,
Delete, Delete,
FilterList,
Label, Label,
Person, Person,
PriorityHigh, PriorityHigh,
@@ -14,6 +15,7 @@ import {
ViewModule, ViewModule,
} from '@mui/icons-material' } from '@mui/icons-material'
import { import {
Badge,
Box, Box,
Button, Button,
Container, Container,
@@ -202,6 +204,7 @@ const ArchivedTasks = () => {
) )
const { const {
activeFilterCount,
activeFilters, activeFilters,
clearAll, clearAll,
filteredData: filteredByBar, filteredData: filteredByBar,
@@ -209,6 +212,8 @@ const ArchivedTasks = () => {
setFilter, setFilter,
} = useFilter(filteredChores, filterDefs) } = useFilter(filteredChores, filterDefs)
const [isFilterSheetOpen, setIsFilterSheetOpen] = useState(false)
const [sortBy, setSortBy] = useState( const [sortBy, setSortBy] = useState(
() => localStorage.getItem('archivedChoresSortBy') || 'archivedAt', () => localStorage.getItem('archivedChoresSortBy') || 'archivedAt',
) )
@@ -770,6 +775,30 @@ const ArchivedTasks = () => {
} }
/> />
{/* Filter Sheet Trigger */}
<Badge
badgeContent={activeFilterCount || null}
color='primary'
size='sm'
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
sx={{ display: 'flex', alignItems: 'center' }}
>
<IconButton
variant={hasActiveFilters ? 'solid' : 'outlined'}
color={hasActiveFilters ? 'primary' : 'neutral'}
size='sm'
sx={{
height: 32,
width: 32,
borderRadius: '50%',
}}
onClick={() => setIsFilterSheetOpen(true)}
title='Filters'
>
<FilterList />
</IconButton>
</Badge>
{/* Sort Menu */} {/* Sort Menu */}
<SortAndFilterMenu <SortAndFilterMenu
sortOptions={[ sortOptions={[
@@ -845,6 +874,9 @@ const ArchivedTasks = () => {
onClearAll={clearAll} onClearAll={clearAll}
resultCount={finalChores.length} resultCount={finalChores.length}
totalCount={filteredChores.length} totalCount={filteredChores.length}
showTrigger={false}
open={isFilterSheetOpen}
onOpenChange={setIsFilterSheetOpen}
/> />
{/* Multi-select Toolbar */} {/* Multi-select Toolbar */}

View File

@@ -6,6 +6,7 @@ import {
Delete, Delete,
DriveFileMove, DriveFileMove,
Edit, Edit,
Flag,
ManageSearch, ManageSearch,
MoreTime, MoreTime,
MoreVert, MoreVert,
@@ -25,6 +26,7 @@ import {
import { import {
Avatar, Avatar,
Button, Button,
Chip,
Divider, Divider,
IconButton, IconButton,
List, List,
@@ -45,9 +47,21 @@ import LABEL_COLORS, {
getTextColorFromBackgroundColor, getTextColorFromBackgroundColor,
} from '../../utils/Colors' } from '../../utils/Colors'
import { isOfficialDonetickInstanceSync } from '../../utils/FeatureToggle' import { isOfficialDonetickInstanceSync } from '../../utils/FeatureToggle'
import Priorities from '../../utils/Priorities'
import { getIconComponent } from '../../utils/ProjectIcons' import { getIconComponent } from '../../utils/ProjectIcons'
import { useProjects } from '../Projects/ProjectQueries' import { useProjects } from '../Projects/ProjectQueries'
const NO_PRIORITY = { name: 'No priority', value: 0, color: 'neutral' }
// After hiding actions the caller does not support, the dividers around them
// would otherwise stack up or dangle at the edges of the list.
const collapseDividers = items =>
items.filter((item, index) => {
if (item.type !== 'divider') return true
if (index === 0 || index === items.length - 1) return false
return items[index - 1].type !== 'divider'
})
const ChoreActionMenu = ({ const ChoreActionMenu = ({
chore, chore,
onAction, onAction,
@@ -55,18 +69,22 @@ const ChoreActionMenu = ({
onCompleteWithPastDate, onCompleteWithPastDate,
onChangeAssignee, onChangeAssignee,
onChangeDueDate, onChangeDueDate,
onChangePriority,
onWriteNFC, onWriteNFC,
onNudge, onNudge,
onDelete, onDelete,
onOpen, onOpen,
onMouseEnter, onMouseEnter,
onMouseLeave, onMouseLeave,
hiddenActions = [],
trigger,
sx = {}, sx = {},
variant = 'soft', variant = 'soft',
}) => { }) => {
const [anchorEl, setAnchorEl] = React.useState(null) const [anchorEl, setAnchorEl] = React.useState(null)
const [isOfficialInstance, setIsOfficialInstance] = useState(false) const [isOfficialInstance, setIsOfficialInstance] = useState(false)
const [showProjectPicker, setShowProjectPicker] = useState(false) const [showProjectPicker, setShowProjectPicker] = useState(false)
const [showPriorityPicker, setShowPriorityPicker] = useState(false)
const menuRef = React.useRef(null) const menuRef = React.useRef(null)
const navigate = useNavigate() const navigate = useNavigate()
const { data: projects = [] } = useProjects() const { data: projects = [] } = useProjects()
@@ -118,6 +136,12 @@ const ChoreActionMenu = ({
const handleMenuClose = () => { const handleMenuClose = () => {
setAnchorEl(null) setAnchorEl(null)
setShowProjectPicker(false) setShowProjectPicker(false)
setShowPriorityPicker(false)
}
const handleChangePriority = priority => {
onChangePriority?.(priority)
handleMenuClose()
} }
const handleMoveToProject = project => { const handleMoveToProject = project => {
@@ -245,6 +269,9 @@ const ChoreActionMenu = ({
) )
} }
const currentPriority =
Priorities.find(p => p.value === chore?.priority) || null
// Shared action list, rendered as MenuItems on large screens and as a // Shared action list, rendered as MenuItems on large screens and as a
// ListItemButton list inside an AppModal sheet on small screens. // ListItemButton list inside an AppModal sheet on small screens.
const actionItems = [ const actionItems = [
@@ -309,6 +336,21 @@ const ChoreActionMenu = ({
handleMenuClose() handleMenuClose()
}, },
}, },
onChangePriority && {
key: 'priority',
icon: <Flag />,
label: 'Priority',
onClick: () => setShowPriorityPicker(true),
endDecorator: (
<Chip
size='sm'
variant='soft'
color={currentPriority?.color || 'neutral'}
>
{currentPriority?.name.trim() || 'None'}
</Chip>
),
},
{ {
key: 'writeNfc', key: 'writeNfc',
icon: <Nfc />, icon: <Nfc />,
@@ -342,7 +384,11 @@ const ChoreActionMenu = ({
onClick: handleDelete, onClick: handleDelete,
color: 'danger', color: 'danger',
}, },
].filter(Boolean) ]
.filter(Boolean)
.filter(item => !hiddenActions.includes(item.key))
const visibleActionItems = collapseDividers(actionItems)
const quickScheduleButtons = ( const quickScheduleButtons = (
<> <>
@@ -415,7 +461,7 @@ const ChoreActionMenu = ({
} }
const renderMenuActionItems = () => const renderMenuActionItems = () =>
actionItems.map(item => { visibleActionItems.map(item => {
if (item.type === 'divider') return <Divider key={item.key} /> if (item.type === 'divider') return <Divider key={item.key} />
if (item.type === 'quickSchedule') { if (item.type === 'quickSchedule') {
return ( return (
@@ -443,12 +489,17 @@ const ChoreActionMenu = ({
> >
{item.icon} {item.icon}
{item.label} {item.label}
{item.endDecorator && (
<ListItemDecorator sx={{ ml: 'auto', minInlineSize: 0 }}>
{item.endDecorator}
</ListItemDecorator>
)}
</MenuItem> </MenuItem>
) )
}) })
const renderModalActionItems = () => const renderModalActionItems = () =>
actionItems.map(item => { visibleActionItems.map(item => {
if (item.type === 'divider') return <Divider key={item.key} /> if (item.type === 'divider') return <Divider key={item.key} />
if (item.type === 'quickSchedule') { if (item.type === 'quickSchedule') {
return ( return (
@@ -462,11 +513,60 @@ const ChoreActionMenu = ({
<ListItemButton color={item.color} onClick={() => item.onClick()}> <ListItemButton color={item.color} onClick={() => item.onClick()}>
<ListItemDecorator>{item.icon}</ListItemDecorator> <ListItemDecorator>{item.icon}</ListItemDecorator>
<ListItemContent>{item.label}</ListItemContent> <ListItemContent>{item.label}</ListItemContent>
{item.endDecorator}
</ListItemButton> </ListItemButton>
</ListItem> </ListItem>
) )
}) })
const priorityOptions = [...Priorities, NO_PRIORITY]
const renderModalPriorityPicker = () =>
priorityOptions.map(priority => (
<ListItem key={priority.value}>
<ListItemButton
selected={(currentPriority?.value || 0) === priority.value}
color={priority.color || 'neutral'}
onClick={() => handleChangePriority(priority)}
>
<ListItemDecorator>{priority.icon || <Flag />}</ListItemDecorator>
<ListItemContent>{priority.name.trim()}</ListItemContent>
</ListItemButton>
</ListItem>
))
const renderMenuPriorityPicker = () => (
<>
<MenuItem
onClick={e => {
e.stopPropagation()
setShowPriorityPicker(false)
}}
sx={{ gap: 1 }}
>
<ArrowBack fontSize='small' />
<Typography level='body-sm' fontWeight={600}>
Priority
</Typography>
</MenuItem>
<Divider />
{priorityOptions.map(priority => (
<MenuItem
key={priority.value}
selected={(currentPriority?.value || 0) === priority.value}
color={priority.color || 'neutral'}
onClick={e => {
e.stopPropagation()
handleChangePriority(priority)
}}
>
{priority.icon || <Flag />}
{priority.name.trim()}
</MenuItem>
))}
</>
)
const renderModalProjectPicker = () => ( const renderModalProjectPicker = () => (
<> <>
<ListItem> <ListItem>
@@ -496,40 +596,57 @@ const ChoreActionMenu = ({
return ( return (
<> <>
<IconButton {trigger ? (
variant={variant} React.cloneElement(trigger, {
color='success' onClick: handleMenuOpen,
onClick={handleMenuOpen} onMouseEnter,
onMouseEnter={onMouseEnter} onMouseLeave,
onMouseLeave={onMouseLeave} })
sx={{ ) : (
borderRadius: '50%', <IconButton
width: 25, variant={variant}
height: 25, color='success'
position: 'relative', onClick={handleMenuOpen}
left: -10, onMouseEnter={onMouseEnter}
...sx, onMouseLeave={onMouseLeave}
}} sx={{
> borderRadius: '50%',
<MoreVert /> width: 25,
</IconButton> height: 25,
position: 'relative',
left: -10,
...sx,
}}
>
<MoreVert />
</IconButton>
)}
{isSmallScreen ? ( {isSmallScreen ? (
<AppModal <AppModal
open={Boolean(anchorEl)} open={Boolean(anchorEl)}
onClose={handleMenuClose} onClose={handleMenuClose}
title={showProjectPicker ? 'Move to project' : chore?.name} title={
showProjectPicker
? 'Move to project'
: showPriorityPicker
? 'Priority'
: chore?.name
}
mobilePresentation='sheet' mobilePresentation='sheet'
showHandle showHandle
contentSx={{ px: 0, pb: 1 }} contentSx={{ px: 0, pb: 1 }}
> >
{showProjectPicker && ( {(showProjectPicker || showPriorityPicker) && (
<Button <Button
variant='plain' variant='plain'
color='neutral' color='neutral'
size='sm' size='sm'
startDecorator={<ArrowBack fontSize='small' />} startDecorator={<ArrowBack fontSize='small' />}
onClick={() => setShowProjectPicker(false)} onClick={() => {
setShowProjectPicker(false)
setShowPriorityPicker(false)
}}
sx={{ mx: 2, mb: 1 }} sx={{ mx: 2, mb: 1 }}
> >
<Typography level='body-sm' fontWeight={600}> <Typography level='body-sm' fontWeight={600}>
@@ -540,7 +657,9 @@ const ChoreActionMenu = ({
<List sx={{ '--ListItem-radius': '8px', px: 1 }}> <List sx={{ '--ListItem-radius': '8px', px: 1 }}>
{showProjectPicker {showProjectPicker
? renderModalProjectPicker() ? renderModalProjectPicker()
: renderModalActionItems()} : showPriorityPicker
? renderModalPriorityPicker()
: renderModalActionItems()}
</List> </List>
</AppModal> </AppModal>
) : ( ) : (
@@ -556,7 +675,9 @@ const ChoreActionMenu = ({
left: '50%', left: '50%',
}} }}
> >
{showProjectPicker ? ( {showPriorityPicker ? (
renderMenuPriorityPicker()
) : showProjectPicker ? (
<> <>
<MenuItem <MenuItem
onClick={e => { onClick={e => {