feat: implement client-side state management hooks
- Created useSearch hook for search state and debouncing - Created useFilters hook for filter state management - Created useOptimisticUpdate hook for optimistic UI updates - Created useScrollPosition hook for scroll preservation - Updated SearchBar, GlobalSearch, and ListDetailView to use new hooks - Added comprehensive documentation for all hooks Validates: Requirements 6.4, 7.3, 7.4, 8.3 Task: 9. Implement client-side state management
This commit is contained in:
@@ -4,6 +4,7 @@ import { useState, useCallback } from 'react'
|
||||
import SearchBar from './SearchBar'
|
||||
import SearchResults from './SearchResults'
|
||||
import { searchItems } from '@/lib/actions'
|
||||
import { useSearch } from '@/hooks'
|
||||
|
||||
interface ItemWithList {
|
||||
id: string
|
||||
@@ -22,13 +23,10 @@ interface ItemWithList {
|
||||
}
|
||||
|
||||
export default function GlobalSearch() {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<ItemWithList[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
|
||||
const handleSearch = useCallback(async (query: string) => {
|
||||
setSearchQuery(query)
|
||||
|
||||
if (!query.trim()) {
|
||||
setSearchResults([])
|
||||
setIsSearching(false)
|
||||
@@ -47,10 +45,14 @@ export default function GlobalSearch() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { debouncedQuery, clearSearch } = useSearch({
|
||||
onSearch: handleSearch
|
||||
})
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSearchQuery('')
|
||||
clearSearch()
|
||||
setSearchResults([])
|
||||
}, [])
|
||||
}, [clearSearch])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -59,10 +61,10 @@ export default function GlobalSearch() {
|
||||
placeholder="Search all tasks..."
|
||||
/>
|
||||
|
||||
{searchQuery.trim() && (
|
||||
{debouncedQuery.trim() && (
|
||||
<SearchResults
|
||||
results={searchResults}
|
||||
query={searchQuery}
|
||||
query={debouncedQuery}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react'
|
||||
import ItemCard from './ItemCard'
|
||||
import SearchBar from './SearchBar'
|
||||
import FilterPanel, { FilterState } from './FilterPanel'
|
||||
import FilterPanel from './FilterPanel'
|
||||
import SearchResults from './SearchResults'
|
||||
import { searchItems, getItemsByFilter } from '@/lib/actions'
|
||||
import { useSearch, useFilters, useScrollPosition } from '@/hooks'
|
||||
|
||||
interface Item {
|
||||
id: string
|
||||
@@ -35,21 +36,13 @@ interface ListDetailViewProps {
|
||||
}
|
||||
|
||||
export default function ListDetailView({ listId, initialItems }: ListDetailViewProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<ItemWithList[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
priority: null,
|
||||
completed: null,
|
||||
tags: [],
|
||||
dueDateRange: { start: null, end: null },
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'desc'
|
||||
})
|
||||
|
||||
const [filteredItems, setFilteredItems] = useState<Item[]>(initialItems)
|
||||
|
||||
// Use scroll position preservation hook
|
||||
useScrollPosition({ key: `list-${listId}` })
|
||||
|
||||
// Extract all unique tags from items
|
||||
const availableTags = useMemo(() => {
|
||||
const tagSet = new Set<string>()
|
||||
@@ -64,10 +57,8 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP
|
||||
return Array.from(tagSet).sort()
|
||||
}, [initialItems])
|
||||
|
||||
// Handle search
|
||||
const handleSearch = useCallback(async (query: string) => {
|
||||
setSearchQuery(query)
|
||||
|
||||
// Use search hook
|
||||
const handleSearchCallback = useCallback(async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([])
|
||||
setIsSearching(false)
|
||||
@@ -81,13 +72,17 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP
|
||||
} catch (error) {
|
||||
console.error('Search error:', error)
|
||||
setSearchResults([])
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}, [listId])
|
||||
|
||||
// Handle filter changes
|
||||
const handleFiltersChange = useCallback(async (newFilters: FilterState) => {
|
||||
setFilters(newFilters)
|
||||
|
||||
const { debouncedQuery } = useSearch({
|
||||
onSearch: handleSearchCallback
|
||||
})
|
||||
|
||||
// Use filters hook
|
||||
const handleFiltersCallback = useCallback(async (newFilters: any) => {
|
||||
try {
|
||||
const results = await getItemsByFilter(listId, {
|
||||
priority: newFilters.priority,
|
||||
@@ -104,8 +99,17 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP
|
||||
}
|
||||
}, [listId, initialItems])
|
||||
|
||||
const { filters, setFilters } = useFilters({
|
||||
onFiltersChange: handleFiltersCallback
|
||||
})
|
||||
|
||||
// Apply initial filters on mount
|
||||
useEffect(() => {
|
||||
handleFiltersCallback(filters)
|
||||
}, []) // Only run on mount
|
||||
|
||||
// Determine which items to display
|
||||
const displayItems = searchQuery.trim() ? searchResults : filteredItems
|
||||
const displayItems = debouncedQuery.trim() ? searchResults : filteredItems
|
||||
const completedItems = displayItems.filter(item => item.completed)
|
||||
const incompleteItems = displayItems.filter(item => !item.completed)
|
||||
|
||||
@@ -113,22 +117,22 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP
|
||||
<div className="space-y-6">
|
||||
{/* Search Bar */}
|
||||
<SearchBar
|
||||
onSearch={handleSearch}
|
||||
onSearch={handleSearchCallback}
|
||||
placeholder="Search tasks in this list..."
|
||||
/>
|
||||
|
||||
{/* Filter Panel */}
|
||||
<FilterPanel
|
||||
filters={filters}
|
||||
onFiltersChange={handleFiltersChange}
|
||||
onFiltersChange={setFilters}
|
||||
availableTags={availableTags}
|
||||
/>
|
||||
|
||||
{/* Results */}
|
||||
{searchQuery.trim() ? (
|
||||
{debouncedQuery.trim() ? (
|
||||
<SearchResults
|
||||
results={searchResults}
|
||||
query={searchQuery}
|
||||
query={debouncedQuery}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { useSearch } from '@/hooks'
|
||||
|
||||
interface SearchBarProps {
|
||||
onSearch: (query: string) => void
|
||||
@@ -16,21 +16,10 @@ export default function SearchBar({
|
||||
debounceMs = 300,
|
||||
initialValue = ''
|
||||
}: SearchBarProps) {
|
||||
const [query, setQuery] = useState(initialValue)
|
||||
|
||||
// Debounced search effect
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onSearch(query)
|
||||
}, debounceMs)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [query, debounceMs, onSearch])
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setQuery('')
|
||||
onSearch('')
|
||||
}, [onSearch])
|
||||
const { query, setQuery, clearSearch, isDebouncing } = useSearch({
|
||||
debounceMs,
|
||||
onSearch
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -48,13 +37,18 @@ export default function SearchBar({
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
onClick={clearSearch}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
)}
|
||||
{isDebouncing && (
|
||||
<div className="absolute right-10 top-1/2 -translate-y-1/2">
|
||||
<div className="w-4 h-4 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user