- Refactored bulk operations in useChoreActions to streamline completion, archiving, deletion, and other actions. - Introduced new hooks for managing local chore state during bulk operations. - Added handleBulkDueDate, handleBulkAssignee, handleBulkPriority, and handleBulkLabels functions for better task management. - Implemented a new LabelDetailView component to display and manage tasks associated with a specific label. - Updated LabelView to navigate to LabelDetailView on label click. - Improved multi-select functionality to support range selection and summary of selected chores. - Minor UI adjustments and text updates in AdvancedOptionsSection for clarity.
212 lines
6.8 KiB
JavaScript
212 lines
6.8 KiB
JavaScript
import { useCallback, useRef, useState } from 'react'
|
|
|
|
// A field is "shared" only when every selected chore agrees on it. Anything
|
|
// else is mixed, which the bulk editor shows as an unset control rather than
|
|
// pretending one of the values is the current one.
|
|
const sharedValue = (items, pick) => {
|
|
if (items.length === 0) return { value: null, isMixed: false }
|
|
const first = pick(items[0])
|
|
const isMixed = items.some(item => pick(item) !== first)
|
|
return { value: isMixed ? null : first, isMixed }
|
|
}
|
|
|
|
const labelIdsOf = chore => (chore.labelsV2 || []).map(label => label.id)
|
|
|
|
export const useMultiSelect = () => {
|
|
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
|
|
const [selectedChores, setSelectedChores] = useState(new Set())
|
|
// Anchor for shift-click range selection. A ref because it only matters at
|
|
// the moment of the next click and should never trigger a render.
|
|
const lastSelectedId = useRef(null)
|
|
|
|
const toggleMultiSelectMode = useCallback(() => {
|
|
setIsMultiSelectMode(prev => {
|
|
if (!prev) setSelectedChores(new Set())
|
|
return !prev
|
|
})
|
|
lastSelectedId.current = null
|
|
}, [])
|
|
|
|
const toggleChoreSelection = useCallback(choreId => {
|
|
setSelectedChores(prev => {
|
|
const next = new Set(prev)
|
|
if (next.has(choreId)) {
|
|
next.delete(choreId)
|
|
} else {
|
|
next.add(choreId)
|
|
}
|
|
return next
|
|
})
|
|
lastSelectedId.current = choreId
|
|
}, [])
|
|
|
|
// Entry point for press-and-hold on a task card: turn multi-select on (if it
|
|
// isn't already) with that task selected.
|
|
const enterMultiSelectWithChore = useCallback(
|
|
choreId => {
|
|
if (!isMultiSelectMode) {
|
|
setIsMultiSelectMode(true)
|
|
setSelectedChores(new Set([choreId]))
|
|
lastSelectedId.current = choreId
|
|
return
|
|
}
|
|
toggleChoreSelection(choreId)
|
|
},
|
|
[isMultiSelectMode, toggleChoreSelection],
|
|
)
|
|
|
|
// Shift-click: add everything between the previous click and this one, in the
|
|
// order the user actually sees them. Falls back to a plain toggle when there
|
|
// is no anchor yet or the anchor has scrolled out of the current list.
|
|
const selectChoreRange = useCallback(
|
|
(choreId, orderedChores = []) => {
|
|
const anchorId = lastSelectedId.current
|
|
if (anchorId === null || anchorId === choreId) {
|
|
toggleChoreSelection(choreId)
|
|
return
|
|
}
|
|
|
|
const anchorIndex = orderedChores.findIndex(c => c.id === anchorId)
|
|
const targetIndex = orderedChores.findIndex(c => c.id === choreId)
|
|
if (anchorIndex === -1 || targetIndex === -1) {
|
|
toggleChoreSelection(choreId)
|
|
return
|
|
}
|
|
|
|
const [from, to] =
|
|
anchorIndex < targetIndex
|
|
? [anchorIndex, targetIndex]
|
|
: [targetIndex, anchorIndex]
|
|
|
|
setSelectedChores(prev => {
|
|
const next = new Set(prev)
|
|
for (let i = from; i <= to; i++) next.add(orderedChores[i].id)
|
|
return next
|
|
})
|
|
lastSelectedId.current = choreId
|
|
},
|
|
[toggleChoreSelection],
|
|
)
|
|
|
|
const selectAllVisibleChores = useCallback(
|
|
(visibleChores, choreSections = [], openChoreSections = {}) => {
|
|
let choresToSelect = []
|
|
|
|
if (visibleChores && visibleChores.length > 0) {
|
|
choresToSelect = visibleChores
|
|
} else {
|
|
const expandedChores = choreSections
|
|
.filter((_section, index) => openChoreSections[index])
|
|
.flatMap(section => section.content || [])
|
|
|
|
const allExpandedSelected =
|
|
expandedChores.length > 0 &&
|
|
expandedChores.every(chore => selectedChores.has(chore.id))
|
|
|
|
if (allExpandedSelected) {
|
|
choresToSelect = choreSections.flatMap(
|
|
section => section.content || [],
|
|
)
|
|
} else {
|
|
choresToSelect = expandedChores
|
|
}
|
|
}
|
|
|
|
if (choresToSelect.length > 0) {
|
|
setSelectedChores(new Set(choresToSelect.map(chore => chore.id)))
|
|
}
|
|
|
|
return choresToSelect.length
|
|
},
|
|
[selectedChores],
|
|
)
|
|
|
|
const clearSelection = useCallback(() => {
|
|
lastSelectedId.current = null
|
|
if (selectedChores.size === 0) {
|
|
setIsMultiSelectMode(false)
|
|
return
|
|
}
|
|
setSelectedChores(new Set())
|
|
}, [selectedChores.size])
|
|
|
|
const getSelectedChoresData = useCallback(
|
|
allChores => {
|
|
if (selectedChores.size === 0) return []
|
|
const byId = new Map(allChores.map(chore => [chore.id, chore]))
|
|
return Array.from(selectedChores)
|
|
.map(id => byId.get(id))
|
|
.filter(Boolean)
|
|
},
|
|
[selectedChores],
|
|
)
|
|
|
|
// What the bulk editor needs to render its controls: the current value where
|
|
// the selection agrees, and which labels are on all / only some of them so
|
|
// "add" and "remove" can be offered accurately.
|
|
const getSelectionSummary = useCallback(
|
|
allChores => {
|
|
const selected = getSelectedChoresData(allChores)
|
|
|
|
const labelCounts = new Map()
|
|
const labelsById = new Map()
|
|
selected.forEach(chore => {
|
|
;(chore.labelsV2 || []).forEach(label => {
|
|
labelsById.set(label.id, label)
|
|
labelCounts.set(label.id, (labelCounts.get(label.id) || 0) + 1)
|
|
})
|
|
})
|
|
|
|
const commonLabelIds = []
|
|
const partialLabelIds = []
|
|
labelCounts.forEach((count, id) => {
|
|
if (count === selected.length) commonLabelIds.push(id)
|
|
else partialLabelIds.push(id)
|
|
})
|
|
|
|
return {
|
|
count: selected.length,
|
|
assignee: sharedValue(selected, c => c.assignedTo),
|
|
priority: sharedValue(selected, c => c.priority),
|
|
dueDate: sharedValue(selected, c => c.nextDueDate),
|
|
project: sharedValue(selected, c => c.projectId ?? null),
|
|
labels: {
|
|
byId: labelsById,
|
|
common: commonLabelIds,
|
|
partial: partialLabelIds,
|
|
// Anything present on at least one chore can be removed.
|
|
removable: [...commonLabelIds, ...partialLabelIds],
|
|
},
|
|
// Candidate assignees common to the whole selection. An empty (or
|
|
// absent) `assignees` list on a chore means "anyone", so those chores
|
|
// place no restriction. null here means no restriction at all, and the
|
|
// caller should offer every circle member.
|
|
assignableUserIds: selected.reduce((acc, chore) => {
|
|
const ids = (chore.assignees || []).map(a => a.userId)
|
|
if (ids.length === 0) return acc
|
|
if (acc === null) return ids
|
|
return acc.filter(id => ids.includes(id))
|
|
}, null),
|
|
hasArchived: selected.some(chore => chore.isActive === false),
|
|
labelIdsOf,
|
|
}
|
|
},
|
|
[getSelectedChoresData],
|
|
)
|
|
|
|
return {
|
|
isMultiSelectMode,
|
|
selectedChores,
|
|
toggleMultiSelectMode,
|
|
toggleChoreSelection,
|
|
selectChoreRange,
|
|
enterMultiSelectWithChore,
|
|
selectAllVisibleChores,
|
|
clearSelection,
|
|
getSelectedChoresData,
|
|
getSelectionSummary,
|
|
setIsMultiSelectMode,
|
|
setSelectedChores,
|
|
}
|
|
}
|