feat: enhance filter functionality and UI improvements

- Added FilterView component for managing custom filters.
- Implemented temporary filter application in MyChores and custom filter hooks.
- Improved sorting of archived chores by updated date.
- Enhanced SortAndGrouping component with keyboard navigation support.
- Updated CustomFilterChips to visually indicate active filters.
- Refactored AdvancedFilterBuilder to improve usability and added listbox positioning.
- Integrated project selection caching in ProjectView for better navigation.
- Cleaned up unused imports and optimized existing code for better performance.
This commit is contained in:
Mo Tarbin
2026-01-27 23:50:49 -05:00
parent aa46eb4c6d
commit 448b8fa160
11 changed files with 669 additions and 257 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useLayoutEffect, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import { useLocation } from 'react-router-dom'
import './PageTransition.css'

View File

@@ -23,6 +23,7 @@ import ArchivedTasks from '../views/Chores/ArchivedTasks'
import MyChores from '../views/Chores/MyChores'
import JoinCircleView from '../views/Circles/JoinCircle'
import NotFound from '../views/components/NotFound'
import FilterView from '../views/Filters/FilterView'
import ChoreHistory from '../views/History/ChoreHistory'
import LabelView from '../views/Labels/LabelView'
import Landing from '../views/Landing/Landing'
@@ -30,7 +31,6 @@ import PaymentCancelledView from '../views/Payments/PaymentFailView'
import PaymentSuccessView from '../views/Payments/PaymentSuccessView'
import PrivacyPolicyView from '../views/PrivacyPolicy/PrivacyPolicyView'
import ProjectView from '../views/Projects/ProjectView'
import FilterView from '../views/Filters/FilterView'
import APITokenSettings from '../views/Settings/APITokenSettings'
import MFASettings from '../views/Settings/MFASettings'
import NotificationSetting from '../views/Settings/NotificationSetting'

View File

@@ -8,17 +8,10 @@
const STORAGE_KEY = 'customFilters'
const MAX_FILTERS = 20 // Limit to prevent localStorage overflow
/**
* Generate a unique filter ID
*/
const generateFilterId = () => {
return `filter_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
return `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
/**
* Get all saved filters
* @returns {Array} - Array of filter objects
*/
export const getSavedFilters = () => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
@@ -26,7 +19,6 @@ export const getSavedFilters = () => {
const filters = JSON.parse(stored)
// Ensure filters have required fields
return filters.filter(f => f.id && f.name && f.conditions)
} catch (error) {
console.error('Error loading saved filters:', error)
@@ -34,18 +26,15 @@ export const getSavedFilters = () => {
}
}
/**
* Save a new filter
* @param {Object} filter - The filter to save
* @returns {Object} - The saved filter with generated ID and metadata
*/
export const saveFilter = (filter) => {
export const saveFilter = filter => {
try {
const filters = getSavedFilters()
// Check limit
if (filters.length >= MAX_FILTERS) {
throw new Error(`Maximum of ${MAX_FILTERS} filters allowed. Please delete some filters first.`)
throw new Error(
`Maximum of ${MAX_FILTERS} filters allowed. Please delete some filters first.`,
)
}
// Create new filter with metadata
@@ -61,7 +50,7 @@ export const saveFilter = (filter) => {
updatedAt: new Date().toISOString(),
isPinned: filter.isPinned || false,
usageCount: 0,
lastUsedAt: null
lastUsedAt: null,
}
// Add to filters array
@@ -77,12 +66,7 @@ export const saveFilter = (filter) => {
}
}
/**
* Update an existing filter
* @param {string} filterId - The ID of the filter to update
* @param {Object} updates - The fields to update
* @returns {Object} - The updated filter
*/
export const updateFilter = (filterId, updates) => {
try {
const filters = getSavedFilters()
@@ -96,7 +80,7 @@ export const updateFilter = (filterId, updates) => {
const updatedFilter = {
...filters[filterIndex],
...updates,
updatedAt: new Date().toISOString()
updatedAt: new Date().toISOString(),
}
filters[filterIndex] = updatedFilter
@@ -111,12 +95,7 @@ export const updateFilter = (filterId, updates) => {
}
}
/**
* Delete a filter
* @param {string} filterId - The ID of the filter to delete
* @returns {boolean} - Success status
*/
export const deleteFilter = (filterId) => {
export const deleteFilter = filterId => {
try {
const filters = getSavedFilters()
const updatedFilters = filters.filter(f => f.id !== filterId)
@@ -130,27 +109,20 @@ export const deleteFilter = (filterId) => {
}
}
/**
* Get a single filter by ID
* @param {string} filterId - The ID of the filter
* @returns {Object|null} - The filter object or null if not found
*/
export const getFilterById = (filterId) => {
export const getFilterById = filterId => {
const filters = getSavedFilters()
return filters.find(f => f.id === filterId) || null
}
/**
* Increment usage count for a filter
* @param {string} filterId - The ID of the filter
*/
export const trackFilterUsage = (filterId) => {
export const trackFilterUsage = filterId => {
try {
const filters = getSavedFilters()
const filterIndex = filters.findIndex(f => f.id === filterId)
if (filterIndex !== -1) {
filters[filterIndex].usageCount = (filters[filterIndex].usageCount || 0) + 1
filters[filterIndex].usageCount =
(filters[filterIndex].usageCount || 0) + 1
filters[filterIndex].lastUsedAt = new Date().toISOString()
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters))
@@ -160,12 +132,8 @@ export const trackFilterUsage = (filterId) => {
}
}
/**
* Toggle pin status of a filter
* @param {string} filterId - The ID of the filter
* @returns {boolean} - New pin status
*/
export const toggleFilterPin = (filterId) => {
export const toggleFilterPin = filterId => {
try {
const filters = getSavedFilters()
const filterIndex = filters.findIndex(f => f.id === filterId)
@@ -186,53 +154,31 @@ export const toggleFilterPin = (filterId) => {
}
}
/**
* Get filters sorted by usage (most used first)
* @returns {Array} - Sorted filters
*/
export const getFiltersByUsage = () => {
const filters = getSavedFilters()
return filters.sort((a, b) => (b.usageCount || 0) - (a.usageCount || 0))
}
/**
* Get pinned filters
* @returns {Array} - Pinned filters
*/
export const getPinnedFilters = () => {
const filters = getSavedFilters()
return filters.filter(f => f.isPinned)
}
/**
* Check if filter name already exists
* @param {string} name - The filter name to check
* @param {string} excludeId - Optional ID to exclude from check (for updates)
* @returns {boolean} - Whether the name exists
*/
export const filterNameExists = (name, excludeId = null) => {
const filters = getSavedFilters()
return filters.some(f =>
f.name.toLowerCase() === name.toLowerCase() &&
f.id !== excludeId
return filters.some(
f => f.name.toLowerCase() === name.toLowerCase() && f.id !== excludeId,
)
}
/**
* Export filters as JSON (for backup/sharing)
* @returns {string} - JSON string of all filters
*/
export const exportFilters = () => {
const filters = getSavedFilters()
return JSON.stringify(filters, null, 2)
}
/**
* Import filters from JSON
* @param {string} jsonString - JSON string of filters
* @returns {number} - Number of filters imported
*/
export const importFilters = (jsonString) => {
export const importFilters = jsonString => {
try {
const importedFilters = JSON.parse(jsonString)
@@ -249,7 +195,7 @@ export const importFilters = (jsonString) => {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
usageCount: 0,
lastUsedAt: null
lastUsedAt: null,,
}))
const allFilters = [...existingFilters, ...newFilters]
@@ -268,10 +214,7 @@ export const importFilters = (jsonString) => {
}
}
/**
* Clear all filters (use with caution!)
* @returns {boolean} - Success status
*/
export const clearAllFilters = () => {
try {
localStorage.removeItem(STORAGE_KEY)

View File

@@ -28,7 +28,6 @@ import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useUnArchiveChore } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { ChoreSorter } from '../../utils/Chores'
import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher'
import LoadingComponent from '../components/Loading'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
@@ -68,7 +67,12 @@ const ArchivedTasks = () => {
try {
const response = await GetArchivedChores()
const data = await response.json()
const sortedChores = data.res.sort(ChoreSorter)
// Sort by updatedAt (most recent first)
const sortedChores = data.res.sort((a, b) => {
const dateA = new Date(a.updatedAt || 0)
const dateB = new Date(b.updatedAt || 0)
return dateB - dateA
})
setArchivedChores(sortedChores)
setFilteredChores(sortedChores)
} catch (error) {

View File

@@ -161,15 +161,19 @@ const MyChores = () => {
savedFilters,
activeFilter,
activeFilterId,
tempFilter,
filteredChores: customFilteredChores,
applyCustomFilter,
clearActiveFilter,
applyTempFilter,
clearTempFilter,
saveFilter,
updateFilter,
deleteFilter,
pinFilter,
createFilterFromCurrentState,
hasProjectConditions,
hasFilterApplied,
} = useCustomFilters(
nonProjectFilteredChores,
membersData?.res,
@@ -205,9 +209,12 @@ const MyChores = () => {
return []
}
// Use project-filtered chores for section grouping
// If a custom filter (temp or saved) is active, use customFilteredChores
let choresToGroup = chores
if (selectedProject) {
if (tempFilter || activeFilterId) {
choresToGroup = customFilteredChores
} else if (selectedProject) {
// Otherwise, use project-filtered chores for section grouping
if (selectedProject.id === 'default') {
// Default project: only show tasks without a projectId
choresToGroup = chores.filter(chore => !chore.projectId)
@@ -228,6 +235,9 @@ const MyChores = () => {
return sections
}, [
chores,
customFilteredChores,
tempFilter,
activeFilterId,
selectedChoreSection,
selectedChoreFilter,
selectedProject,
@@ -325,6 +335,32 @@ const MyChores = () => {
}
}, [searchInputFocus])
// Read and apply project from URL parameters
useEffect(() => {
if (!projects.length) return
const projectIdFromUrl = searchParams.get('project')
if (projectIdFromUrl && projectIdFromUrl !== selectedProject?.id) {
const project = projectsWithDefault.find(p => p.id === projectIdFromUrl)
if (project) {
setSelectedProjectWithCache(project)
}
}
}, [
searchParams,
projects,
projectsWithDefault,
selectedProject,
setSelectedProjectWithCache,
searchParams,
projects,
projectsWithDefault,
selectedProject,
setSelectedProjectWithCache,
])
// Read and apply filters from URL parameters
useEffect(() => {
if (!chores.length || !savedFilters.length) return
@@ -332,6 +368,7 @@ const MyChores = () => {
// Check for filterId (camelCase) or filter_id (snake_case) for advanced filters
const filterId =
searchParams.get('filterId') || searchParams.get('filter_id')
const oldFilter = searchParams.get('filter')
// Handle advanced filter parameter
@@ -505,10 +542,7 @@ const MyChores = () => {
}
// Always navigate with params (preserves project param)
const paramString = params.toString()
Navigate(paramString ? `/chores?${paramString}` : '/chores', {
replace: true,
})
Navigate({ pathname: '/chores', search: params.toString() })
}
const searchOptions = useMemo(
@@ -617,7 +651,7 @@ const MyChores = () => {
}
const getFilteredChores = useMemo(() => {
if (activeFilterId) {
if (activeFilterId || tempFilter) {
return customFilteredChores
}
@@ -644,6 +678,7 @@ const MyChores = () => {
return baseChores
}, [
activeFilterId,
tempFilter,
customFilteredChores,
projectFilteredChores,
searchTerm,
@@ -741,16 +776,18 @@ const MyChores = () => {
/>
{/* Project Selector - Hidden when active filter has project conditions */}
{projectsWithDefault.length > 1 && !hasProjectConditions && (
<ProjectSelector
selectedProject={selectedProject?.name || 'Default Project'}
onProjectSelect={project => {
setSelectedProjectWithCache(project)
clearActiveFilter()
}}
showKeyboardShortcuts={showKeyboardShortcuts}
/>
)}
{projectsWithDefault.length > 1 &&
!hasProjectConditions &&
!hasFilterApplied && (
<ProjectSelector
selectedProject={selectedProject?.name || 'Default Project'}
onProjectSelect={project => {
setSelectedProjectWithCache(project)
clearActiveFilter()
}}
showKeyboardShortcuts={showKeyboardShortcuts}
/>
)}
{/* View Mode Toggle Button */}
<IconButton
@@ -1446,7 +1483,14 @@ const MyChores = () => {
)}
</Container>
<Sidepanel chores={chores} performers={membersData?.res || []} />
<Sidepanel
chores={customFilteredChores}
allChores={chores}
performers={membersData?.res || []}
applyTempFilter={applyTempFilter}
clearTempFilter={clearTempFilter}
tempFilter={tempFilter}
/>
{/* Multi-select Help - only show when in multi-select mode */}
{/* <MultiSelectHelp isVisible={isMultiSelectMode} /> */}

View File

@@ -1,7 +1,10 @@
import { Add, Check } from '@mui/icons-material'
import {
Box,
Button,
Chip,
Divider,
ListItemContent,
ListItemDecorator,
Menu,
MenuItem,
Radio,
@@ -9,7 +12,7 @@ import {
} from '@mui/joy'
import IconButton from '@mui/joy/IconButton'
import { useEffect, useRef, useState } from 'react'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
const SortAndGrouping = ({
label,
@@ -26,10 +29,15 @@ const SortAndGrouping = ({
onCreateNewFilter,
}) => {
const [anchorEl, setAnchorEl] = useState(null)
const [selectedIndex, setSelectedIndex] = useState(0)
const [isKeyboardNavigating, setIsKeyboardNavigating] = useState(false)
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
const menuRef = useRef(null)
const buttonRef = useRef(null)
const handleMenuOpen = event => {
setAnchorEl(event.currentTarget)
setIsKeyboardNavigating(false)
}
const handleMenuClose = () => {
@@ -49,37 +57,177 @@ const SortAndGrouping = ({
}
}, [])
// Keyboard shortcut handler
useEffect(() => {
const handleKeyDown = event => {
const isHoldingCmdOrCtrl = event.ctrlKey || event.metaKey
// Cmd/Ctrl + G to open sort menu
if (isHoldingCmdOrCtrl && event.key === 'g') {
event.preventDefault()
if (!anchorEl) {
setAnchorEl(buttonRef.current)
setSelectedIndex(0)
setIsKeyboardNavigating(true)
} else {
handleMenuClose()
}
return
}
// Only handle navigation keys when menu is open
if (!anchorEl) return
const groupByItems = [
{ name: 'Smart', value: 'default' },
{ name: 'Due Date', value: 'due_date' },
{ name: 'Priority', value: 'priority' },
{ name: 'Labels', value: 'labels' },
]
const filterItems = ['anyone', 'assigned_to_me', 'assigned_to_others']
// Total selectable items: 4 (group by) + 3 (filters) + 1 (create custom filter) = 8
const totalItems = groupByItems.length + filterItems.length + 1
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
setIsKeyboardNavigating(true)
setSelectedIndex(prev => (prev < totalItems - 1 ? prev + 1 : prev))
break
case 'ArrowUp':
event.preventDefault()
setIsKeyboardNavigating(true)
setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev))
break
case 'Enter':
event.preventDefault()
if (selectedIndex < groupByItems.length) {
// Group by items (0-3)
const item = groupByItems[selectedIndex]
onItemSelect(item)
setSelectedItem?.(item.name)
handleMenuClose()
} else if (selectedIndex < groupByItems.length + filterItems.length) {
// Filter items (4-6)
const filterIndex = selectedIndex - groupByItems.length
setFilter(filterItems[filterIndex])
handleMenuClose()
} else {
// Create custom filter (7)
onCreateNewFilter()
handleMenuClose()
}
break
case 'Escape':
event.preventDefault()
handleMenuClose()
break
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [
anchorEl,
selectedIndex,
onItemSelect,
setSelectedItem,
setFilter,
onCreateNewFilter,
])
// Reset selected index when menu opens
useEffect(() => {
if (anchorEl) {
setSelectedIndex(0)
}
}, [anchorEl])
// Keyboard shortcut hint handler
useEffect(() => {
const handleKeyDown = event => {
if (event.ctrlKey || event.metaKey) {
setShowKeyboardShortcuts(true)
}
}
const handleKeyUp = event => {
if (!event.ctrlKey && !event.metaKey) {
setShowKeyboardShortcuts(false)
}
}
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('keyup', handleKeyUp)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('keyup', handleKeyUp)
}
}, [])
return (
<>
{!label && (
<IconButton
onClick={handleMenuOpen}
variant='outlined'
color={isActive ? 'primary' : 'neutral'}
size='sm'
sx={{
height: 24,
borderRadius: 24,
}}
>
{icon}
{label ? label : null}
</IconButton>
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
<IconButton
ref={buttonRef}
onClick={handleMenuOpen}
variant='outlined'
color={isActive ? 'primary' : 'neutral'}
size='sm'
sx={{
height: 24,
borderRadius: 24,
}}
title='Sort and Group (Ctrl+G)'
>
{icon}
{label ? label : null}
</IconButton>
<KeyboardShortcutHint
shortcut='G'
show={showKeyboardShortcuts}
sx={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1000,
}}
/>
</Box>
)}
{label && (
<Button
onClick={handleMenuOpen}
variant='outlined'
color={isActive ? 'primary' : 'neutral'}
size='sm'
startDecorator={icon}
sx={{
height: 24,
borderRadius: 24,
}}
>
{label}
</Button>
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
<Button
ref={buttonRef}
onClick={handleMenuOpen}
variant='outlined'
color={isActive ? 'primary' : 'neutral'}
size='sm'
startDecorator={icon}
sx={{
height: 24,
borderRadius: 24,
}}
title='Sort and Group (Ctrl+G)'
>
{label}
</Button>
<KeyboardShortcutHint
shortcut='G'
show={showKeyboardShortcuts}
sx={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1000,
}}
/>
</Box>
)}
<Menu
@@ -88,24 +236,39 @@ const SortAndGrouping = ({
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
placement='bottom-start'
sx={{
'& .MuiMenuItem-root': {
padding: '8px 16px', // Consistent padding for menu items
},
minWidth: 280,
p: 1,
'--List-gap': '4px',
boxShadow: 'var(--joy-shadow-lg)',
border: '1px solid var(--joy-palette-divider)',
borderRadius: 'var(--joy-radius-md)',
}}
>
<MenuItem key={`${k}-title`} disabled>
<Typography level='body-sm' fontWeight='lg'>
Group By
</Typography>
<MenuItem
disabled
sx={{
borderRadius: 'var(--joy-radius-sm)',
cursor: 'default',
opacity: 1,
}}
>
<ListItemContent>
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
{title || 'Group By'}
</Typography>
</ListItemContent>
</MenuItem>
<Divider sx={{ my: 1 }} />
{[
{ name: 'Smart', value: 'default' },
{ name: 'Due Date', value: 'due_date' },
{ name: 'Priority', value: 'priority' },
{ name: 'Labels', value: 'labels' },
].map(item => (
].map((item, index) => (
<MenuItem
key={`${k}-${item?.value}`}
onClick={() => {
@@ -113,41 +276,87 @@ const SortAndGrouping = ({
setSelectedItem?.(item.name)
handleMenuClose()
}}
onMouseEnter={() => setIsKeyboardNavigating(false)}
sx={{
borderRadius: 'var(--joy-radius-sm)',
backgroundColor:
selectedItem === item.name
? 'var(--joy-palette-primary-softBg)'
: selectedIndex === index && anchorEl && isKeyboardNavigating
? 'var(--joy-palette-neutral-softHoverBg)'
: 'transparent',
'&:hover': {
backgroundColor:
selectedItem === item.name
? 'var(--joy-palette-primary-softBg)'
: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
{useChips ? (
<Chip
size='sm'
<ListItemContent>
<Box
sx={{
backgroundColor: item.color ? item.color : null,
color: getTextColorFromBackgroundColor(item.color),
fontWeight: 'md',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
{item.name}
</Chip>
) : (
<>
{item?.icon}
<Typography level='body-sm' sx={{ ml: 1 }}>
<Typography
level='body-sm'
sx={{
fontWeight: selectedItem === item.name ? 600 : 400,
color:
selectedItem === item.name
? 'var(--joy-palette-primary-600)'
: 'var(--joy-palette-text-primary)',
}}
>
{item.name}
</Typography>
</>
)}
{selectedItem === item.name && (
<Check
sx={{
fontSize: '16px',
color: 'var(--joy-palette-primary-500)',
}}
/>
)}
</Box>
</ListItemContent>
</MenuItem>
))}
<Divider />
<Divider sx={{ my: 1 }} />
<MenuItem key={`${k}-quick-filter`} disabled>
<Typography level='body-sm' fontWeight='lg'>
Quick Filters
</Typography>
<MenuItem
disabled
sx={{
borderRadius: 'var(--joy-radius-sm)',
cursor: 'default',
opacity: 1,
}}
>
<ListItemContent>
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
Quick Filters
</Typography>
</ListItemContent>
</MenuItem>
<MenuItem key={`${k}-assignee-title`} disabled>
<Typography level='body-xs' fontWeight='md'>
Assigned to :
</Typography>
<MenuItem
disabled
sx={{
borderRadius: 'var(--joy-radius-sm)',
cursor: 'default',
opacity: 1,
paddingY: '4px',
}}
>
<ListItemContent>
<Typography level='body-xs' sx={{ fontWeight: 600 }}>
Assigned to:
</Typography>
</ListItemContent>
</MenuItem>
<MenuItem
@@ -156,9 +365,48 @@ const SortAndGrouping = ({
setFilter('anyone')
handleMenuClose()
}}
onMouseEnter={() => setIsKeyboardNavigating(false)}
sx={{
borderRadius: 'var(--joy-radius-sm)',
backgroundColor:
selectedFilter === 'anyone'
? 'var(--joy-palette-primary-softBg)'
: selectedIndex === 4 && anchorEl && isKeyboardNavigating
? 'var(--joy-palette-neutral-softHoverBg)'
: 'transparent',
'&:hover': {
backgroundColor:
selectedFilter === 'anyone'
? 'var(--joy-palette-primary-softBg)'
: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<Radio checked={selectedFilter === 'anyone'} variant='outlined' />
<Typography level='body-sm'>Anyone</Typography>
<ListItemDecorator>
<Radio checked={selectedFilter === 'anyone'} variant='outlined' />
</ListItemDecorator>
<ListItemContent>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Typography
level='body-sm'
sx={{
fontWeight: selectedFilter === 'anyone' ? 600 : 400,
color:
selectedFilter === 'anyone'
? 'var(--joy-palette-primary-600)'
: 'var(--joy-palette-text-primary)',
}}
>
Anyone
</Typography>
</Box>
</ListItemContent>
</MenuItem>
<MenuItem
@@ -167,27 +415,52 @@ const SortAndGrouping = ({
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-assignable-to-me`}
onClick={() => {
setFilter('assignable_to_me')
handleMenuClose()
onMouseEnter={() => setIsKeyboardNavigating(false)}
sx={{
borderRadius: 'var(--joy-radius-sm)',
backgroundColor:
selectedFilter === 'assigned_to_me'
? 'var(--joy-palette-primary-softBg)'
: selectedIndex === 5 && anchorEl && isKeyboardNavigating
? 'var(--joy-palette-neutral-softHoverBg)'
: 'transparent',
'&:hover': {
backgroundColor:
selectedFilter === 'assigned_to_me'
? 'var(--joy-palette-primary-softBg)'
: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<Radio
checked={selectedFilter === 'assignable_to_me'}
variant='outlined'
/>
<Typography level='body-sm'>Available for me</Typography>
</MenuItem> */}
<ListItemDecorator>
<Radio
checked={selectedFilter === 'assigned_to_me'}
variant='outlined'
/>
</ListItemDecorator>
<ListItemContent>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Typography
level='body-sm'
sx={{
fontWeight: selectedFilter === 'assigned_to_me' ? 600 : 400,
color:
selectedFilter === 'assigned_to_me'
? 'var(--joy-palette-primary-600)'
: 'var(--joy-palette-text-primary)',
}}
>
Assigned to me
</Typography>
</Box>
</ListItemContent>
</MenuItem>
<MenuItem
key={`${k}-assignee-assigned-to-others`}
@@ -195,44 +468,94 @@ const SortAndGrouping = ({
setFilter('assigned_to_others')
handleMenuClose()
}}
onMouseEnter={() => setIsKeyboardNavigating(false)}
sx={{
borderRadius: 'var(--joy-radius-sm)',
backgroundColor:
selectedFilter === 'assigned_to_others'
? 'var(--joy-palette-primary-softBg)'
: selectedIndex === 6 && anchorEl && isKeyboardNavigating
? 'var(--joy-palette-neutral-softHoverBg)'
: 'transparent',
'&:hover': {
backgroundColor:
selectedFilter === 'assigned_to_others'
? 'var(--joy-palette-primary-softBg)'
: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<Radio
checked={selectedFilter === 'assigned_to_others'}
variant='outlined'
/>
<Typography level='body-sm'>Assigned to others</Typography>
<ListItemDecorator>
<Radio
checked={selectedFilter === 'assigned_to_others'}
variant='outlined'
/>
</ListItemDecorator>
<ListItemContent>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Typography
level='body-sm'
sx={{
fontWeight:
selectedFilter === 'assigned_to_others' ? 600 : 400,
color:
selectedFilter === 'assigned_to_others'
? 'var(--joy-palette-primary-600)'
: 'var(--joy-palette-text-primary)',
}}
>
Assigned to others
</Typography>
</Box>
</ListItemContent>
</MenuItem>
<Divider />
<Divider sx={{ my: 1 }} />
<MenuItem
key={`${k}-custom-filter`}
onClick={() => {
onCreateNewFilter()
handleMenuClose()
// TODO: Open advanced filter builder
}}
onMouseEnter={() => setIsKeyboardNavigating(false)}
sx={{
borderRadius: 'var(--joy-radius-sm)',
backgroundColor:
selectedIndex === 7 && anchorEl && isKeyboardNavigating
? 'var(--joy-palette-success-softHoverBg)'
: 'transparent',
'&:hover': {
backgroundColor: 'var(--joy-palette-success-softHoverBg)',
},
}}
>
<Typography level='body-sm' fontWeight='md' color='primary'>
Create Custom Filter...
</Typography>
<ListItemDecorator sx={{ color: 'var(--joy-palette-success-500)' }}>
<Add />
</ListItemDecorator>
<ListItemContent>
<Typography
level='body-sm'
sx={{
fontWeight: 500,
}}
>
Create Custom Filter
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Build advanced filter rules
</Typography>
</ListItemContent>
</MenuItem>
{/*
// i need this but i think it have a bad UX and confusing so commenting it for now
<MenuItem
key={`${k}-assignee-created-by-me`}
onClick={() => {
setFilter('created_by_me')
handleMenuClose()
}}
>
<Radio
checked={selectedFilter === 'created_by_me'}
variant='outlined'
/>
<Typography level='body-sm'>Created by me</Typography>
</MenuItem> */}
</Menu>
</>
)

View File

@@ -1,4 +1,5 @@
import {
Check,
Delete,
Edit,
Settings,
@@ -129,33 +130,40 @@ const CustomFilterChips = ({
}}
/>
)}
<Chip
size='sm'
variant='solid'
sx={{
...(hasCustomColor
? {
bgcolor:
textColor === '#FFFFFF'
? '#00000040'
: '#FFFFFF40',
color: textColor,
border: `1px solid ${textColor}30`,
}
: {}),
}}
color={
hasCustomColor
? undefined
: hasWarning
? 'warning'
: isActive
? 'primary'
{isActive ? (
<Check
sx={{
fontSize: '1rem',
color: hasCustomColor ? textColor : 'primary.500',
}}
/>
) : (
<Chip
size='sm'
variant='solid'
sx={{
...(hasCustomColor
? {
bgcolor:
textColor === '#FFFFFF'
? '#00000040'
: '#FFFFFF40',
color: textColor,
border: `1px solid ${textColor}30`,
}
: {}),
}}
color={
hasCustomColor
? undefined
: hasWarning
? 'warning'
: 'neutral'
}
>
{filter.count}
</Chip>
}
>
{filter.count}
</Chip>
)}
</Box>
}
endDecorator={
@@ -206,15 +214,14 @@ const CustomFilterChips = ({
<Chip
variant='outlined'
size='lg'
startDecorator={<Settings />}
sx={{ cursor: 'pointer' }}
sx={{ cursor: 'pointer', minWidth: 'auto', px: 0.8 }}
onClick={e => {
e.preventDefault()
e.stopPropagation()
navigate('/filters')
}}
>
Manage Filters
<Settings />
</Chip>
<Menu

View File

@@ -19,6 +19,7 @@ export const useCustomFilters = (chores, membersData, labels, projects) => {
const { data: userProfile } = useUserProfile()
const [savedFilters, setSavedFilters] = useState([])
const [activeFilterId, setActiveFilterId] = useState(null)
const [tempFilter, setTempFilter] = useState(null)
const loadFilters = useCallback(() => {
const filters = getSavedFilters()
@@ -74,12 +75,21 @@ export const useCustomFilters = (chores, membersData, labels, projects) => {
return activeFilter.conditions.some(c => c.type === 'project')
}, [activeFilter])
// check if has any filter applied:
const hasFilterApplied = useMemo(() => {
return activeFilterId !== null || tempFilter !== null
}, [activeFilterId, tempFilter])
const filteredChores = useMemo(() => {
// Temporary filter takes precedence over saved filters
if (tempFilter) {
return applyFilter(chores, tempFilter, context)
}
if (!activeFilter || !activeFilter.isValid) {
return chores
}
return applyFilter(chores, activeFilter, context)
}, [chores, activeFilter, context])
}, [chores, activeFilter, tempFilter, context])
const applyCustomFilter = useCallback(
filterId => {
@@ -92,6 +102,16 @@ export const useCustomFilters = (chores, membersData, labels, projects) => {
const clearActiveFilter = useCallback(() => {
setActiveFilterId(null)
setTempFilter(null)
}, [])
const applyTempFilter = useCallback(filter => {
setTempFilter(filter)
setActiveFilterId(null)
}, [])
const clearTempFilter = useCallback(() => {
setTempFilter(null)
}, [])
const saveFilter = useCallback(
@@ -228,14 +248,18 @@ export const useCustomFilters = (chores, membersData, labels, projects) => {
savedFilters: filtersWithCounts,
activeFilter,
activeFilterId,
tempFilter,
filteredChores,
applyCustomFilter,
clearActiveFilter,
applyTempFilter,
clearTempFilter,
saveFilter,
updateFilter,
deleteFilter,
pinFilter,
createFilterFromCurrentState,
hasProjectConditions,
hasFilterApplied,
}
}

View File

@@ -13,7 +13,14 @@ import {
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Add, FilterAlt, Star, StarBorder, Task } from '@mui/icons-material'
import {
Add,
FilterAlt,
MoreVert,
Star,
StarBorder,
Task,
} from '@mui/icons-material'
import { useChores } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import {
@@ -328,7 +335,7 @@ const FilterCard = ({
return
}
// Navigate to MyChores with filter applied via URL param
navigate(`/chores?filterId=${encodeURIComponent(filter.id)}`)
navigate(`/filters/${encodeURIComponent(filter.id)}`)
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
@@ -362,6 +369,11 @@ const FilterCard = ({
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={e => {
e.stopPropagation()
const clampedDelta = isSwipeRevealed ? 0 : -maxSwipeDistance
setSwipeTranslateX(clampedDelta)
}}
>
{/* Drag indicator dots */}
<Box
@@ -371,17 +383,7 @@ const FilterCard = ({
gap: 0.25,
}}
>
{[...Array(3)].map((_, i) => (
<Box
key={i}
sx={{
width: 3,
height: 3,
borderRadius: '50%',
bgcolor: 'text.tertiary',
}}
/>
))}
<MoreVert sx={{ fontSize: 20 }} />
</Box>
</Box>

View File

@@ -173,6 +173,12 @@ const AdvancedFilterBuilder = ({
}
placeholder='Select assignees'
sx={{ width: '100%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((selectedElement, idx) => {
@@ -210,6 +216,12 @@ const AdvancedFilterBuilder = ({
}
placeholder='Select creators'
sx={{ width: '100%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((selectedElement, idx) => {
@@ -255,13 +267,23 @@ const AdvancedFilterBuilder = ({
}
placeholder='Select priorities'
sx={{ width: '100%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
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>
))}
{selected.map((selectedElement, idx) => {
const value = selectedElement.value
const priority = Priorities.find(p => p.value === value)
return (
<Chip key={`priority-${value}-${idx}`} size='sm'>
{priority?.name || `Priority ${value}`}
</Chip>
)
})}
</Box>
)}
>
@@ -286,6 +308,12 @@ const AdvancedFilterBuilder = ({
}
placeholder='Select labels'
sx={{ width: '100%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((selectedElement, idx) => {
@@ -319,6 +347,12 @@ const AdvancedFilterBuilder = ({
}
placeholder='Select projects'
sx={{ width: '100%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((event, idx) => {
@@ -363,6 +397,12 @@ const AdvancedFilterBuilder = ({
updateCondition(index, 'value', [newValue])
}
sx={{ width: '100%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
>
<Option value={0}>Active</Option>
<Option value={1}>Started</Option>
@@ -379,6 +419,12 @@ const AdvancedFilterBuilder = ({
updateCondition(index, 'operator', newValue)
}
sx={{ width: '100%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
>
<Option value='isOverdue'>Is Overdue</Option>
<Option value='isDueToday'>Is Due Today</Option>
@@ -467,6 +513,12 @@ const AdvancedFilterBuilder = ({
<Select
value={filterColor}
onChange={(_, value) => value && setFilterColor(value)}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
renderValue={selected => (
<Typography
startDecorator={
@@ -518,8 +570,10 @@ const AdvancedFilterBuilder = ({
sx={{
gap: 1,
overflowY: 'auto',
overflowX: 'hidden',
maxHeight: { xs: '40vh', sm: '50vh' },
pr: 0.5,
position: 'relative',
}}
>
{conditions.map((condition, index) => (
@@ -567,6 +621,12 @@ const AdvancedFilterBuilder = ({
updateCondition(index, 'type', newValue)
}
sx={{ width: '100%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
>
<Option value='assignee'>Assignee</Option>
<Option value='createdBy'>Created By</Option>
@@ -625,9 +685,11 @@ const AdvancedFilterBuilder = ({
sx={{
maxHeight: 150,
overflowY: 'auto',
overflowX: 'hidden',
bgcolor: 'background.level1',
p: 1,
borderRadius: 'sm',
position: 'relative',
}}
>
{previewCount === 0 ? (

View File

@@ -24,9 +24,9 @@ import LABEL_COLORS, {
import { DeleteProject } from '../../utils/Fetcher'
import { getIconComponent } from '../../utils/ProjectIcons'
import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
import { useProjectFilter } from '../Chores/hooks/useProjectFilter'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import { useProjects } from './ProjectQueries'
const ProjectCard = ({
project,
onEditClick,
@@ -36,7 +36,9 @@ const ProjectCard = ({
taskCounts = {},
}) => {
const navigate = useNavigate()
const { data: projects = [], isLoading: projectsLoading } = useProjects()
// Helper function to get color name from hex value
const { setSelectedProjectWithCache } = useProjectFilter(projects)
const getColorName = hexValue => {
const colorObj = LABEL_COLORS.find(
color => color.value.toLowerCase() === hexValue.toLowerCase(),
@@ -311,6 +313,7 @@ const ProjectCard = ({
// For default project, use 'default', for others use project ID
const projectIdentifier =
project.id === 'default' ? 'default' : project.id
setSelectedProjectWithCache(project)
navigate(`/chores?project=${encodeURIComponent(projectIdentifier)}`)
}}
onTouchStart={isEditable ? handleTouchStart : undefined}