Implement global search feature with context, UI components, and routing updates
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
"logout": "Logout",
|
||||
"version": "Version",
|
||||
"navigation": {
|
||||
"search": "Search",
|
||||
"allTasks": "All Tasks",
|
||||
"archived": "Archived",
|
||||
"things": "Things",
|
||||
|
||||
@@ -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'
|
||||
@@ -145,7 +146,9 @@ function App() {
|
||||
|
||||
<AuthProvider>
|
||||
<SSEProvider>
|
||||
<AppContent />
|
||||
<GlobalSearchProvider>
|
||||
<AppContent />
|
||||
</GlobalSearchProvider>
|
||||
</SSEProvider>
|
||||
</AuthProvider>
|
||||
</div>
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
192
src/search/GlobalSearchContext.jsx
Normal file
192
src/search/GlobalSearchContext.jsx
Normal 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
|
||||
}
|
||||
32
src/search/GlobalSearchPage.jsx
Normal file
32
src/search/GlobalSearchPage.jsx
Normal 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
|
||||
463
src/search/GlobalSearchPalette.jsx
Normal file
463
src/search/GlobalSearchPalette.jsx
Normal file
@@ -0,0 +1,463 @@
|
||||
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 & activity',
|
||||
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}
|
||||
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 fuse = useMemo(
|
||||
() =>
|
||||
new Fuse(documents, {
|
||||
threshold: 0.38,
|
||||
distance: 120,
|
||||
ignoreLocation: true,
|
||||
includeScore: true,
|
||||
keys: [
|
||||
{ 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,
|
||||
)
|
||||
return [...recentResults, ...QUICK_ACTIONS]
|
||||
}
|
||||
|
||||
const matches = fuse
|
||||
.search(normalized, { limit: 60 })
|
||||
.map(match => {
|
||||
const title = match.item.title?.toLocaleLowerCase() ?? ''
|
||||
let score = match.score ?? 1
|
||||
|
||||
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)
|
||||
|
||||
const byGroup = new Map(GROUPS.map(group => [group, []]))
|
||||
matches.forEach(item => {
|
||||
const group = byGroup.get(item.provider)
|
||||
if (group && group.length < 7) group.push(item)
|
||||
})
|
||||
const grouped = GROUPS.flatMap(group => byGroup.get(group))
|
||||
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, fuse, query, recents])
|
||||
|
||||
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}
|
||||
onMouseEnter={() => 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
|
||||
178
src/search/searchProviders.js
Normal file
178
src/search/searchProviders.js
Normal file
@@ -0,0 +1,178 @@
|
||||
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.map(entry => {
|
||||
const chore = choresById.get(String(entry.choreId))
|
||||
const member = membersById.get(String(entry.completedBy))
|
||||
const note = stripHtml(entry.notes)
|
||||
return document('history', {
|
||||
id: `history:${entry.id}`,
|
||||
entityId: entry.id,
|
||||
title: chore?.name || entry.choreName || 'Task activity',
|
||||
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}`,
|
||||
}),
|
||||
),
|
||||
})
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
Reference in New Issue
Block a user