Merge pull request #201 from donetick/search-improvments

Search improvments
This commit is contained in:
Mohamad Tarbin
2026-08-10 01:19:43 -04:00
committed by GitHub
13 changed files with 980 additions and 41 deletions

View File

@@ -20,6 +20,7 @@
"logout": "Logout",
"version": "Version",
"navigation": {
"search": "Search",
"allTasks": "All Tasks",
"archived": "Archived",
"things": "Things",

View File

@@ -16,6 +16,7 @@ import useOnboardingGate from './hooks/useOnboardingGate'
import useStatusBar from './hooks/useStatusBar'
import { useSyncOnReconnect } from './hooks/useSyncOnReconnect'
import { useResource } from './queries/ResourceQueries'
import { GlobalSearchProvider } from './search/GlobalSearchContext'
import { recordRoute } from './service/DiagnosticsSession'
import { useNotification } from './service/NotificationProvider'
import NetworkBanner from './views/components/NetworkBanner'
@@ -147,7 +148,9 @@ function App() {
<AuthProvider>
<SSEProvider>
<AppContent />
<GlobalSearchProvider>
<AppContent />
</GlobalSearchProvider>
</SSEProvider>
</AuthProvider>
</div>

View File

@@ -13,6 +13,7 @@ import SettingsOverview from '@/views/Settings/SettingsOverview'
import SettingsRoutes from '@/views/Settings/SettingsRoutes'
import ThemeSettings from '@/views/Settings/ThemeSettings'
import GlobalSearchPage from '../search/GlobalSearchPage'
import AuthenticationLoading from '../views/Authorization/Authenticating'
import ForgotPasswordView from '../views/Authorization/ForgotPasswordView'
import LoginSettings from '../views/Authorization/LoginSettings'
@@ -131,6 +132,10 @@ const Router = createBrowserRouter([
path: '/chores',
element: <MyChores />,
},
{
path: '/search',
element: <GlobalSearchPage />,
},
{
path: '/archived',
element: <ArchivedTasks />,

View File

@@ -0,0 +1,192 @@
import useMediaQuery from '@mui/material/useMediaQuery'
import { useQueryClient } from '@tanstack/react-query'
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { offlineDB } from '../utils/OfflineDB'
import { isParentUser } from '../utils/UserHelpers'
import GlobalSearchPalette from './GlobalSearchPalette'
import { getSearchProviders } from './searchProviders'
const GlobalSearchContext = createContext(null)
const BLOCKED_ROUTES = [
'/login',
'/signup',
'/welcome',
'/onboarding',
'/get-started',
'/ready',
]
const unwrap = value => (Array.isArray(value) ? value : value?.res || [])
const uniqueBy = (items, getId) => [
...new Map(
items.filter(Boolean).map(item => [String(getId(item)), item]),
).values(),
]
export const GlobalSearchProvider = ({ children }) => {
const queryClient = useQueryClient()
const location = useLocation()
const navigate = useNavigate()
const isMobile = useMediaQuery('(max-width:768px)')
const [isOpen, setIsOpen] = useState(false)
const [initialQuery, setInitialQuery] = useState('')
const [documents, setDocuments] = useState([])
const [isLoading, setIsLoading] = useState(false)
const loadDocuments = useCallback(async () => {
setIsLoading(true)
try {
const cachedChores = queryClient
.getQueriesData({ queryKey: ['chores'] })
.flatMap(([, data]) => unwrap(data))
const cachedHistory = [
...queryClient.getQueriesData({ queryKey: ['choresHistory'] }),
...queryClient.getQueriesData({ queryKey: ['choreHistory'] }),
].flatMap(([, data]) => unwrap(data))
const [
offlineChores,
offlineHistory,
offlineProjects,
offlineLabels,
offlineMembers,
offlineProfile,
] = await Promise.all([
offlineDB.getChores(true).catch(() => []),
offlineDB.getHistoryByDays(365).catch(() => []),
offlineDB.getKV('projects').catch(() => []),
offlineDB.getKV('labels').catch(() => []),
offlineDB.getKV('circle_members').catch(() => []),
offlineDB.getKV('user_profile').catch(() => null),
])
const projects = uniqueBy(
[
...unwrap(queryClient.getQueryData(['projects'])),
...unwrap(offlineProjects),
],
item => item.id,
)
const labels = uniqueBy(
[
...unwrap(queryClient.getQueryData(['labels'])),
...unwrap(offlineLabels),
],
item => item.id,
)
const members = uniqueBy(
[
...unwrap(queryClient.getQueryData(['allCircleMembers'])),
...unwrap(offlineMembers),
],
item => item.userId,
)
const chores = uniqueBy(
[...cachedChores, ...unwrap(offlineChores)],
item => item.id,
)
const history = uniqueBy(
[...cachedHistory, ...unwrap(offlineHistory)],
item => item.id,
)
const profile =
queryClient
.getQueriesData({ queryKey: ['userProfile'] })
.find(([, data]) => data)?.[1] || offlineProfile
const sources = {
chores,
history,
projects,
labels,
members,
isParent: isParentUser(profile),
choresById: new Map(chores.map(item => [String(item.id), item])),
projectsById: new Map(projects.map(item => [String(item.id), item])),
membersById: new Map(members.map(item => [String(item.userId), item])),
}
const nextDocuments = getSearchProviders().flatMap(provider => {
try {
return provider.getDocuments(sources) || []
} catch (error) {
console.warn(`Search provider ${provider.id} failed`, error)
return []
}
})
setDocuments(nextDocuments)
} finally {
setIsLoading(false)
}
}, [queryClient])
const openSearch = useCallback(
(query = '') => {
if (BLOCKED_ROUTES.some(route => location.pathname.startsWith(route)))
return
if (isMobile) {
loadDocuments()
navigate('/search', { state: { initialQuery: query } })
return
}
setInitialQuery(query)
setIsOpen(true)
loadDocuments()
},
[isMobile, loadDocuments, location.pathname, navigate],
)
const closeSearch = useCallback(() => setIsOpen(false), [])
useEffect(() => {
const onKeyDown = event => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'f') {
event.preventDefault()
isOpen ? closeSearch() : openSearch()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [closeSearch, isOpen, openSearch])
const value = useMemo(
() => ({
closeSearch,
documents,
isLoading,
loadDocuments,
openSearch,
}),
[closeSearch, documents, isLoading, loadDocuments, openSearch],
)
return (
<GlobalSearchContext.Provider value={value}>
{children}
{isOpen && (
<GlobalSearchPalette
documents={documents}
initialQuery={initialQuery}
isLoading={isLoading}
onClose={closeSearch}
/>
)}
</GlobalSearchContext.Provider>
)
}
export const useGlobalSearch = () => {
const context = useContext(GlobalSearchContext)
if (!context)
throw new Error('useGlobalSearch must be used inside GlobalSearchProvider')
return context
}

View File

@@ -0,0 +1,32 @@
import { useEffect } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useGlobalSearch } from './GlobalSearchContext'
import GlobalSearchPalette from './GlobalSearchPalette'
const GlobalSearchPage = () => {
const location = useLocation()
const navigate = useNavigate()
const { documents, isLoading, loadDocuments } = useGlobalSearch()
useEffect(() => {
loadDocuments()
}, [loadDocuments])
const handleClose = () => {
if (window.history.state?.idx > 0) navigate(-1)
else navigate('/chores', { replace: true })
}
return (
<GlobalSearchPalette
documents={documents}
initialQuery={location.state?.initialQuery || ''}
isLoading={isLoading}
onClose={handleClose}
presentation='page'
/>
)
}
export default GlobalSearchPage

View File

@@ -0,0 +1,468 @@
import {
AddRounded,
CheckCircleOutline,
FolderOutlined,
HistoryRounded,
InboxOutlined,
LabelOutlined,
PersonOutline,
SearchRounded,
SettingsOutlined,
} from '@mui/icons-material'
import {
Box,
Chip,
CircularProgress,
Divider,
Input,
List,
ListItemButton,
ListItemContent,
ListItemDecorator,
Typography,
} from '@mui/joy'
import Fuse from 'fuse.js'
import PropTypes from 'prop-types'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import AppModal from '../components/common/AppModal'
const RECENTS_KEY = 'donetick.globalSearch.recents'
const GROUPS = [
'tasks',
'history',
'projects',
'labels',
'people',
'settings',
'actions',
]
const GROUP_LABELS = {
tasks: 'Tasks',
history: 'Notes',
projects: 'Projects',
labels: 'Labels',
people: 'People',
settings: 'Settings',
actions: 'Quick actions',
}
const ICONS = {
tasks: <CheckCircleOutline />,
history: <HistoryRounded />,
projects: <FolderOutlined />,
labels: <LabelOutlined />,
people: <PersonOutline />,
settings: <SettingsOutlined />,
actions: <AddRounded />,
}
const QUICK_ACTIONS = [
{
id: 'action:create',
provider: 'actions',
title: 'Create a task',
subtitle: 'Quick action',
route: '/chores/create',
},
{
id: 'action:tasks',
provider: 'actions',
title: 'View all tasks',
subtitle: 'Navigation',
route: '/chores',
},
{
id: 'action:archived',
provider: 'actions',
title: 'View archived tasks',
subtitle: 'Navigation',
route: '/archived',
},
{
id: 'action:settings',
provider: 'actions',
title: 'Open settings',
subtitle: 'Navigation',
route: '/settings',
},
]
const readRecents = () => {
try {
return JSON.parse(localStorage.getItem(RECENTS_KEY)) || []
} catch {
return []
}
}
const saveRecent = result => {
if (result.provider === 'actions') return
const recent = {
id: result.id,
provider: result.provider,
route: result.route,
title: result.title,
subtitle: result.subtitle,
}
localStorage.setItem(
RECENTS_KEY,
JSON.stringify(
[recent, ...readRecents().filter(item => item.id !== result.id)].slice(
0,
6,
),
),
)
}
const Highlight = ({ query, text }) => {
if (!text || !query.trim()) return text || null
const words = query.trim().split(/\s+/).filter(Boolean)
const escaped = words.map(word => word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
if (!escaped.length) return text
const pattern = new RegExp(`(${escaped.join('|')})`, 'ig')
const isMatch = new RegExp(`^(${escaped.join('|')})$`, 'i')
return String(text)
.split(pattern)
.map((part, index) =>
isMatch.test(part) ? (
<Box
component='mark'
key={index}
sx={{ bgcolor: 'warning.softBg', color: 'inherit', borderRadius: 2 }}
>
{part}
</Box>
) : (
part
),
)
}
const SearchContainer = ({ children, onClose, presentation }) => {
if (presentation === 'page') {
return (
<Box
component='main'
sx={{
display: 'flex',
flexDirection: 'column',
height: 'calc(100dvh - 56px)',
minHeight: 0,
overflow: 'hidden',
bgcolor: 'background.body',
}}
>
{children}
</Box>
)
}
return (
<AppModal
open
onClose={onClose}
disableRestoreFocus
title='Search'
size='lg'
maxHeight='min(720px, calc(100dvh - 48px))'
contentSx={{
p: 0,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
sx={{ height: 'min(720px, calc(100dvh - 48px))' }}
>
{children}
</AppModal>
)
}
const GlobalSearchPalette = ({
documents,
initialQuery,
isLoading,
onClose,
presentation = 'modal',
}) => {
const navigate = useNavigate()
const focusInputRef = useCallback(node => {
if (node) requestAnimationFrame(() => node.focus())
}, [])
const [query, setQuery] = useState(initialQuery || '')
const [selectedIndex, setSelectedIndex] = useState(0)
const [recents] = useState(readRecents)
const selectedResultRef = useRef(null)
const searchIndexes = useMemo(
() =>
new Map(
GROUPS.filter(group => group !== 'actions').map(group => [
group,
new Fuse(
documents.filter(item => item.provider === group),
{
threshold: 0.38,
distance: 120,
ignoreLocation: true,
includeScore: true,
keys:
group === 'history'
? [{ name: 'body', weight: 1 }]
: [
{ name: 'title', weight: 0.5 },
{ name: 'keywords', weight: 0.25 },
{ name: 'body', weight: 0.17 },
{ name: 'subtitle', weight: 0.08 },
],
},
),
]),
),
[documents],
)
const results = useMemo(() => {
const normalized = query.trim().toLocaleLowerCase()
if (!normalized) {
const currentById = new Map(documents.map(item => [item.id, item]))
const recentResults = recents
.map(item => currentById.get(item.id) || item)
.filter(item => item.provider !== 'history' || currentById.has(item.id))
return [...recentResults, ...QUICK_ACTIONS]
}
const grouped = GROUPS.filter(group => group !== 'actions').flatMap(group =>
(searchIndexes.get(group)?.search(normalized, { limit: 7 }) || [])
.map(match => {
const title = match.item.title?.toLocaleLowerCase() ?? ''
let score = match.score ?? 1
if (group !== 'history') {
if (title === normalized) {
score -= 1
} else if (title.startsWith(normalized)) {
score -= 0.15
} else if (title.includes(normalized)) {
score -= 0.08
}
}
return { ...match.item, score }
})
.sort((a, b) => a.score - b.score),
)
grouped.push({
id: 'action:filter-tasks',
provider: 'actions',
title: `Show tasks matching “${query.trim()}`,
subtitle: 'Filter the task list',
route: `/chores?search=${encodeURIComponent(query.trim())}`,
})
return grouped
}, [documents, query, recents, searchIndexes])
useEffect(() => {
selectedResultRef.current?.scrollIntoView({
block: 'nearest',
inline: 'nearest',
})
}, [selectedIndex, results])
const selectResult = result => {
saveRecent(result)
navigate(result.route)
if (presentation === 'modal') onClose()
}
const onInputKeyDown = event => {
if (event.key === 'ArrowDown') {
event.preventDefault()
setSelectedIndex(index => Math.min(index + 1, results.length - 1))
} else if (event.key === 'ArrowUp') {
event.preventDefault()
setSelectedIndex(index => Math.max(index - 1, 0))
} else if (event.key === 'Enter' && results[selectedIndex]) {
event.preventDefault()
selectResult(results[selectedIndex])
} else if (event.key === 'Escape') {
event.preventDefault()
onClose()
}
}
return (
<SearchContainer onClose={onClose} presentation={presentation}>
<Box sx={{ p: { xs: 1.5, sm: 2 } }}>
<Input
autoFocus
slotProps={{
input: {
ref: focusInputRef,
'aria-label':
'Search tasks, history, projects, labels and settings',
},
}}
value={query}
onChange={event => {
setQuery(event.target.value)
setSelectedIndex(0)
}}
onKeyDown={onInputKeyDown}
placeholder='Search Donetick'
startDecorator={<SearchRounded />}
endDecorator={
isLoading ? (
<CircularProgress size='sm' />
) : presentation === 'modal' ? (
<Chip size='sm' variant='outlined'>
Esc
</Chip>
) : null
}
sx={{
'--Input-minHeight': '48px',
fontSize: 'md',
borderRadius: 'lg',
}}
/>
<Typography
level='body-xs'
sx={{ color: 'text.tertiary', mt: 1, px: 0.5 }}
>
Searching content available on this device
</Typography>
</Box>
<Divider />
<Box
sx={{
overflowY: 'auto',
flex: 1,
pb: 'var(--safe-area-inset-bottom, 0px)',
}}
>
{!isLoading && query.trim() && results.length === 1 && (
<Box sx={{ px: 3, py: 6, textAlign: 'center' }}>
<InboxOutlined
sx={{ fontSize: 36, color: 'text.tertiary', mb: 1 }}
/>
<Typography level='title-md'>No direct matches</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
You can still filter the task list with this search.
</Typography>
</Box>
)}
<List aria-live='polite' sx={{ px: 1, py: 1 }}>
{results.map((result, index) => {
const hasQuery = Boolean(query.trim())
const showHeading = hasQuery
? index === 0 || result.provider !== results[index - 1].provider
: index === 0 ||
(result.provider === 'actions' &&
results[index - 1].provider !== 'actions')
return (
<Box key={result.id}>
{showHeading && (
<Typography
level='body-xs'
sx={{
color: 'text.tertiary',
fontWeight: 'lg',
px: 1.5,
pt: index ? 2 : 0.5,
pb: 0.5,
textTransform: 'uppercase',
letterSpacing: '0.08em',
}}
>
{!query.trim() && result.provider !== 'actions'
? 'Recent'
: GROUP_LABELS[result.provider]}
</Typography>
)}
<ListItemButton
ref={index === selectedIndex ? selectedResultRef : null}
selected={index === selectedIndex}
onMouseMove={() => setSelectedIndex(index)}
onClick={() => selectResult(result)}
sx={{
borderRadius: 'md',
py: 1.1,
alignItems: 'flex-start',
}}
>
<ListItemDecorator
sx={{ mt: 0.25, color: result.color || 'text.secondary' }}
>
{ICONS[result.provider]}
</ListItemDecorator>
<ListItemContent>
<Typography
level='title-sm'
sx={{ overflowWrap: 'anywhere' }}
>
<Highlight query={query} text={result.title} />
</Typography>
<Typography
level='body-xs'
sx={{ color: 'text.secondary' }}
noWrap
>
{[result.subtitle, result.body]
.filter(Boolean)
.join(' · ')}
</Typography>
</ListItemContent>
</ListItemButton>
</Box>
)
})}
</List>
</Box>
<Divider />
<Box
sx={{
display: { xs: 'none', sm: 'flex' },
gap: 2,
px: 2,
py: 1,
color: 'text.tertiary',
}}
>
<Typography level='body-xs'> Navigate</Typography>
<Typography level='body-xs'> Open</Typography>
<Typography level='body-xs' sx={{ ml: 'auto' }}>
{query.trim()
? `${Math.max(0, results.length - 1)} results`
: 'Type to search'}
</Typography>
</Box>
</SearchContainer>
)
}
SearchContainer.propTypes = {
children: PropTypes.node.isRequired,
onClose: PropTypes.func.isRequired,
presentation: PropTypes.oneOf(['modal', 'page']).isRequired,
}
Highlight.propTypes = {
query: PropTypes.string.isRequired,
text: PropTypes.string,
}
GlobalSearchPalette.propTypes = {
documents: PropTypes.arrayOf(PropTypes.object).isRequired,
initialQuery: PropTypes.string,
isLoading: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
presentation: PropTypes.oneOf(['modal', 'page']),
}
export default GlobalSearchPalette

View File

@@ -0,0 +1,182 @@
const stripHtml = value => {
if (!value) return ''
if (typeof globalThis.document === 'undefined')
return String(value).replace(/<[^>]*>/g, ' ')
const element = globalThis.document.createElement('div')
element.innerHTML = String(value)
return element.textContent || element.innerText || ''
}
const HISTORY_STATUS = {
0: 'in progress',
1: 'completed',
2: 'skipped',
3: 'pending approval',
4: 'rejected',
5: 'missed',
6: 'rescheduled',
}
const SETTINGS = [
['profile', 'Profile', 'Name, avatar and personal details'],
['circle', 'Circle', 'Members and household settings', true],
['account', 'Account', 'Subscription and account management', true],
['subaccounts', 'Subaccounts', 'Manage child accounts'],
['notifications', 'Notifications', 'Reminders and notification preferences'],
['mfa', 'Multi-factor authentication', 'Secure your account', true],
['apitokens', 'API tokens', 'Manage integrations and access tokens', true],
['storage', 'Storage', 'Files, backups and device storage'],
['sidepanel', 'Side panel', 'Customize navigation'],
['theme', 'Appearance', 'Theme, dark mode and colors'],
['localization', 'Language and region', 'Language, dates and time formats'],
[
'advanced',
'Advanced settings',
'Offline support, webhooks and application behavior',
],
['developer', 'Developer settings', 'Diagnostics and experimental tools'],
]
const providers = []
export const registerSearchProvider = provider => {
if (!provider?.id || typeof provider.getDocuments !== 'function') {
throw new Error('A search provider needs an id and getDocuments function')
}
const existing = providers.findIndex(item => item.id === provider.id)
if (existing >= 0) providers.splice(existing, 1, provider)
else providers.push(provider)
return () => {
const index = providers.indexOf(provider)
if (index >= 0) providers.splice(index, 1)
}
}
export const getSearchProviders = () => [...providers]
const document = (provider, item) => ({ provider, ...item })
registerSearchProvider({
id: 'tasks',
getDocuments: ({ chores, membersById, projectsById }) =>
chores.map(chore => {
const labels =
chore.labelsV2?.map(label => label.name).filter(Boolean) || []
const project = projectsById.get(String(chore.projectId))
const assignees = (chore.assignees || [])
.map(assignee => membersById.get(String(assignee.userId))?.displayName)
.filter(Boolean)
const description = stripHtml(chore.description)
return document('tasks', {
id: `task:${chore.id}`,
entityId: chore.id,
title: chore.name || 'Untitled task',
subtitle:
[project?.name, ...labels].filter(Boolean).join(' · ') || 'Task',
body: description,
keywords: [...labels, project?.name, ...assignees]
.filter(Boolean)
.join(' '),
route: `/chores/${chore.id}`,
updatedAt: chore.updatedAt || chore.createdAt,
})
}),
})
registerSearchProvider({
id: 'history',
getDocuments: ({ choresById, history, membersById }) =>
history.flatMap(entry => {
const note = stripHtml(entry.notes).trim()
if (!note) return []
const chore = choresById.get(String(entry.choreId))
const member = membersById.get(String(entry.completedBy))
return [
document('history', {
id: `history:${entry.id}`,
entityId: entry.id,
title: chore?.name || entry.choreName || 'Task note',
subtitle: [
member?.displayName,
entry.performedAt
? new Date(entry.performedAt).toLocaleDateString()
: null,
]
.filter(Boolean)
.join(' · '),
body: note,
keywords: `${HISTORY_STATUS[entry.status] || 'activity'} ${member?.displayName || ''}`,
route: entry.choreId
? `/chores/${entry.choreId}/history`
: '/activities',
updatedAt: entry.performedAt || entry.updatedAt,
}),
]
}),
})
registerSearchProvider({
id: 'projects',
getDocuments: ({ projects }) =>
projects.map(project =>
document('projects', {
id: `project:${project.id}`,
entityId: project.id,
title: project.name || 'Untitled project',
subtitle: 'Project',
body: stripHtml(project.description),
keywords: 'folder project',
route: `/chores?project=${encodeURIComponent(project.id)}`,
updatedAt: project.updatedAt,
}),
),
})
registerSearchProvider({
id: 'labels',
getDocuments: ({ labels }) =>
labels.map(label =>
document('labels', {
id: `label:${label.id}`,
entityId: label.id,
title: label.name || 'Untitled label',
subtitle: 'Label',
keywords: 'tag label',
route: '/labels',
color: label.color,
}),
),
})
registerSearchProvider({
id: 'people',
getDocuments: ({ members }) =>
members.map(member =>
document('people', {
id: `person:${member.userId}`,
entityId: member.userId,
title: member.displayName || member.username || 'Circle member',
subtitle: 'Circle member',
keywords: `${member.username || ''} person member assignee`,
route: '/chores',
}),
),
})
registerSearchProvider({
id: 'settings',
getDocuments: ({ isParent }) =>
SETTINGS.filter(([, , , parentOnly]) => !parentOnly || isParent).map(
([id, title, description]) =>
document('settings', {
id: `setting:${id}`,
entityId: id,
title,
subtitle: 'Settings',
body: description,
keywords: `preferences configuration ${id}`,
route: `/settings/${id}`,
}),
),
})

View File

@@ -28,6 +28,7 @@ import { useQueryClient } from '@tanstack/react-query'
import Fuse from 'fuse.js'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import EmptyState from '../../components/common/EmptyState'
import FilterBar from '../../components/common/FilterBar'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
@@ -38,9 +39,9 @@ import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { commandQueue, CommandType } from '../../utils/CommandQueue'
import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher'
import Priorities from '../../utils/Priorities'
import { offlineDB } from '../../utils/OfflineDB'
import { isOfflineFeatureEnabled } from '../../utils/OfflineFeatureToggle'
import Priorities from '../../utils/Priorities'
import LoadingComponent from '../components/Loading'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import ChoreCard from './ChoreCard'
@@ -93,7 +94,7 @@ const applyPendingArchivedState = async chores => {
const ArchivedTasks = () => {
const { data: userProfile, isLoading: isUserProfileLoading } =
useUserProfile()
const { showSuccess, showError } = useNotification()
const { showError, showSuccess } = useNotification()
const { impersonatedUser } = useImpersonateUser()
const queryClient = useQueryClient()
const unArchiveChore = useUnArchiveChore()
@@ -200,11 +201,11 @@ const ArchivedTasks = () => {
)
const {
filteredData: finalChores,
activeFilters,
setFilter,
clearAll,
filteredData: finalChores,
hasActiveFilters,
setFilter,
} = useFilter(filteredChores, filterDefs)
useEffect(() => {
@@ -253,13 +254,6 @@ const ArchivedTasks = () => {
setShowKeyboardShortcuts(true)
}
// Ctrl/Cmd + F to focus search input
if (isHoldingCmdOrCtrl && event.key === 'f') {
event.preventDefault()
searchInputRef.current?.focus()
return
}
// Ctrl/Cmd + S Toggle Multi-select mode
if (isHoldingCmdOrCtrl && event.key === 's') {
event.preventDefault()

View File

@@ -411,14 +411,29 @@ const MyChores = () => {
}
}, [searchInputFocus])
// A global-search result can hand a query back to the task list as a scoped filter.
useEffect(() => {
const query = searchParams.get('search')
if (query !== null) {
setSearchTerm(query.toLowerCase())
setSelectedCalendarDate(null)
clearActiveFilter()
clearQuickFilters()
}
// The setters above are intentionally applied only when URL search params change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams])
// 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 (projectIdFromUrl && projectIdFromUrl !== String(selectedProject?.id)) {
const project = projectsWithDefault.find(
p => String(p.id) === projectIdFromUrl,
)
if (project) {
setSelectedProjectWithCache(project)
}
@@ -759,6 +774,11 @@ const MyChores = () => {
setFilteredChores(selectedProject ? projectFilteredChores : chores)
setSearchInputFocus(0)
setSelectedCalendarDate(null)
if (searchParams.has('search')) {
const params = new URLSearchParams(searchParams)
params.delete('search')
setSearchParams(params, { replace: true })
}
}
const setSelectedChoreSectionWithCache = value => {

View File

@@ -1,21 +1,33 @@
import { CancelRounded } from '@mui/icons-material'
import { CancelRounded, SearchRounded } from '@mui/icons-material'
import { Box, Input } from '@mui/joy'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import { useGlobalSearch } from '../../../search/GlobalSearchContext'
const SearchBar = ({
value,
inputRef,
onChange,
onClose,
onFocus,
showKeyboardShortcuts,
inputRef,
value,
}) => {
const { openSearch } = useGlobalSearch()
const handleOpen = () => {
onFocus?.()
openSearch(value)
}
return (
<Input
slotProps={{ input: { ref: inputRef } }}
placeholder='Search'
slotProps={{ input: { ref: inputRef, readOnly: true } }}
placeholder='Search Donetick'
value={value}
onFocus={onFocus}
onFocus={handleOpen}
onMouseDown={event => {
event.preventDefault()
handleOpen()
}}
fullWidth
sx={{
mt: 1,
@@ -24,17 +36,29 @@ const SearchBar = ({
height: 24,
borderColor: 'text.disabled',
padding: 1,
cursor: 'pointer',
'& input': { cursor: 'pointer' },
}}
onChange={onChange}
startDecorator={
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<SearchRounded sx={{ fontSize: 18, color: 'text.secondary' }} />
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
</Box>
}
endDecorator={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{value && (
<>
<KeyboardShortcutHint shortcut='X' show={showKeyboardShortcuts} />
<CancelRounded onClick={onClose} />
<CancelRounded
aria-label='Clear task search'
onMouseDown={event => event.stopPropagation()}
onClick={event => {
event.stopPropagation()
onClose()
}}
/>
</>
)}
</Box>

View File

@@ -1,15 +1,15 @@
import { useState, useEffect } from 'react'
import { useEffect, useState } from 'react'
export const useKeyboardShortcuts = ({
isMultiSelectMode,
selectedChores,
addTaskModalOpen,
searchTerm,
searchFilter,
filteredChores,
choreSections,
openChoreSections,
filteredChores,
handlers,
isMultiSelectMode,
openChoreSections,
searchFilter,
searchTerm,
selectedChores,
}) => {
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
@@ -35,10 +35,6 @@ export const useKeyboardShortcuts = ({
event.preventDefault()
handlers.onNavigateToCreate()
return
} else if (isHoldingCmdOrCtrl && event.key === 'f') {
event.preventDefault()
handlers.onFocusSearch()
return
} else if (isHoldingCmdOrCtrl && event.key === 'x') {
event.preventDefault()
if (searchTerm?.length > 0) {

View File

@@ -9,6 +9,7 @@ import {
ListAlt,
Logout,
MenuRounded,
SearchRounded,
SettingsOutlined,
Toll,
Widgets,
@@ -23,30 +24,36 @@ import {
ListItemDecorator,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { version } from '../../../package.json'
import UserProfileAvatar from '../../components/UserProfileAvatar'
import Z_INDEX from '../../constants/zIndex'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useResource } from '../../queries/ResourceQueries'
import { useGlobalSearch } from '../../search/GlobalSearchContext'
import { apiClient } from '../../utils/ApiClient'
import NavBarLink from './NavBarLink'
import SyncStatusIndicator from './SyncStatusIndicator'
import Z_INDEX from '../../constants/zIndex'
import { useResource } from '../../queries/ResourceQueries'
import { apiClient } from '../../utils/ApiClient'
const publicPages = ['/landing', '/privacy', '/terms']
const NavBar = () => {
const { t } = useTranslation('common')
const { isRTL } = useLocalization()
const { data: resource } = useResource()
const { openSearch } = useGlobalSearch()
const navigate = useNavigate()
const [drawerOpen, setDrawerOpen] = useState(false)
const links = [
{
label: t('navigation.search'),
icon: <SearchRounded />,
onClick: () => openSearch(),
},
{
to: '/chores',
label: t('navigation.allTasks'),
@@ -105,6 +112,22 @@ const NavBar = () => {
<MenuRounded />
</IconButton>
)
if (location.pathname === '/search') {
return (
<IconButton
size='md'
variant='plain'
onClick={() => {
if (window.history.state?.idx > 0) navigate(-1)
else navigate('/chores', { replace: true })
}}
aria-label='Back from search'
title={t('back')}
>
<ArrowBack />
</IconButton>
)
}
if (!Capacitor.isNativePlatform()) {
return menuRounded
}

View File

@@ -7,13 +7,12 @@ import {
import { Link } from 'react-router-dom'
const NavBarLink = ({ link }) => {
const { to, icon, label } = link
const { to, icon, label, onClick } = link
return (
<ListItem>
<ListItemButton
key={to}
component={Link}
to={to}
{...(onClick ? { onClick } : { component: Link, to })}
variant='plain'
color='neutral'
sx={{