add SortAndFilterMenu component and integrate it into various views for enhanced sorting and filtering capabilities

This commit is contained in:
Mo Tarbin
2026-08-16 02:16:33 -04:00
parent 325d5e6df7
commit 5359ae193b
8 changed files with 914 additions and 38 deletions

View File

@@ -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",

View File

@@ -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": {

View 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

View File

@@ -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'

View File

@@ -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={

View File

@@ -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
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 || '')
}
return fuse.search(searchTerm).map(result => result.item)
}, [fuse, searchTerm, userLabels])
})
}, [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')
},
}}
/>
)}

View File

@@ -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,12 +476,89 @@ 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 */}
{showDefaultProject && (
<ProjectCardContent
project={{
id: 'default',
@@ -414,10 +579,11 @@ const ProjectView = () => {
})
}
/>
)}
{/* User projects - swipeable */}
<SwipeableList type={ListType.IOS} fullSwipe={false}>
{userProjects.map(project => (
{filteredProjects.map(project => (
<SwipeableListItem
onClick={() => handleCardClick(project)}
key={project.id}

View File

@@ -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}