add SortAndFilterMenu component and integrate it into various views for enhanced sorting and filtering capabilities
This commit is contained in:
@@ -9,7 +9,9 @@
|
||||
"placeholder": "Search labels",
|
||||
"noResultsTitle": "No labels match",
|
||||
"noResultsDescription": "No label matches \"{{searchTerm}}\".",
|
||||
"clear": "Clear search"
|
||||
"noFilterResultsDescription": "No label matches the current filter.",
|
||||
"clear": "Clear search",
|
||||
"showAll": "Show all labels"
|
||||
},
|
||||
"detail": {
|
||||
"taskCount_one": "{{count}} task",
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
"message": "Are you sure you want to delete \"{{name}}\"? This will remove the project but keep all tasks (they'll move to the Default Project)."
|
||||
},
|
||||
"loadError": "Failed to load projects. Please try again.",
|
||||
"search": {
|
||||
"placeholder": "Search projects",
|
||||
"noResultsTitle": "No projects match",
|
||||
"noResultsDescription": "No project matches \"{{searchTerm}}\".",
|
||||
"noFilterResultsDescription": "No project matches the current filter.",
|
||||
"clear": "Clear search",
|
||||
"showAll": "Show all projects"
|
||||
},
|
||||
"blurb": "Organize your tasks into projects. Create custom workspaces to keep your tasks organized and easily accessible.",
|
||||
"defaultDescription": "All tasks without a specific project",
|
||||
"selector": {
|
||||
|
||||
256
src/components/common/SortAndFilterMenu.jsx
Normal file
256
src/components/common/SortAndFilterMenu.jsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { ArrowDownward, ArrowUpward, Check, Sort } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Divider,
|
||||
IconButton,
|
||||
ListItemContent,
|
||||
ListItemDecorator,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Radio,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Compact sort + filter menu, meant to sit next to a search input.
|
||||
*
|
||||
* Props:
|
||||
* sortOptions - [{ name, value }] shown under the sort header
|
||||
* selectedSort - currently selected sort value
|
||||
* onSortChange - (value) => void
|
||||
* sortDirection - 'asc' | 'desc'
|
||||
* onSortDirectionChange - (direction) => void
|
||||
* filterTitle - optional header for the filter section
|
||||
* filterOptions - optional [{ name, value }] rendered as radios
|
||||
* selectedFilter - currently selected filter value
|
||||
* onFilterChange - (value) => void
|
||||
* isActive - highlights the trigger button when a non-default choice is on
|
||||
*/
|
||||
const SortAndFilterMenu = ({
|
||||
filterOptions,
|
||||
filterTitle,
|
||||
icon = <Sort />,
|
||||
isActive,
|
||||
onFilterChange,
|
||||
onSortChange,
|
||||
onSortDirectionChange,
|
||||
selectedFilter,
|
||||
selectedSort,
|
||||
sortDirection = 'asc',
|
||||
sortOptions = [],
|
||||
title = 'Sort by',
|
||||
}) => {
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
const menuRef = useRef(null)
|
||||
const buttonRef = useRef(null)
|
||||
|
||||
const handleMenuClose = () => setAnchorEl(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleMenuOutsideClick = event => {
|
||||
if (
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(event.target) &&
|
||||
!buttonRef.current?.contains(event.target)
|
||||
) {
|
||||
handleMenuClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleMenuOutsideClick)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleMenuOutsideClick)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const SectionHeader = ({ children }) => (
|
||||
<MenuItem
|
||||
disabled
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
cursor: 'default',
|
||||
opacity: 1,
|
||||
}}
|
||||
>
|
||||
<ListItemContent>
|
||||
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
||||
{children}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
ref={buttonRef}
|
||||
onClick={event => setAnchorEl(anchorEl ? null : event.currentTarget)}
|
||||
variant='outlined'
|
||||
color={isActive ? 'primary' : 'neutral'}
|
||||
size='sm'
|
||||
sx={{ height: 32, width: 32, borderRadius: '50%', flexShrink: 0 }}
|
||||
aria-label='Sort and filter options'
|
||||
title='Sort & Filter'
|
||||
>
|
||||
{icon}
|
||||
</IconButton>
|
||||
|
||||
<Menu
|
||||
ref={menuRef}
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
placement='bottom-end'
|
||||
sx={{
|
||||
minWidth: 240,
|
||||
p: 1,
|
||||
'--List-gap': '4px',
|
||||
boxShadow: 'var(--joy-shadow-lg)',
|
||||
border: '1px solid var(--joy-palette-divider)',
|
||||
borderRadius: 'var(--joy-radius-md)',
|
||||
zIndex: 1300,
|
||||
}}
|
||||
>
|
||||
<SectionHeader>{title}</SectionHeader>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
{sortOptions.map(option => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
onSortChange(option.value)
|
||||
handleMenuClose()
|
||||
}}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
backgroundColor:
|
||||
selectedSort === option.value
|
||||
? 'var(--joy-palette-primary-softBg)'
|
||||
: 'transparent',
|
||||
'&:hover': {
|
||||
backgroundColor:
|
||||
selectedSort === option.value
|
||||
? 'var(--joy-palette-primary-softBg)'
|
||||
: 'var(--joy-palette-neutral-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemContent>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
fontWeight: selectedSort === option.value ? 600 : 400,
|
||||
color:
|
||||
selectedSort === option.value
|
||||
? 'var(--joy-palette-primary-600)'
|
||||
: 'var(--joy-palette-text-primary)',
|
||||
}}
|
||||
>
|
||||
{option.name}
|
||||
</Typography>
|
||||
{selectedSort === option.value && (
|
||||
<Check
|
||||
sx={{
|
||||
fontSize: '16px',
|
||||
color: 'var(--joy-palette-primary-500)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
))}
|
||||
|
||||
{onSortDirectionChange && (
|
||||
<>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<MenuItem
|
||||
onClick={() =>
|
||||
onSortDirectionChange(sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
{sortDirection === 'asc' ? (
|
||||
<ArrowUpward sx={{ fontSize: '18px' }} />
|
||||
) : (
|
||||
<ArrowDownward sx={{ fontSize: '18px' }} />
|
||||
)}
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm'>
|
||||
{sortDirection === 'asc' ? 'Ascending' : 'Descending'}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{filterOptions?.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<SectionHeader>{filterTitle || 'Filter'}</SectionHeader>
|
||||
{filterOptions.map(option => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
onFilterChange(option.value)
|
||||
handleMenuClose()
|
||||
}}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
backgroundColor:
|
||||
selectedFilter === option.value
|
||||
? 'var(--joy-palette-primary-softBg)'
|
||||
: 'transparent',
|
||||
'&:hover': {
|
||||
backgroundColor:
|
||||
selectedFilter === option.value
|
||||
? 'var(--joy-palette-primary-softBg)'
|
||||
: 'var(--joy-palette-neutral-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
<Radio
|
||||
checked={selectedFilter === option.value}
|
||||
variant='outlined'
|
||||
/>
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
fontWeight: selectedFilter === option.value ? 600 : 400,
|
||||
color:
|
||||
selectedFilter === option.value
|
||||
? 'var(--joy-palette-primary-600)'
|
||||
: 'var(--joy-palette-text-primary)',
|
||||
}}
|
||||
>
|
||||
{option.name}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SortAndFilterMenu
|
||||
@@ -32,6 +32,7 @@ import { useNavigate } from 'react-router-dom'
|
||||
import EmptyState from '../../components/common/EmptyState'
|
||||
import FilterBar from '../../components/common/FilterBar'
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import SortAndFilterMenu from '../../components/common/SortAndFilterMenu'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useFilter } from '../../hooks/useFilter'
|
||||
import { useUnArchiveChore } from '../../queries/ChoreQueries'
|
||||
@@ -203,11 +204,46 @@ const ArchivedTasks = () => {
|
||||
const {
|
||||
activeFilters,
|
||||
clearAll,
|
||||
filteredData: finalChores,
|
||||
filteredData: filteredByBar,
|
||||
hasActiveFilters,
|
||||
setFilter,
|
||||
} = useFilter(filteredChores, filterDefs)
|
||||
|
||||
const [sortBy, setSortBy] = useState(
|
||||
() => localStorage.getItem('archivedChoresSortBy') || 'archivedAt',
|
||||
)
|
||||
const [sortDirection, setSortDirection] = useState(
|
||||
() => localStorage.getItem('archivedChoresSortDirection') || 'desc',
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('archivedChoresSortBy', sortBy)
|
||||
localStorage.setItem('archivedChoresSortDirection', sortDirection)
|
||||
}, [sortBy, sortDirection])
|
||||
|
||||
const finalChores = useMemo(() => {
|
||||
const direction = sortDirection === 'desc' ? -1 : 1
|
||||
return [...filteredByBar].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
return direction * (a.name || '').localeCompare(b.name || '')
|
||||
case 'priority':
|
||||
return direction * ((a.priority ?? 0) - (b.priority ?? 0))
|
||||
case 'dueDate': {
|
||||
const aDue = new Date(a.nextDueDate || 0).getTime()
|
||||
const bDue = new Date(b.nextDueDate || 0).getTime()
|
||||
return direction * (aDue - bDue)
|
||||
}
|
||||
case 'archivedAt':
|
||||
default: {
|
||||
const aDate = new Date(a.updatedAt || 0).getTime()
|
||||
const bDate = new Date(b.updatedAt || 0).getTime()
|
||||
return direction * (aDate - bDate)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [filteredByBar, sortBy, sortDirection])
|
||||
|
||||
useEffect(() => {
|
||||
const loadArchivedChores = async () => {
|
||||
if (!membersLoading && userProfile) {
|
||||
@@ -734,6 +770,21 @@ const ArchivedTasks = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Sort Menu */}
|
||||
<SortAndFilterMenu
|
||||
sortOptions={[
|
||||
{ name: 'Archived date', value: 'archivedAt' },
|
||||
{ name: 'Name', value: 'name' },
|
||||
{ name: 'Priority', value: 'priority' },
|
||||
{ name: 'Due date', value: 'dueDate' },
|
||||
]}
|
||||
selectedSort={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
sortDirection={sortDirection}
|
||||
onSortDirectionChange={setSortDirection}
|
||||
isActive={sortBy !== 'archivedAt' || sortDirection !== 'desc'}
|
||||
/>
|
||||
|
||||
{/* View Mode Toggle Button */}
|
||||
<IconButton
|
||||
variant='outlined'
|
||||
|
||||
@@ -9,8 +9,11 @@ import {
|
||||
} from '@meauxt/react-swipeable-list'
|
||||
import {
|
||||
Add,
|
||||
Close,
|
||||
FilterAlt,
|
||||
MoreVert,
|
||||
Search,
|
||||
SearchOff,
|
||||
Star,
|
||||
StarBorder,
|
||||
Task,
|
||||
@@ -24,13 +27,16 @@ import {
|
||||
CircularProgress,
|
||||
Container,
|
||||
IconButton,
|
||||
Input,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Fuse from 'fuse.js'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import EmptyState from '../../components/common/EmptyState'
|
||||
import SortAndFilterMenu from '../../components/common/SortAndFilterMenu'
|
||||
import { useChores } from '../../queries/ChoreQueries'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import { getFilterCount, getFilterOverdueCount } from '../../utils/FilterEngine'
|
||||
@@ -266,6 +272,21 @@ const FilterView = () => {
|
||||
const [editingFilter, setEditingFilter] = useState(null)
|
||||
const [confirmationModel, setConfirmationModel] = useState({})
|
||||
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [sortBy, setSortBy] = useState(
|
||||
() => localStorage.getItem('filtersSortBy') || 'smart',
|
||||
)
|
||||
const [sortDirection, setSortDirection] = useState(
|
||||
() => localStorage.getItem('filtersSortDirection') || 'asc',
|
||||
)
|
||||
const [pinnedFilter, setPinnedFilter] = useState('all')
|
||||
const searchInputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('filtersSortBy', sortBy)
|
||||
localStorage.setItem('filtersSortDirection', sortDirection)
|
||||
}, [sortBy, sortDirection])
|
||||
|
||||
// Sort filters: pinned first, then by usage count, then by last used
|
||||
const savedFilters = useMemo(() => {
|
||||
return [...filtersData].sort((a, b) => {
|
||||
@@ -282,6 +303,71 @@ const FilterView = () => {
|
||||
})
|
||||
}, [filtersData])
|
||||
|
||||
const visibleFilters = useMemo(() => {
|
||||
if (pinnedFilter === 'pinned') {
|
||||
return savedFilters.filter(filter => filter.isPinned)
|
||||
}
|
||||
if (pinnedFilter === 'unpinned') {
|
||||
return savedFilters.filter(filter => !filter.isPinned)
|
||||
}
|
||||
return savedFilters
|
||||
}, [pinnedFilter, savedFilters])
|
||||
|
||||
const fuse = useMemo(
|
||||
() =>
|
||||
new Fuse(visibleFilters, {
|
||||
keys: ['name', 'description'],
|
||||
includeScore: true,
|
||||
isCaseSensitive: false,
|
||||
findAllMatches: true,
|
||||
}),
|
||||
[visibleFilters],
|
||||
)
|
||||
|
||||
const filteredFilters = useMemo(() => {
|
||||
const matched = searchTerm
|
||||
? fuse.search(searchTerm).map(result => result.item)
|
||||
: visibleFilters
|
||||
|
||||
const direction = sortDirection === 'desc' ? -1 : 1
|
||||
|
||||
// "Smart" keeps the pinned-then-usage order the list already arrives in.
|
||||
if (sortBy === 'smart') {
|
||||
return direction === -1 ? [...matched].reverse() : matched
|
||||
}
|
||||
|
||||
return [...matched].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
return direction * (a.name || '').localeCompare(b.name || '')
|
||||
case 'usage':
|
||||
return direction * ((a.usageCount || 0) - (b.usageCount || 0))
|
||||
case 'lastUsed': {
|
||||
const aUsed = new Date(a.lastUsedAt || 0).getTime()
|
||||
const bUsed = new Date(b.lastUsedAt || 0).getTime()
|
||||
return direction * (aUsed - bUsed)
|
||||
}
|
||||
case 'created': {
|
||||
const aCreated = new Date(a.createdAt || 0).getTime()
|
||||
const bCreated = new Date(b.createdAt || 0).getTime()
|
||||
return direction * (aCreated - bCreated)
|
||||
}
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
}, [fuse, searchTerm, visibleFilters, sortBy, sortDirection])
|
||||
|
||||
const handleSearchChange = e => {
|
||||
setSearchTerm(e.target.value)
|
||||
setShowMoreInfoId(null)
|
||||
}
|
||||
|
||||
const handleSearchClose = () => {
|
||||
setSearchTerm('')
|
||||
searchInputRef.current?.blur()
|
||||
}
|
||||
|
||||
// Calculate task counts for each filter
|
||||
useEffect(() => {
|
||||
if (chores && chores.res && savedFilters.length > 0) {
|
||||
@@ -428,6 +514,68 @@ const FilterView = () => {
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{savedFilters.length > 0 && (
|
||||
<Box
|
||||
sx={{ px: 2, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}
|
||||
>
|
||||
<Input
|
||||
slotProps={{ input: { ref: searchInputRef } }}
|
||||
placeholder='Search filters'
|
||||
value={searchTerm}
|
||||
fullWidth
|
||||
sx={{
|
||||
borderRadius: 24,
|
||||
height: 24,
|
||||
borderColor: 'text.disabled',
|
||||
padding: 1,
|
||||
}}
|
||||
onChange={handleSearchChange}
|
||||
startDecorator={<Search />}
|
||||
endDecorator={
|
||||
searchTerm && (
|
||||
<IconButton
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={handleSearchClose}
|
||||
sx={{ borderRadius: '50%' }}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<SortAndFilterMenu
|
||||
sortOptions={[
|
||||
{ name: 'Smart', value: 'smart' },
|
||||
{ name: 'Name', value: 'name' },
|
||||
{ name: 'Usage count', value: 'usage' },
|
||||
{ name: 'Last used', value: 'lastUsed' },
|
||||
{ name: 'Created date', value: 'created' },
|
||||
]}
|
||||
selectedSort={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
sortDirection={sortDirection}
|
||||
onSortDirectionChange={setSortDirection}
|
||||
filterTitle='Show'
|
||||
filterOptions={[
|
||||
{ name: 'All filters', value: 'all' },
|
||||
{ name: 'Pinned', value: 'pinned' },
|
||||
{ name: 'Not pinned', value: 'unpinned' },
|
||||
]}
|
||||
selectedFilter={pinnedFilter}
|
||||
onFilterChange={value => {
|
||||
setPinnedFilter(value)
|
||||
setShowMoreInfoId(null)
|
||||
}}
|
||||
isActive={
|
||||
pinnedFilter !== 'all' ||
|
||||
sortBy !== 'smart' ||
|
||||
sortDirection !== 'asc'
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
@@ -445,9 +593,28 @@ const FilterView = () => {
|
||||
onClick: handleAddFilter,
|
||||
}}
|
||||
/>
|
||||
) : filteredFilters.length === 0 ? (
|
||||
<EmptyState
|
||||
variant='no-results'
|
||||
fullHeight
|
||||
icon={<SearchOff />}
|
||||
title='No filters match'
|
||||
description={
|
||||
searchTerm
|
||||
? `No saved filter matches "${searchTerm}".`
|
||||
: 'No saved filter matches the current filter.'
|
||||
}
|
||||
primaryAction={{
|
||||
label: searchTerm ? 'Clear search' : 'Show all filters',
|
||||
onClick: () => {
|
||||
handleSearchClose()
|
||||
setPinnedFilter('all')
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||
{savedFilters.map(filter => {
|
||||
{filteredFilters.map(filter => {
|
||||
return (
|
||||
<SwipeableListItem
|
||||
swipeActionOpen={
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import EmptyState from '../../components/common/EmptyState'
|
||||
import SortAndFilterMenu from '../../components/common/SortAndFilterMenu'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors'
|
||||
import { DeleteLabel } from '../../utils/Fetcher'
|
||||
@@ -176,25 +177,59 @@ const LabelView = () => {
|
||||
const [confirmationModel, setConfirmationModel] = useState({})
|
||||
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [sortBy, setSortBy] = useState(
|
||||
() => localStorage.getItem('labelsSortBy') || 'name',
|
||||
)
|
||||
const [sortDirection, setSortDirection] = useState(
|
||||
() => localStorage.getItem('labelsSortDirection') || 'asc',
|
||||
)
|
||||
const [ownershipFilter, setOwnershipFilter] = useState('all')
|
||||
const searchInputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('labelsSortBy', sortBy)
|
||||
localStorage.setItem('labelsSortDirection', sortDirection)
|
||||
}, [sortBy, sortDirection])
|
||||
|
||||
const visibleLabels = useMemo(() => {
|
||||
if (ownershipFilter === 'mine') {
|
||||
return userLabels.filter(label => label.created_by === userProfile?.id)
|
||||
}
|
||||
if (ownershipFilter === 'shared') {
|
||||
return userLabels.filter(label => label.created_by !== userProfile?.id)
|
||||
}
|
||||
return userLabels
|
||||
}, [ownershipFilter, userLabels, userProfile?.id])
|
||||
|
||||
const fuse = useMemo(
|
||||
() =>
|
||||
new Fuse(userLabels, {
|
||||
new Fuse(visibleLabels, {
|
||||
keys: ['name'],
|
||||
includeScore: true,
|
||||
isCaseSensitive: false,
|
||||
findAllMatches: true,
|
||||
}),
|
||||
[userLabels],
|
||||
[visibleLabels],
|
||||
)
|
||||
|
||||
const filteredLabels = useMemo(() => {
|
||||
if (!searchTerm) {
|
||||
return userLabels
|
||||
}
|
||||
return fuse.search(searchTerm).map(result => result.item)
|
||||
}, [fuse, searchTerm, userLabels])
|
||||
const matched = searchTerm
|
||||
? fuse.search(searchTerm).map(result => result.item)
|
||||
: visibleLabels
|
||||
|
||||
const direction = sortDirection === 'desc' ? -1 : 1
|
||||
return [...matched].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'color':
|
||||
return direction * (a.color || '').localeCompare(b.color || '')
|
||||
case 'created':
|
||||
return direction * ((a.id || 0) - (b.id || 0))
|
||||
case 'name':
|
||||
default:
|
||||
return direction * (a.name || '').localeCompare(b.name || '')
|
||||
}
|
||||
})
|
||||
}, [fuse, searchTerm, visibleLabels, sortBy, sortDirection])
|
||||
|
||||
const handleSearchChange = e => {
|
||||
setSearchTerm(e.target.value.toLowerCase())
|
||||
@@ -310,7 +345,9 @@ const LabelView = () => {
|
||||
</Stack>
|
||||
</Box>
|
||||
{userLabels.length > 0 && (
|
||||
<Box sx={{ px: 2, mb: 2 }}>
|
||||
<Box
|
||||
sx={{ px: 2, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}
|
||||
>
|
||||
<Input
|
||||
slotProps={{ input: { ref: searchInputRef } }}
|
||||
placeholder={t('search.placeholder')}
|
||||
@@ -337,6 +374,33 @@ const LabelView = () => {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<SortAndFilterMenu
|
||||
sortOptions={[
|
||||
{ name: 'Name', value: 'name' },
|
||||
{ name: 'Color', value: 'color' },
|
||||
{ name: 'Recently created', value: 'created' },
|
||||
]}
|
||||
selectedSort={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
sortDirection={sortDirection}
|
||||
onSortDirectionChange={setSortDirection}
|
||||
filterTitle='Show'
|
||||
filterOptions={[
|
||||
{ name: 'All labels', value: 'all' },
|
||||
{ name: 'Created by me', value: 'mine' },
|
||||
{ name: 'Shared with me', value: 'shared' },
|
||||
]}
|
||||
selectedFilter={ownershipFilter}
|
||||
onFilterChange={value => {
|
||||
setOwnershipFilter(value)
|
||||
setShowMoreInfoId(null)
|
||||
}}
|
||||
isActive={
|
||||
ownershipFilter !== 'all' ||
|
||||
sortBy !== 'name' ||
|
||||
sortDirection !== 'asc'
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
@@ -363,12 +427,17 @@ const LabelView = () => {
|
||||
fullHeight
|
||||
icon={<SearchOff />}
|
||||
title={t('search.noResultsTitle')}
|
||||
description={t('search.noResultsDescription', {
|
||||
searchTerm,
|
||||
})}
|
||||
description={
|
||||
searchTerm
|
||||
? t('search.noResultsDescription', { searchTerm })
|
||||
: t('search.noFilterResultsDescription')
|
||||
}
|
||||
primaryAction={{
|
||||
label: t('search.clear'),
|
||||
onClick: handleSearchClose,
|
||||
label: searchTerm ? t('search.clear') : t('search.showAll'),
|
||||
onClick: () => {
|
||||
handleSearchClose()
|
||||
setOwnershipFilter('all')
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,14 @@ import {
|
||||
TrailingActions,
|
||||
Type as ListType,
|
||||
} from '@meauxt/react-swipeable-list'
|
||||
import { Add, MoreVert, Task } from '@mui/icons-material'
|
||||
import {
|
||||
Add,
|
||||
Close,
|
||||
MoreVert,
|
||||
Search,
|
||||
SearchOff,
|
||||
Task,
|
||||
} from '@mui/icons-material'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import {
|
||||
@@ -17,14 +24,18 @@ import {
|
||||
CircularProgress,
|
||||
Container,
|
||||
IconButton,
|
||||
Input,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Fuse from 'fuse.js'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import EmptyState from '../../components/common/EmptyState'
|
||||
import SortAndFilterMenu from '../../components/common/SortAndFilterMenu'
|
||||
import { useChores } from '../../queries/ChoreQueries'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors'
|
||||
@@ -240,6 +251,83 @@ const ProjectView = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const [confirmationModel, setConfirmationModel] = useState({})
|
||||
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [sortBy, setSortBy] = useState(
|
||||
() => localStorage.getItem('projectsSortBy') || 'name',
|
||||
)
|
||||
const [sortDirection, setSortDirection] = useState(
|
||||
() => localStorage.getItem('projectsSortDirection') || 'asc',
|
||||
)
|
||||
const [ownershipFilter, setOwnershipFilter] = useState('all')
|
||||
const searchInputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('projectsSortBy', sortBy)
|
||||
localStorage.setItem('projectsSortDirection', sortDirection)
|
||||
}, [sortBy, sortDirection])
|
||||
|
||||
const visibleProjects = useMemo(() => {
|
||||
if (ownershipFilter === 'mine') {
|
||||
return userProjects.filter(
|
||||
project => project.created_by === userProfile?.id,
|
||||
)
|
||||
}
|
||||
if (ownershipFilter === 'shared') {
|
||||
return userProjects.filter(
|
||||
project => project.created_by !== userProfile?.id,
|
||||
)
|
||||
}
|
||||
return userProjects
|
||||
}, [ownershipFilter, userProjects, userProfile?.id])
|
||||
|
||||
const fuse = useMemo(
|
||||
() =>
|
||||
new Fuse(visibleProjects, {
|
||||
keys: ['name', 'description'],
|
||||
includeScore: true,
|
||||
isCaseSensitive: false,
|
||||
findAllMatches: true,
|
||||
}),
|
||||
[visibleProjects],
|
||||
)
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
const matched = searchTerm
|
||||
? fuse.search(searchTerm).map(result => result.item)
|
||||
: visibleProjects
|
||||
|
||||
const direction = sortDirection === 'desc' ? -1 : 1
|
||||
return [...matched].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'tasks':
|
||||
return direction * ((taskCounts[a.id] || 0) - (taskCounts[b.id] || 0))
|
||||
case 'created':
|
||||
return direction * ((a.id || 0) - (b.id || 0))
|
||||
case 'name':
|
||||
default:
|
||||
return direction * (a.name || '').localeCompare(b.name || '')
|
||||
}
|
||||
})
|
||||
}, [fuse, searchTerm, visibleProjects, sortBy, sortDirection, taskCounts])
|
||||
|
||||
// The default project is pinned above the list, so it is matched separately.
|
||||
const showDefaultProject = useMemo(() => {
|
||||
if (ownershipFilter === 'shared') return false
|
||||
if (!searchTerm) return true
|
||||
return t('chores:toolbar.defaultProject')
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase())
|
||||
}, [ownershipFilter, searchTerm, t])
|
||||
|
||||
const handleSearchChange = e => {
|
||||
setSearchTerm(e.target.value)
|
||||
setShowMoreInfoId(null)
|
||||
}
|
||||
|
||||
const handleSearchClose = () => {
|
||||
setSearchTerm('')
|
||||
searchInputRef.current?.blur()
|
||||
}
|
||||
|
||||
const handleAddProject = () => {
|
||||
setCurrentProject(null)
|
||||
@@ -388,36 +476,114 @@ const ProjectView = () => {
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 2, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Input
|
||||
slotProps={{ input: { ref: searchInputRef } }}
|
||||
placeholder={t('search.placeholder')}
|
||||
value={searchTerm}
|
||||
fullWidth
|
||||
sx={{
|
||||
borderRadius: 24,
|
||||
height: 24,
|
||||
borderColor: 'text.disabled',
|
||||
padding: 1,
|
||||
}}
|
||||
onChange={handleSearchChange}
|
||||
startDecorator={<Search />}
|
||||
endDecorator={
|
||||
searchTerm && (
|
||||
<IconButton
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={handleSearchClose}
|
||||
sx={{ borderRadius: '50%' }}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<SortAndFilterMenu
|
||||
sortOptions={[
|
||||
{ name: 'Name', value: 'name' },
|
||||
{ name: 'Task count', value: 'tasks' },
|
||||
{ name: 'Recently created', value: 'created' },
|
||||
]}
|
||||
selectedSort={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
sortDirection={sortDirection}
|
||||
onSortDirectionChange={setSortDirection}
|
||||
filterTitle='Show'
|
||||
filterOptions={[
|
||||
{ name: 'All projects', value: 'all' },
|
||||
{ name: 'Created by me', value: 'mine' },
|
||||
{ name: 'Shared with me', value: 'shared' },
|
||||
]}
|
||||
selectedFilter={ownershipFilter}
|
||||
onFilterChange={value => {
|
||||
setOwnershipFilter(value)
|
||||
setShowMoreInfoId(null)
|
||||
}}
|
||||
isActive={
|
||||
ownershipFilter !== 'all' ||
|
||||
sortBy !== 'name' ||
|
||||
sortDirection !== 'asc'
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{!showDefaultProject && filteredProjects.length === 0 && (
|
||||
<EmptyState
|
||||
variant='no-results'
|
||||
fullHeight
|
||||
icon={<SearchOff />}
|
||||
title={t('search.noResultsTitle')}
|
||||
description={
|
||||
searchTerm
|
||||
? t('search.noResultsDescription', { searchTerm })
|
||||
: t('search.noFilterResultsDescription')
|
||||
}
|
||||
primaryAction={{
|
||||
label: searchTerm ? t('search.clear') : t('search.showAll'),
|
||||
onClick: () => {
|
||||
handleSearchClose()
|
||||
setOwnershipFilter('all')
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Default project - not swipeable */}
|
||||
<ProjectCardContent
|
||||
project={{
|
||||
id: 'default',
|
||||
name: t('chores:toolbar.defaultProject'),
|
||||
description: t('defaultDescription'),
|
||||
icon: 'FolderOpen',
|
||||
color: '#1976d2',
|
||||
created_by: userProfile?.id,
|
||||
}}
|
||||
currentUserId={userProfile?.id}
|
||||
taskCounts={{ default: taskCounts.default || 0 }}
|
||||
onCardClick={() =>
|
||||
handleCardClick({
|
||||
{showDefaultProject && (
|
||||
<ProjectCardContent
|
||||
project={{
|
||||
id: 'default',
|
||||
name: t('chores:toolbar.defaultProject'),
|
||||
description: t('defaultDescription'),
|
||||
icon: 'FolderOpen',
|
||||
color: '#1976d2',
|
||||
})
|
||||
}
|
||||
/>
|
||||
created_by: userProfile?.id,
|
||||
}}
|
||||
currentUserId={userProfile?.id}
|
||||
taskCounts={{ default: taskCounts.default || 0 }}
|
||||
onCardClick={() =>
|
||||
handleCardClick({
|
||||
id: 'default',
|
||||
name: t('chores:toolbar.defaultProject'),
|
||||
icon: 'FolderOpen',
|
||||
color: '#1976d2',
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* User projects - swipeable */}
|
||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||
{userProjects.map(project => (
|
||||
{filteredProjects.map(project => (
|
||||
<SwipeableListItem
|
||||
onClick={() => handleCardClick(project)}
|
||||
key={project.id}
|
||||
|
||||
@@ -9,11 +9,14 @@ import {
|
||||
} from '@meauxt/react-swipeable-list'
|
||||
import {
|
||||
Add,
|
||||
Close,
|
||||
Delete,
|
||||
Edit,
|
||||
Flip,
|
||||
MoreVert,
|
||||
PlusOne,
|
||||
Search,
|
||||
SearchOff,
|
||||
ToggleOff,
|
||||
ToggleOn,
|
||||
Widgets,
|
||||
@@ -24,14 +27,17 @@ import {
|
||||
Chip,
|
||||
Container,
|
||||
IconButton,
|
||||
Input,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Fuse from 'fuse.js'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { track } from '../../analytics'
|
||||
import EmptyState from '../../components/common/EmptyState'
|
||||
import SortAndFilterMenu from '../../components/common/SortAndFilterMenu'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import {
|
||||
CreateThing,
|
||||
@@ -215,8 +221,78 @@ const ThingsView = () => {
|
||||
const [createModalThing, setCreateModalThing] = useState(null)
|
||||
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
||||
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [sortBy, setSortBy] = useState(
|
||||
() => localStorage.getItem('thingsSortBy') || 'name',
|
||||
)
|
||||
const [sortDirection, setSortDirection] = useState(
|
||||
() => localStorage.getItem('thingsSortDirection') || 'asc',
|
||||
)
|
||||
const [typeFilter, setTypeFilter] = useState('all')
|
||||
const searchInputRef = useRef(null)
|
||||
const { showError, showNotification } = useNotification()
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('thingsSortBy', sortBy)
|
||||
localStorage.setItem('thingsSortDirection', sortDirection)
|
||||
}, [sortBy, sortDirection])
|
||||
|
||||
const visibleThings = useMemo(
|
||||
() =>
|
||||
typeFilter === 'all'
|
||||
? things
|
||||
: things.filter(thing => thing?.type === typeFilter),
|
||||
[things, typeFilter],
|
||||
)
|
||||
|
||||
const fuse = useMemo(
|
||||
() =>
|
||||
new Fuse(visibleThings, {
|
||||
keys: ['name', 'state'],
|
||||
includeScore: true,
|
||||
isCaseSensitive: false,
|
||||
findAllMatches: true,
|
||||
}),
|
||||
[visibleThings],
|
||||
)
|
||||
|
||||
const filteredThings = useMemo(() => {
|
||||
const matched = searchTerm
|
||||
? fuse.search(searchTerm).map(result => result.item)
|
||||
: visibleThings
|
||||
|
||||
const direction = sortDirection === 'desc' ? -1 : 1
|
||||
return [...matched].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'type':
|
||||
return direction * (a.type || '').localeCompare(b.type || '')
|
||||
case 'state':
|
||||
return (
|
||||
direction *
|
||||
String(a.state ?? '').localeCompare(String(b.state ?? ''))
|
||||
)
|
||||
case 'updated': {
|
||||
const aDate = new Date(a.updatedAt || a.updated_at || 0).getTime()
|
||||
const bDate = new Date(b.updatedAt || b.updated_at || 0).getTime()
|
||||
return direction * (aDate - bDate)
|
||||
}
|
||||
case 'name':
|
||||
default:
|
||||
return direction * (a.name || '').localeCompare(b.name || '')
|
||||
}
|
||||
})
|
||||
}, [fuse, searchTerm, visibleThings, sortBy, sortDirection])
|
||||
|
||||
const handleSearchChange = e => {
|
||||
setSearchTerm(e.target.value)
|
||||
setShowMoreInfoId(null)
|
||||
}
|
||||
|
||||
const handleSearchClose = () => {
|
||||
setSearchTerm('')
|
||||
searchInputRef.current?.blur()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// fetch things
|
||||
GetThings().then(result => {
|
||||
@@ -404,6 +480,67 @@ const ThingsView = () => {
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
{things.length > 0 && (
|
||||
<Box
|
||||
sx={{ px: 2, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}
|
||||
>
|
||||
<Input
|
||||
slotProps={{ input: { ref: searchInputRef } }}
|
||||
placeholder='Search things'
|
||||
value={searchTerm}
|
||||
fullWidth
|
||||
sx={{
|
||||
borderRadius: 24,
|
||||
height: 24,
|
||||
borderColor: 'text.disabled',
|
||||
padding: 1,
|
||||
}}
|
||||
onChange={handleSearchChange}
|
||||
startDecorator={<Search />}
|
||||
endDecorator={
|
||||
searchTerm && (
|
||||
<IconButton
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={handleSearchClose}
|
||||
sx={{ borderRadius: '50%' }}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<SortAndFilterMenu
|
||||
sortOptions={[
|
||||
{ name: 'Name', value: 'name' },
|
||||
{ name: 'Type', value: 'type' },
|
||||
{ name: 'State', value: 'state' },
|
||||
{ name: 'Last updated', value: 'updated' },
|
||||
]}
|
||||
selectedSort={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
sortDirection={sortDirection}
|
||||
onSortDirectionChange={setSortDirection}
|
||||
filterTitle='Type'
|
||||
filterOptions={[
|
||||
{ name: 'All types', value: 'all' },
|
||||
{ name: 'Text', value: 'text' },
|
||||
{ name: 'Number', value: 'number' },
|
||||
{ name: 'Boolean', value: 'boolean' },
|
||||
]}
|
||||
selectedFilter={typeFilter}
|
||||
onFilterChange={value => {
|
||||
setTypeFilter(value)
|
||||
setShowMoreInfoId(null)
|
||||
}}
|
||||
isActive={
|
||||
typeFilter !== 'all' ||
|
||||
sortBy !== 'name' ||
|
||||
sortDirection !== 'asc'
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
@@ -425,8 +562,28 @@ const ThingsView = () => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{things.length > 0 && filteredThings.length === 0 && (
|
||||
<EmptyState
|
||||
variant='no-results'
|
||||
fullHeight
|
||||
icon={<SearchOff />}
|
||||
title='No things match'
|
||||
description={
|
||||
searchTerm
|
||||
? `No thing matches "${searchTerm}".`
|
||||
: 'No thing matches the current filter.'
|
||||
}
|
||||
primaryAction={{
|
||||
label: searchTerm ? 'Clear search' : 'Show all things',
|
||||
onClick: () => {
|
||||
handleSearchClose()
|
||||
setTypeFilter('all')
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||
{things.map(thing => (
|
||||
{filteredThings.map(thing => (
|
||||
<SwipeableListItem
|
||||
onClick={() => navigate(`/things/${thing?.id}`)}
|
||||
key={thing.id}
|
||||
|
||||
Reference in New Issue
Block a user