feat: add custom filter chips and filter section components

- Implemented CustomFilterChips component for displaying and managing custom filters with context menu options for pinning, editing, and deleting filters.
- Created FilterSection component to integrate CustomFilterChips and provide UI for creating advanced filters and displaying active filters.
- Introduced useCustomFilters hook to manage custom filter logic, including loading, saving, updating, and deleting filters.
- Added AdvancedFilterBuilder modal for creating and editing advanced filters with dynamic condition handling.
- Implemented SaveFilterModal for saving filters with preview functionality and validation for filter names.
- Enhanced useChoreFilters and useProjectFilter hooks for improved filter management and state handling.
This commit is contained in:
Mo Tarbin
2026-01-26 01:58:21 -05:00
parent 57bc6ad98e
commit 2b8482cb06
10 changed files with 2329 additions and 41 deletions

View File

@@ -0,0 +1,594 @@
import { Add, Delete, Save } from '@mui/icons-material'
import {
Box,
Button,
Chip,
IconButton,
Input,
List,
ListItem,
Option,
Select,
Typography,
} from '@mui/joy'
import { useEffect, useMemo, useState } from 'react'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { filterNameExists } from '../../../utils/CustomFilterStorage'
import { applyFilter } from '../../../utils/FilterEngine'
import Priorities from '../../../utils/Priorities'
const AdvancedFilterBuilder = ({
isOpen,
onClose,
onSave,
members = [],
labels = [],
projects = [],
allChores = [],
userProfile = null,
editingFilter = null,
}) => {
const { ResponsiveModal } = useResponsiveModal()
const [filterName, setFilterName] = useState('')
const [conditions, setConditions] = useState([
{ type: 'assignee', operator: 'is', value: [] },
])
const [error, setError] = useState('')
// Initialize state when editing a filter
useEffect(() => {
if (editingFilter) {
setFilterName(editingFilter.name)
setConditions(editingFilter.conditions || [])
setError('')
} else {
setFilterName('')
setConditions([{ type: 'assignee', operator: 'is', value: [] }])
setError('')
}
}, [editingFilter, isOpen])
const previewChores = useMemo(() => {
const validConditions = conditions.filter(c => {
if (c.type === 'dueDate') return true
return c.value && (Array.isArray(c.value) ? c.value.length > 0 : true)
})
if (validConditions.length === 0) return []
const result = applyFilter(
allChores,
{ conditions: validConditions, operator: 'AND' },
{
userId: userProfile?.id,
members,
labels,
projects,
},
)
return result
}, [conditions, allChores, userProfile, members, labels, projects])
const previewCount = previewChores.length
const previewOverdueCount = previewChores.filter(
chore => chore.nextDueDate && new Date(chore.nextDueDate) < new Date(),
).length
const addCondition = () => {
setConditions([
...conditions,
{ type: 'assignee', operator: 'is', value: [] },
])
}
const removeCondition = index => {
setConditions(conditions.filter((_, i) => i !== index))
}
const updateCondition = (index, field, value) => {
const updated = [...conditions]
updated[index] = { ...updated[index], [field]: value }
if (field === 'type') {
updated[index].value = []
if (value === 'dueDate') {
updated[index].operator = 'isOverdue'
updated[index].value = null
} else if (value === 'status') {
updated[index].value = [3]
}
}
setConditions(updated)
}
const handleSave = () => {
if (!filterName.trim()) {
setError('Please enter a filter name')
return
}
// Check for duplicate name, excluding current filter if editing
if (filterNameExists(filterName.trim(), editingFilter?.id)) {
setError('A filter with this name already exists')
return
}
const validConditions = conditions.filter(c => {
if (c.type === 'dueDate') return true
return c.value && (Array.isArray(c.value) ? c.value.length > 0 : true)
})
if (conditions.length === 0 || validConditions.length === 0) {
setError('Please add at least one filter condition')
return
}
const filterData = {
name: filterName.trim(),
conditions: validConditions,
operator: 'AND',
}
// Include ID if editing
if (editingFilter) {
filterData.id = editingFilter.id
}
onSave(filterData)
onClose()
}
const renderValueSelector = (condition, index) => {
switch (condition.type) {
case 'assignee':
return (
<Select
multiple
value={condition.value || []}
onChange={(_, newValue) =>
updateCondition(index, 'value', newValue)
}
placeholder='Select assignees'
sx={{ width: '100%' }}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((selectedElement, idx) => {
const value = selectedElement.value
const member = members.find(
m => String(m.userId) === String(value),
)
return (
<Chip key={`${value}-${idx}`} size='sm'>
{member?.displayName || member?.username || 'Unknown'}
</Chip>
)
})}
</Box>
)}
>
{members.map((member, idx) => (
<Option
key={`member-${member.userId}-${idx}`}
value={member.userId}
>
{member.displayName || member.username} ({member.userId})
</Option>
))}
</Select>
)
case 'createdBy':
return (
<Select
multiple
value={condition.value || []}
onChange={(_, newValue) =>
updateCondition(index, 'value', newValue)
}
placeholder='Select creators'
sx={{ width: '100%' }}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((selectedElement, idx) => {
const value = selectedElement.value
if (value === 'me')
return (
<Chip key={`${value}-${idx}`} size='sm'>
Me
</Chip>
)
const member = members.find(
m => String(m.userId) === String(value),
)
return (
<Chip key={`${value}-${idx}`} size='sm'>
{member?.displayName || member?.username || 'Unknown'}
</Chip>
)
})}
</Box>
)}
>
<Option value='me'>Me</Option>
{members.map((member, idx) => (
<Option
key={`creator-${member.userId}-${idx}`}
value={member.userId}
>
{member.displayName || member.username}
</Option>
))}
</Select>
)
case 'priority':
return (
<Select
multiple
value={condition.value || []}
onChange={(_, newValue) =>
updateCondition(index, 'value', newValue)
}
placeholder='Select priorities'
sx={{ width: '100%' }}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((value, idx) => (
<Chip key={`priority-${value}-${idx}`} size='sm'>
Priority {value}
</Chip>
))}
</Box>
)}
>
{Priorities.map((priority, idx) => (
<Option
key={`priority-opt-${priority.value}-${idx}`}
value={priority.value}
>
{priority.name}
</Option>
))}
</Select>
)
case 'label':
return (
<Select
multiple
value={condition.value || []}
onChange={(_, newValue) =>
updateCondition(index, 'value', newValue)
}
placeholder='Select labels'
sx={{ width: '100%' }}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((selectedElement, idx) => {
const value = selectedElement.value
const label = labels.find(l => String(l.id) === String(value))
return (
<Chip key={`label-chip-${value}-${idx}`} size='sm'>
{label?.name || 'Unknown'}
</Chip>
)
})}
</Box>
)}
>
{labels.map((label, idx) => (
<Option key={`label-opt-${label.id}-${idx}`} value={label.id}>
{label.name}
</Option>
))}
</Select>
)
case 'project':
return (
<Select
multiple
value={condition.value || []}
onChange={(_, newValue) =>
updateCondition(index, 'value', newValue)
}
placeholder='Select projects'
sx={{ width: '100%' }}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((event, idx) => {
const value = event.value
if (value === 'default')
return (
<Chip key={`default-${idx}`} size='sm'>
Default
</Chip>
)
const project = projects.find(
p => String(p.id) === String(value),
)
return (
<Chip key={`project-chip-${value}-${idx}`} size='sm'>
{project?.name || 'Unknown'}
</Chip>
)
})}
</Box>
)}
>
<Option value='default'>Default Project</Option>
{projects
.filter(p => p.id !== 'default')
.map((project, idx) => (
<Option
key={`project-opt-${project.id}-${idx}`}
value={project.id}
>
{project.name}
</Option>
))}
</Select>
)
case 'status':
return (
<Select
value={condition.value?.[0] || 3}
onChange={(_, newValue) =>
updateCondition(index, 'value', [newValue])
}
sx={{ width: '100%' }}
>
<Option value={0}>Active</Option>
<Option value={1}>Started</Option>
<Option value={2}>In Progress</Option>
<Option value={3}>Pending Approval</Option>
</Select>
)
case 'dueDate':
return (
<Select
value={condition.operator}
onChange={(_, newValue) =>
updateCondition(index, 'operator', newValue)
}
sx={{ width: '100%' }}
>
<Option value='isOverdue'>Is Overdue</Option>
<Option value='isDueToday'>Is Due Today</Option>
<Option value='isDueTomorrow'>Is Due Tomorrow</Option>
<Option value='isDueThisWeek'>Is Due This Week</Option>
<Option value='isDueThisMonth'>Is Due This Month</Option>
<Option value='hasNoDueDate'>Has No Due Date</Option>
<Option value='hasDueDate'>Has Due Date</Option>
</Select>
)
default:
return null
}
}
return (
<ResponsiveModal open={isOpen} onClose={onClose} size='md'>
<Typography level='h4' sx={{ mb: 2 }}>
{editingFilter ? 'Edit Filter' : 'Create Advanced Filter'}
</Typography>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
height: '100%',
}}
>
<Box>
<Typography level='body-sm' sx={{ mb: 1 }}>
Filter Name
</Typography>
<Input
placeholder='e.g., High Priority Tasks for Team'
value={filterName}
onChange={e => {
setFilterName(e.target.value)
setError('')
}}
error={!!error}
autoFocus
/>
{error && (
<Typography level='body-sm' color='danger' sx={{ mt: 0.5 }}>
{error}
</Typography>
)}
</Box>
<Box
sx={{
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
}}
>
<Typography level='body-sm' sx={{ mb: 1 }}>
Filter Conditions (All must match)
</Typography>
<List
sx={{
gap: 1,
overflowY: 'auto',
maxHeight: { xs: '40vh', sm: '50vh' },
pr: 0.5,
}}
>
{conditions.map((condition, index) => (
<ListItem
key={index}
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
p: 1.5,
bgcolor: 'background.level1',
borderRadius: 'sm',
position: 'relative',
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
}}
>
<Typography level='body-xs' color='neutral'>
Condition {index + 1}
</Typography>
<IconButton
size='sm'
color='danger'
variant='plain'
onClick={() => removeCondition(index)}
disabled={conditions.length === 1}
>
<Delete />
</IconButton>
</Box>
<Box sx={{ width: '100%' }}>
<Typography level='body-xs' sx={{ mb: 0.5 }}>
Field
</Typography>
<Select
value={condition.type}
onChange={(_, newValue) =>
updateCondition(index, 'type', newValue)
}
sx={{ width: '100%' }}
>
<Option value='assignee'>Assignee</Option>
<Option value='createdBy'>Created By</Option>
<Option value='priority'>Priority</Option>
<Option value='label'>Label</Option>
<Option value='project'>Project</Option>
<Option value='status'>Status</Option>
<Option value='dueDate'>Due Date</Option>
</Select>
</Box>
<Box sx={{ width: '100%' }}>
<Typography level='body-xs' sx={{ mb: 0.5 }}>
{condition.type === 'dueDate' ? 'Condition' : 'Value'}
</Typography>
{renderValueSelector(condition, index)}
</Box>
</ListItem>
))}
</List>
<Button
size='sm'
variant='outlined'
startDecorator={<Add />}
onClick={addCondition}
sx={{ mt: 1 }}
>
Add Condition
</Button>
</Box>
<Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 1,
}}
>
<Typography level='body-sm'>Preview</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Chip size='sm' variant='soft' color='neutral'>
{previewCount} tasks
</Chip>
{previewOverdueCount > 0 && (
<Chip size='sm' variant='solid' color='danger'>
{previewOverdueCount} overdue
</Chip>
)}
</Box>
</Box>
<Box
sx={{
maxHeight: 150,
overflowY: 'auto',
bgcolor: 'background.level1',
p: 1,
borderRadius: 'sm',
}}
>
{previewCount === 0 ? (
<Typography
level='body-sm'
color='neutral'
sx={{ textAlign: 'center', py: 2 }}
>
No tasks match these filters
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{previewChores.slice(0, 3).map(chore => (
<Box
key={chore.id}
sx={{
bgcolor: 'background.surface',
p: 1,
borderRadius: 'sm',
}}
>
<Typography level='body-sm'>{chore.name}</Typography>
</Box>
))}
{previewCount > 3 && (
<Typography
level='body-xs'
color='neutral'
sx={{ textAlign: 'center', mt: 0.5 }}
>
...and {previewCount - 3} more
</Typography>
)}
</Box>
)}
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button variant='outlined' color='neutral' onClick={onClose}>
Cancel
</Button>
<Button
variant='solid'
color='primary'
onClick={handleSave}
startDecorator={<Save />}
>
{editingFilter ? 'Update Filter' : 'Save Filter'}
</Button>
</Box>
</Box>
</ResponsiveModal>
)
}
export default AdvancedFilterBuilder

View File

@@ -0,0 +1,263 @@
import { Save, Star, StarBorder } from '@mui/icons-material'
import {
Box,
Button,
Chip,
Input,
Modal,
ModalClose,
ModalDialog,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { filterNameExists } from '../../../utils/CustomFilterStorage'
import CompactChoreCard from '../../Chores/CompactChoreCard'
const SaveFilterModal = ({
isOpen,
onClose,
onSave,
filterData,
previewChores = [],
previewCount = 0,
previewOverdueCount = 0,
}) => {
const [filterName, setFilterName] = useState('')
const [isPinned, setIsPinned] = useState(false)
const [error, setError] = useState('')
const handleSave = () => {
if (!filterName.trim()) {
setError('Please enter a filter name')
return
}
if (filterNameExists(filterName.trim())) {
setError('A filter with this name already exists')
return
}
const newFilter = {
...filterData,
name: filterName.trim(),
isPinned,
}
onSave(newFilter)
onClose()
}
const getConditionLabel = condition => {
switch (condition.type) {
case 'assignee':
if (condition.value === 'me') return 'Assigned to me'
if (condition.value === 'others') return 'Assigned to others'
return 'Specific assignee'
case 'createdBy':
if (condition.value === 'me') return 'Created by me'
return 'Created by specific user'
case 'priority':
return `Priority ${condition.value}`
case 'status':
return condition.value === 3 ? 'Pending approval' : `Status ${condition.value}`
case 'dueDate':
if (condition.operator === 'isOverdue') return 'Overdue'
if (condition.operator === 'isDueToday') return 'Due today'
if (condition.operator === 'isDueThisWeek') return 'Due this week'
if (condition.operator === 'hasNoDueDate') return 'No due date'
return 'Due date condition'
case 'label':
return 'Has label'
case 'project':
if (condition.value === 'default') return 'Default project'
return 'Specific project'
default:
return condition.type
}
}
return (
<Modal open={isOpen} onClose={onClose}>
<ModalDialog
sx={{
maxWidth: 500,
width: '90%',
maxHeight: '90vh',
overflow: 'auto',
}}
>
<ModalClose />
<Typography level='h4' sx={{ mb: 2 }}>
Save Filter
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box>
<Typography level='body-sm' sx={{ mb: 1 }}>
Filter Name
</Typography>
<Input
placeholder='e.g., My High Priority Tasks'
value={filterName}
onChange={e => {
setFilterName(e.target.value)
setError('')
}}
error={!!error}
autoFocus
/>
{error && (
<Typography level='body-sm' color='danger' sx={{ mt: 0.5 }}>
{error}
</Typography>
)}
</Box>
<Box>
<Typography level='body-sm' sx={{ mb: 1 }}>
Filter Conditions
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{filterData.conditions.length === 0 ? (
<Typography level='body-sm' color='neutral'>
No filters applied
</Typography>
) : (
filterData.conditions.map((condition, index) => (
<Chip
key={index}
variant='soft'
color='neutral'
size='sm'
>
{getConditionLabel(condition)}
</Chip>
))
)}
</Box>
</Box>
<Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 1,
}}
>
<Typography level='body-sm'>Preview</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Chip size='sm' variant='soft' color='neutral'>
{previewCount} tasks
</Chip>
{previewOverdueCount > 0 && (
<Chip size='sm' variant='solid' color='danger'>
{previewOverdueCount} overdue
</Chip>
)}
</Box>
</Box>
<Box
sx={{
maxHeight: 200,
overflowY: 'auto',
bgcolor: 'background.level1',
p: 1,
borderRadius: 'sm',
}}
>
{previewCount === 0 ? (
<Typography
level='body-sm'
color='neutral'
sx={{ textAlign: 'center', py: 2 }}
>
No tasks match these filters
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{previewChores.slice(0, 3).map(chore => (
<Box
key={chore.id}
sx={{
bgcolor: 'background.surface',
p: 1,
borderRadius: 'sm',
}}
>
<Typography level='body-sm'>{chore.name}</Typography>
</Box>
))}
{previewCount > 3 && (
<Typography
level='body-xs'
color='neutral'
sx={{ textAlign: 'center', mt: 0.5 }}
>
...and {previewCount - 3} more
</Typography>
)}
</Box>
)}
</Box>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
cursor: 'pointer',
p: 1,
borderRadius: 'sm',
'&:hover': {
bgcolor: 'background.level1',
},
}}
onClick={() => setIsPinned(!isPinned)}
>
{isPinned ? (
<Star color='warning' />
) : (
<StarBorder color='neutral' />
)}
<Box>
<Typography level='body-sm' fontWeight='md'>
Pin this filter
</Typography>
<Typography level='body-xs' color='neutral'>
Pinned filters appear first in the list
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button variant='outlined' color='neutral' onClick={onClose}>
Cancel
</Button>
<Button
variant='solid'
color='primary'
onClick={handleSave}
startDecorator={<Save />}
disabled={!filterName.trim() || filterData.conditions.length === 0}
>
Save Filter
</Button>
</Box>
</Box>
</ModalDialog>
</Modal>
)
}
export default SaveFilterModal