From 4098f2e49850c279fdb79c333e1bebfd410b741d Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Tue, 17 Jun 2025 01:00:39 -0400 Subject: [PATCH] feat: Implement multi-select functionality with keyboard shortcuts and bulk actions --- src/views/Chores/ChoreCard.jsx | 51 ++- src/views/Chores/CompactChoreCard.jsx | 43 +- src/views/Chores/MultiSelectHelp.jsx | 198 +++++++++ src/views/Chores/MyChores.jsx | 592 +++++++++++++++++++++++++- 4 files changed, 873 insertions(+), 11 deletions(-) create mode 100644 src/views/Chores/MultiSelectHelp.jsx diff --git a/src/views/Chores/ChoreCard.jsx b/src/views/Chores/ChoreCard.jsx index 0a1753d..d1b7f1d 100644 --- a/src/views/Chores/ChoreCard.jsx +++ b/src/views/Chores/ChoreCard.jsx @@ -11,6 +11,7 @@ import { Box, Button, Card, + Checkbox, Chip, CircularProgress, Grid, @@ -47,6 +48,10 @@ const ChoreCard = ({ sx, viewOnly, onChipClick, + // Multi-select props + isMultiSelectMode = false, + isSelected = false, + onSelectionToggle, }) => { const [isChangeDueDateModalOpen, setIsChangeDueDateModalOpen] = React.useState(false) @@ -392,14 +397,54 @@ const ChoreCard = ({ flexDirection: 'column', justifyContent: 'space-between', p: 2, - // backgroundColor: 'white', boxShadow: 'sm', borderRadius: 20, key: `${chore.id}-card`, - - // mb: 2, + position: 'relative', + backgroundColor: 'background.surface', + border: '1px solid', + borderColor: 'divider', + transition: 'all 0.2s ease-in-out', + '&:hover': { + boxShadow: 'md', + borderColor: 'primary.300', + }, + // Add padding when in multi-select mode to account for checkbox + pl: isMultiSelectMode ? 6 : 2, }} > + {/* Multi-select checkbox */} + {isMultiSelectMode && ( + e.stopPropagation()} + /> + )} { const [isChangeDueDateModalOpen, setIsChangeDueDateModalOpen] = React.useState(false) @@ -389,16 +394,17 @@ const CompactChoreCard = ({ ...sx, display: 'flex', alignItems: 'center', - // px: 1, - // py: 0.75, minHeight: 56, // More compact height cursor: 'pointer', borderBottom: '1px solid', borderColor: 'divider', position: 'relative', - pl: '16px', // Add left padding for the priority bar + pl: isMultiSelectMode ? '48px' : '16px', // Add space for checkbox when in multi-select mode + backgroundColor: 'background.surface', + transition: 'all 0.2s ease-in-out', '&:hover': { bgcolor: 'background.level1', + boxShadow: 'sm', }, '&:last-child': { borderBottom: 'none', @@ -416,6 +422,37 @@ const CompactChoreCard = ({ }} onClick={() => navigate(`/chores/${chore.id}`)} > + {/* Multi-select checkbox */} + {isMultiSelectMode && ( + e.stopPropagation()} + /> + )} {/* Priority bar clickable area */} {chore.priority > 0 && ( { + const [isHelpOpen, setIsHelpOpen] = useState(false) + + if (!isVisible) return null + + return ( + <> + {/* Help Button */} + setIsHelpOpen(true)} + sx={{ + position: 'fixed', + bottom: 24, + right: 24, + zIndex: 1000, + width: 48, + height: 48, + borderRadius: '50%', + boxShadow: 'lg', + }} + title='Show keyboard shortcuts' + > + + + + {/* Help Modal */} + setIsHelpOpen(false)}> + + + + + Multi-select Mode + + setIsHelpOpen(false)} + > + + + + + + Use these keyboard shortcuts to work more efficiently with multiple + tasks: + + + + {/* Selection shortcuts */} + + + Selection + + + + + + + + {/* Action shortcuts */} + + + Actions + + + + + + + + {/* Interface shortcuts */} + + + Interface + + + + + + + + + + + + + + + + ) +} + +const ShortcutItem = ({ keys, description }) => ( + + + {description} + + + {keys.map((key, index) => ( + + {index > 0 && ( + + + + + )} + + + {key} + + + + ))} + + +) + +export default MultiSelectHelp diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index df6ee79..4a9985e 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -2,10 +2,17 @@ import { Add, Bolt, CancelRounded, + CheckBox, + CheckBoxOutlineBlank, + Close, + Delete, + Done, EditCalendar, ExpandCircleDown, Grain, PriorityHigh, + SelectAll, + SkipNext, Sort, Style, Unarchive, @@ -37,12 +44,16 @@ import { GetArchivedChores } from '../../utils/Fetcher' import Priorities from '../../utils/Priorities' import LoadingComponent from '../components/Loading' import { useLabels } from '../Labels/LabelQueries' +import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import ChoreCard from './ChoreCard' import CompactChoreCard from './CompactChoreCard' import IconButtonWithMenu from './IconButtonWithMenu' +import MultiSelectHelp from './MultiSelectHelp' +import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' import { ChoreFilters, ChoresGrouper, ChoreSorter } from '../../utils/Chores' +import { DeleteChore, MarkChoreComplete, SkipChore } from '../../utils/Fetcher' import TaskInput from '../components/AddTaskModal' import { canScheduleNotification, @@ -55,7 +66,8 @@ import SortAndGrouping from './SortAndGrouping' const MyChores = () => { const { data: userProfile, isLoading: isUserProfileLoading } = useUserProfile() - const { showSuccess } = useNotification() + const { showSuccess, showError } = useNotification() + const { impersonatedUser } = useImpersonateUser() const [chores, setChores] = useState([]) const [archivedChores, setArchivedChores] = useState(null) const [filteredChores, setFilteredChores] = useState([]) @@ -92,6 +104,11 @@ const MyChores = () => { } = useChores() const { data: membersData, isLoading: membersLoading } = useCircleMembers() + // Multi-select state + const [isMultiSelectMode, setIsMultiSelectMode] = useState(false) + const [selectedChores, setSelectedChores] = useState(new Set()) + const [confirmModelConfig, setConfirmModelConfig] = useState({}) + useEffect(() => { if (!choresLoading && !membersLoading && userProfile) { setPerformers(membersData.res) @@ -143,20 +160,133 @@ const MyChores = () => { } }, [searchInputFocus]) - // add listern to Control/Command + K to focus on search input + // Keyboard shortcuts for multi-select and other actions useEffect(() => { const handleKeyDown = event => { + // Ctrl/Cmd + K to open task modal if ((event.ctrlKey || event.metaKey) && event.key === 'k') { event.preventDefault() setAddTaskModalOpen(true) + return + } + + // Ctrl/Cmd + A to select all - works both in and out of multi-select mode + if ((event.ctrlKey || event.metaKey) && event.key === 'a') { + event.preventDefault() + if (!isMultiSelectMode) { + // Enable multi-select mode and select all visible tasks + setIsMultiSelectMode(true) + setTimeout(() => { + selectAllVisibleChores() + }, 0) + // showSuccess({ + // title: '🎯 Multi-select Mode Active', + // message: 'Selected all visible tasks. Press Esc to exit.', + // }) + } else { + // Already in multi-select mode, check if all visible tasks are already selected + let visibleChores = [] + + if (searchTerm?.length > 0 || searchFilter !== 'All') { + visibleChores = filteredChores + const allVisibleSelected = + visibleChores.length > 0 && + visibleChores.every(chore => selectedChores.has(chore.id)) + + if (allVisibleSelected) { + showSuccess({ + title: '✅ All Tasks Selected', + message: `All ${visibleChores.length} filtered task${visibleChores.length !== 1 ? 's are' : ' is'} already selected.`, + }) + } else { + selectAllVisibleChores() + showSuccess({ + title: '🎯 Tasks Selected', + message: `Selected ${visibleChores.length} filtered task${visibleChores.length !== 1 ? 's' : ''}.`, + }) + } + } else { + // Check expanded sections first + const expandedChores = choreSections + .filter((section, index) => openChoreSections[index]) + .flatMap(section => section.content || []) + + const allExpandedSelected = + expandedChores.length > 0 && + expandedChores.every(chore => selectedChores.has(chore.id)) + + // Get all chores (including collapsed sections) + const allChores = choreSections.flatMap( + section => section.content || [], + ) + const allChoresSelected = + allChores.length > 0 && + allChores.every(chore => selectedChores.has(chore.id)) + + if (allChoresSelected) { + // All chores (including collapsed) are already selected + showSuccess({ + title: '✅ All Tasks Selected', + message: `All ${allChores.length} task${allChores.length !== 1 ? 's are' : ' is'} already selected (including collapsed sections).`, + }) + } else if (allExpandedSelected) { + // All expanded are selected, now select ALL (including collapsed) + selectAllVisibleChores() // This will now select all chores + const collapsedCount = allChores.length - expandedChores.length + showSuccess({ + title: '🎯 All Tasks Selected', + message: `Selected all ${allChores.length} tasks (including ${collapsedCount} from collapsed sections).`, + }) + } else { + // Not all expanded are selected, select expanded only + selectAllVisibleChores() // This will select expanded only + showSuccess({ + title: '🎯 Tasks Selected', + message: `Selected ${expandedChores.length} task${expandedChores.length !== 1 ? 's' : ''} from expanded sections.`, + }) + } + } + } + return + } + + // Multi-select keyboard shortcuts (only when in multi-select mode) + if (isMultiSelectMode) { + // Escape to clear selection or exit multi-select mode + if (event.key === 'Escape') { + event.preventDefault() + if (selectedChores.size > 0) { + clearSelection() + } else { + setIsMultiSelectMode(false) + } + return + } + + // Delete/Backspace key for bulk delete (with confirmation) + if ( + (event.key === 'Delete' || event.key === 'Backspace') && + selectedChores.size > 0 + ) { + event.preventDefault() + handleBulkDelete() + return + } + + // Enter key for bulk complete + if (event.key === 'Enter' && selectedChores.size > 0) { + event.preventDefault() + handleBulkComplete() + return + } } } - document.addEventListener('keydown', handleKeyDown) + document.addEventListener('keydown', handleKeyDown) return () => { document.removeEventListener('keydown', handleKeyDown) } - }, []) + }, [isMultiSelectMode, selectedChores.size]) const setSelectedChoreSectionWithCache = value => { setSelectedChoreSection(value) localStorage.setItem('selectedChoreSection', value) @@ -188,6 +318,10 @@ const MyChores = () => { performers={performers} userLabels={userLabels} onChipClick={handleLabelFiltering} + // Multi-select props + isMultiSelectMode={isMultiSelectMode} + isSelected={selectedChores.has(chore.id)} + onSelectionToggle={() => toggleChoreSelection(chore.id)} /> ) } @@ -375,6 +509,246 @@ const MyChores = () => { setFilteredChores(fuse.search(term).map(result => result.item)) } + // Multi-select helper functions + const toggleMultiSelectMode = () => { + const newMode = !isMultiSelectMode + setIsMultiSelectMode(newMode) + + if (newMode) { + setSelectedChores(new Set()) // Clear selection when exiting multi-select + } + } + + const toggleChoreSelection = choreId => { + const newSelection = new Set(selectedChores) + if (newSelection.has(choreId)) { + newSelection.delete(choreId) + } else { + newSelection.add(choreId) + } + setSelectedChores(newSelection) + } + + const selectAllVisibleChores = () => { + let visibleChores = [] + + if (searchTerm?.length > 0 || searchFilter !== 'All') { + // If there's a search term or filter, all filtered chores are visible + visibleChores = filteredChores + } else { + // First, get chores from expanded sections only + const expandedChores = choreSections + .filter((section, index) => openChoreSections[index]) // Only expanded sections + .flatMap(section => section.content || []) // Get all chores from expanded sections + + // Check if all expanded chores are already selected + const allExpandedSelected = + expandedChores.length > 0 && + expandedChores.every(chore => selectedChores.has(chore.id)) + + if (allExpandedSelected) { + // If all expanded chores are already selected, select ALL chores (including collapsed sections) + visibleChores = choreSections.flatMap(section => section.content || []) + } else { + // Otherwise, just select expanded chores + visibleChores = expandedChores + } + } + + if (visibleChores.length > 0) { + const allIds = new Set(visibleChores.map(chore => chore.id)) + setSelectedChores(allIds) + } + } + + const clearSelection = () => { + // if already empty, just exit multi-select mode: + if (selectedChores.size === 0) { + setIsMultiSelectMode(false) + return + } + setSelectedChores(new Set()) + } + + const getSelectedChoresData = () => { + const allChores = [...chores, ...(archivedChores || [])] + return Array.from(selectedChores) + .map(id => allChores.find(chore => chore.id === id)) + .filter(Boolean) + } + + // Bulk operations with improved UX and confirmation modal + const handleBulkComplete = async () => { + const selectedData = getSelectedChoresData() + if (selectedData.length === 0) return + + setConfirmModelConfig({ + isOpen: true, + title: 'Complete Tasks', + confirmText: 'Complete', + cancelText: 'Cancel', + message: `Mark ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} as completed?`, + onClose: async isConfirmed => { + if (isConfirmed === true) { + try { + const completedTasks = [] + const failedTasks = [] + + for (const chore of selectedData) { + try { + await MarkChoreComplete( + chore.id, + impersonatedUser + ? { completedBy: impersonatedUser.userId } + : null, + null, + null, + ) + completedTasks.push(chore) + } catch (error) { + failedTasks.push(chore) + } + } + + if (completedTasks.length > 0) { + showSuccess({ + title: '✅ Tasks Completed', + message: `Successfully completed ${completedTasks.length} task${completedTasks.length > 1 ? 's' : ''}.`, + }) + } + + if (failedTasks.length > 0) { + showError({ + title: 'Some Tasks Failed', + message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be completed.`, + }) + } + + refetchChores() + clearSelection() + } catch (error) { + showError({ + title: 'Bulk Complete Failed', + message: 'An unexpected error occurred. Please try again.', + }) + } + } + setConfirmModelConfig({}) + }, + }) + } + + const handleBulkDelete = async () => { + const selectedData = getSelectedChoresData() + if (selectedData.length === 0) return + + setConfirmModelConfig({ + isOpen: true, + title: 'Delete Tasks', + confirmText: 'Delete', + cancelText: 'Cancel', + message: `Delete ${selectedData.length} task${selectedData.length > 1 ? 's' : ''}?\n\nThis action cannot be undone.`, + onClose: async isConfirmed => { + if (isConfirmed === true) { + try { + const deletedTasks = [] + const failedTasks = [] + + for (const chore of selectedData) { + try { + await DeleteChore(chore.id) + deletedTasks.push(chore) + } catch (error) { + failedTasks.push(chore) + } + } + + if (deletedTasks.length > 0) { + showSuccess({ + title: '🗑️ Tasks Deleted', + message: `Successfully deleted ${deletedTasks.length} task${deletedTasks.length > 1 ? 's' : ''}.`, + }) + + const deletedIds = new Set(deletedTasks.map(c => c.id)) + setChores(chores.filter(c => !deletedIds.has(c.id))) + setFilteredChores( + filteredChores.filter(c => !deletedIds.has(c.id)), + ) + } + + if (failedTasks.length > 0) { + showError({ + title: 'Some Tasks Failed', + message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be deleted.`, + }) + } + + clearSelection() + } catch (error) { + showError({ + title: 'Bulk Delete Failed', + message: 'An unexpected error occurred. Please try again.', + }) + } + } + setConfirmModelConfig({}) + }, + }) + } + + const handleBulkSkip = async () => { + const selectedData = getSelectedChoresData() + if (selectedData.length === 0) return + + setConfirmModelConfig({ + isOpen: true, + title: 'Skip Tasks', + confirmText: 'Skip', + cancelText: 'Cancel', + message: `Skip ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} to next due date?`, + onClose: async isConfirmed => { + if (isConfirmed === true) { + try { + const skippedTasks = [] + const failedTasks = [] + + for (const chore of selectedData) { + try { + await SkipChore(chore.id) + skippedTasks.push(chore) + } catch (error) { + failedTasks.push(chore) + } + } + + if (skippedTasks.length > 0) { + showSuccess({ + title: '⏭️ Tasks Skipped', + message: `Successfully skipped ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`, + }) + } + + if (failedTasks.length > 0) { + showError({ + title: 'Some Tasks Failed', + message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be skipped.`, + }) + } + + refetchChores() + clearSelection() + } catch (error) { + showError({ + title: 'Bulk Skip Failed', + message: 'An unexpected error occurred. Please try again.', + }) + } + } + setConfirmModelConfig({}) + }, + }) + } + if ( isUserProfileLoading || userLabelsLoading || @@ -536,6 +910,26 @@ const MyChores = () => { > {isCompactView ? : } + + {/* Multi-select Toggle Button */} + + {isMultiSelectMode ? : } + {showSearchFilter && (
@@ -657,6 +1051,186 @@ const MyChores = () => {
)} + + {/* Multi-select Toolbar */} + {isMultiSelectMode && ( + + {/* Selection Info and Controls */} + + + + + {selectedChores.size} task + {selectedChores.size !== 1 ? 's' : ''} selected + + + + + + + + + + + + {/* Action Buttons */} + + + + + {/* + + + + + */} + + + )} + {searchFilter !== 'All' && ( { + + {/* Multi-select Help - only show when in multi-select mode */} + + + {/* Confirmation Modal for bulk operations */} + {confirmModelConfig?.isOpen && ( + + )} ) } @@ -949,7 +1531,7 @@ const FILTERS = { return chore.assignedTo === userID }) }, - 'No Due Date': function (chores, userID) { + 'No Due Date': function (chores) { return chores.filter(chore => { return chore.nextDueDate === null })