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:
@@ -120,7 +120,7 @@
|
|||||||
- **Property 14: Overdue task identification**
|
- **Property 14: Overdue task identification**
|
||||||
- **Validates: Requirements 5.5**
|
- **Validates: Requirements 5.5**
|
||||||
|
|
||||||
- [-] 8. Add search and filter UI components
|
- [x] 8. Add search and filter UI components
|
||||||
- Create SearchBar component with debounced input
|
- Create SearchBar component with debounced input
|
||||||
- Build FilterPanel component with controls for priority, completion, tags, and due date
|
- Build FilterPanel component with controls for priority, completion, tags, and due date
|
||||||
- Add sort controls (field and direction)
|
- Add sort controls (field and direction)
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
- **Property 23: Search clear restoration**
|
- **Property 23: Search clear restoration**
|
||||||
- **Validates: Requirements 8.3**
|
- **Validates: Requirements 8.3**
|
||||||
|
|
||||||
- [ ] 9. Implement client-side state management
|
- [x] 9. Implement client-side state management
|
||||||
- Create useSearch hook for search state and debouncing
|
- Create useSearch hook for search state and debouncing
|
||||||
- Create useFilters hook for filter state management
|
- Create useFilters hook for filter state management
|
||||||
- Create useOptimisticUpdate hook for optimistic UI updates
|
- Create useOptimisticUpdate hook for optimistic UI updates
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useCallback } from 'react'
|
|||||||
import SearchBar from './SearchBar'
|
import SearchBar from './SearchBar'
|
||||||
import SearchResults from './SearchResults'
|
import SearchResults from './SearchResults'
|
||||||
import { searchItems } from '@/lib/actions'
|
import { searchItems } from '@/lib/actions'
|
||||||
|
import { useSearch } from '@/hooks'
|
||||||
|
|
||||||
interface ItemWithList {
|
interface ItemWithList {
|
||||||
id: string
|
id: string
|
||||||
@@ -22,13 +23,10 @@ interface ItemWithList {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function GlobalSearch() {
|
export default function GlobalSearch() {
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
|
||||||
const [searchResults, setSearchResults] = useState<ItemWithList[]>([])
|
const [searchResults, setSearchResults] = useState<ItemWithList[]>([])
|
||||||
const [isSearching, setIsSearching] = useState(false)
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
|
|
||||||
const handleSearch = useCallback(async (query: string) => {
|
const handleSearch = useCallback(async (query: string) => {
|
||||||
setSearchQuery(query)
|
|
||||||
|
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
setSearchResults([])
|
setSearchResults([])
|
||||||
setIsSearching(false)
|
setIsSearching(false)
|
||||||
@@ -47,10 +45,14 @@ export default function GlobalSearch() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const { debouncedQuery, clearSearch } = useSearch({
|
||||||
|
onSearch: handleSearch
|
||||||
|
})
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSearchQuery('')
|
clearSearch()
|
||||||
setSearchResults([])
|
setSearchResults([])
|
||||||
}, [])
|
}, [clearSearch])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -59,10 +61,10 @@ export default function GlobalSearch() {
|
|||||||
placeholder="Search all tasks..."
|
placeholder="Search all tasks..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{searchQuery.trim() && (
|
{debouncedQuery.trim() && (
|
||||||
<SearchResults
|
<SearchResults
|
||||||
results={searchResults}
|
results={searchResults}
|
||||||
query={searchQuery}
|
query={debouncedQuery}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from 'react'
|
import { useState, useCallback, useMemo, useEffect } from 'react'
|
||||||
import ItemCard from './ItemCard'
|
import ItemCard from './ItemCard'
|
||||||
import SearchBar from './SearchBar'
|
import SearchBar from './SearchBar'
|
||||||
import FilterPanel, { FilterState } from './FilterPanel'
|
import FilterPanel from './FilterPanel'
|
||||||
import SearchResults from './SearchResults'
|
import SearchResults from './SearchResults'
|
||||||
import { searchItems, getItemsByFilter } from '@/lib/actions'
|
import { searchItems, getItemsByFilter } from '@/lib/actions'
|
||||||
|
import { useSearch, useFilters, useScrollPosition } from '@/hooks'
|
||||||
|
|
||||||
interface Item {
|
interface Item {
|
||||||
id: string
|
id: string
|
||||||
@@ -35,21 +36,13 @@ interface ListDetailViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ListDetailView({ listId, initialItems }: ListDetailViewProps) {
|
export default function ListDetailView({ listId, initialItems }: ListDetailViewProps) {
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
|
||||||
const [searchResults, setSearchResults] = useState<ItemWithList[]>([])
|
const [searchResults, setSearchResults] = useState<ItemWithList[]>([])
|
||||||
const [isSearching, setIsSearching] = useState(false)
|
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)
|
const [filteredItems, setFilteredItems] = useState<Item[]>(initialItems)
|
||||||
|
|
||||||
|
// Use scroll position preservation hook
|
||||||
|
useScrollPosition({ key: `list-${listId}` })
|
||||||
|
|
||||||
// Extract all unique tags from items
|
// Extract all unique tags from items
|
||||||
const availableTags = useMemo(() => {
|
const availableTags = useMemo(() => {
|
||||||
const tagSet = new Set<string>()
|
const tagSet = new Set<string>()
|
||||||
@@ -64,10 +57,8 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP
|
|||||||
return Array.from(tagSet).sort()
|
return Array.from(tagSet).sort()
|
||||||
}, [initialItems])
|
}, [initialItems])
|
||||||
|
|
||||||
// Handle search
|
// Use search hook
|
||||||
const handleSearch = useCallback(async (query: string) => {
|
const handleSearchCallback = useCallback(async (query: string) => {
|
||||||
setSearchQuery(query)
|
|
||||||
|
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
setSearchResults([])
|
setSearchResults([])
|
||||||
setIsSearching(false)
|
setIsSearching(false)
|
||||||
@@ -81,13 +72,17 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search error:', error)
|
console.error('Search error:', error)
|
||||||
setSearchResults([])
|
setSearchResults([])
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false)
|
||||||
}
|
}
|
||||||
}, [listId])
|
}, [listId])
|
||||||
|
|
||||||
// Handle filter changes
|
const { debouncedQuery } = useSearch({
|
||||||
const handleFiltersChange = useCallback(async (newFilters: FilterState) => {
|
onSearch: handleSearchCallback
|
||||||
setFilters(newFilters)
|
})
|
||||||
|
|
||||||
|
// Use filters hook
|
||||||
|
const handleFiltersCallback = useCallback(async (newFilters: any) => {
|
||||||
try {
|
try {
|
||||||
const results = await getItemsByFilter(listId, {
|
const results = await getItemsByFilter(listId, {
|
||||||
priority: newFilters.priority,
|
priority: newFilters.priority,
|
||||||
@@ -104,8 +99,17 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP
|
|||||||
}
|
}
|
||||||
}, [listId, initialItems])
|
}, [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
|
// 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 completedItems = displayItems.filter(item => item.completed)
|
||||||
const incompleteItems = 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">
|
<div className="space-y-6">
|
||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
<SearchBar
|
<SearchBar
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearchCallback}
|
||||||
placeholder="Search tasks in this list..."
|
placeholder="Search tasks in this list..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Filter Panel */}
|
{/* Filter Panel */}
|
||||||
<FilterPanel
|
<FilterPanel
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onFiltersChange={handleFiltersChange}
|
onFiltersChange={setFilters}
|
||||||
availableTags={availableTags}
|
availableTags={availableTags}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
{searchQuery.trim() ? (
|
{debouncedQuery.trim() ? (
|
||||||
<SearchResults
|
<SearchResults
|
||||||
results={searchResults}
|
results={searchResults}
|
||||||
query={searchQuery}
|
query={debouncedQuery}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
|
||||||
import { Search, X } from 'lucide-react'
|
import { Search, X } from 'lucide-react'
|
||||||
|
import { useSearch } from '@/hooks'
|
||||||
|
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
onSearch: (query: string) => void
|
onSearch: (query: string) => void
|
||||||
@@ -16,21 +16,10 @@ export default function SearchBar({
|
|||||||
debounceMs = 300,
|
debounceMs = 300,
|
||||||
initialValue = ''
|
initialValue = ''
|
||||||
}: SearchBarProps) {
|
}: SearchBarProps) {
|
||||||
const [query, setQuery] = useState(initialValue)
|
const { query, setQuery, clearSearch, isDebouncing } = useSearch({
|
||||||
|
debounceMs,
|
||||||
// Debounced search effect
|
onSearch
|
||||||
useEffect(() => {
|
})
|
||||||
const timer = setTimeout(() => {
|
|
||||||
onSearch(query)
|
|
||||||
}, debounceMs)
|
|
||||||
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}, [query, debounceMs, onSearch])
|
|
||||||
|
|
||||||
const handleClear = useCallback(() => {
|
|
||||||
setQuery('')
|
|
||||||
onSearch('')
|
|
||||||
}, [onSearch])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -48,13 +37,18 @@ export default function SearchBar({
|
|||||||
/>
|
/>
|
||||||
{query && (
|
{query && (
|
||||||
<button
|
<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"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
232
hooks/README.md
Normal file
232
hooks/README.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# Custom Hooks Documentation
|
||||||
|
|
||||||
|
This directory contains custom React hooks for client-side state management in the Ultimate Lists App.
|
||||||
|
|
||||||
|
## Hooks Overview
|
||||||
|
|
||||||
|
### `useSearch`
|
||||||
|
|
||||||
|
Manages search state with debouncing functionality.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Automatic debouncing of search queries
|
||||||
|
- Immediate clear functionality
|
||||||
|
- Tracks debouncing state
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```typescript
|
||||||
|
import { useSearch } from '@/hooks'
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { query, setQuery, debouncedQuery, isDebouncing, clearSearch } = useSearch({
|
||||||
|
debounceMs: 300,
|
||||||
|
onSearch: async (query) => {
|
||||||
|
// Perform search with debounced query
|
||||||
|
const results = await searchItems(query)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `useFilters`
|
||||||
|
|
||||||
|
Manages filter state for sorting and filtering items.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Centralized filter state management
|
||||||
|
- Detects active filters
|
||||||
|
- Easy filter clearing
|
||||||
|
- Individual filter updates
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```typescript
|
||||||
|
import { useFilters } from '@/hooks'
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { filters, setFilters, updateFilter, clearFilters, hasActiveFilters } = useFilters({
|
||||||
|
initialFilters: { sortBy: 'createdAt' },
|
||||||
|
onFiltersChange: async (filters) => {
|
||||||
|
// Apply filters
|
||||||
|
const results = await getItemsByFilter(listId, filters)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={() => updateFilter('priority', 'high')}>
|
||||||
|
High Priority
|
||||||
|
</button>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button onClick={clearFilters}>Clear Filters</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `useOptimisticUpdate`
|
||||||
|
|
||||||
|
Provides optimistic UI updates for better perceived performance.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Immediate UI feedback
|
||||||
|
- Automatic rollback on error
|
||||||
|
- Pending state tracking
|
||||||
|
- Error handling
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```typescript
|
||||||
|
import { useOptimisticUpdate } from '@/hooks'
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { optimisticData, isPending, error, performUpdate } = useOptimisticUpdate({
|
||||||
|
onUpdate: async (data) => {
|
||||||
|
await updateItem(data.id, data)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Update failed:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleToggle = async () => {
|
||||||
|
await performUpdate({ id: '123', completed: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={handleToggle} disabled={isPending}>
|
||||||
|
{isPending ? 'Updating...' : 'Toggle'}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `useOptimisticList`
|
||||||
|
|
||||||
|
Specialized hook for managing lists with optimistic updates.
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```typescript
|
||||||
|
import { useOptimisticList } from '@/hooks'
|
||||||
|
|
||||||
|
function MyComponent({ initialItems }) {
|
||||||
|
const { optimisticList, isPending, addItem, updateItem, removeItem } = useOptimisticList(
|
||||||
|
initialItems,
|
||||||
|
async (updatedList) => {
|
||||||
|
// Sync with server
|
||||||
|
await syncList(updatedList)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{optimisticList.map(item => (
|
||||||
|
<div key={item.id}>
|
||||||
|
{item.title}
|
||||||
|
<button onClick={() => removeItem(item.id)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `useScrollPosition`
|
||||||
|
|
||||||
|
Preserves scroll position across navigation for better UX.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Automatic scroll position saving
|
||||||
|
- Restoration on component mount
|
||||||
|
- Debounced save operations
|
||||||
|
- SessionStorage-based persistence
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```typescript
|
||||||
|
import { useScrollPosition } from '@/hooks'
|
||||||
|
|
||||||
|
function MyComponent({ listId }) {
|
||||||
|
// Automatically saves and restores scroll position
|
||||||
|
useScrollPosition({
|
||||||
|
key: `list-${listId}`,
|
||||||
|
enabled: true,
|
||||||
|
debounceMs: 100
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Your scrollable content */}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `useContainerScrollPosition`
|
||||||
|
|
||||||
|
Similar to `useScrollPosition` but for specific container elements.
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```typescript
|
||||||
|
import { useContainerScrollPosition } from '@/hooks'
|
||||||
|
|
||||||
|
function MyComponent({ listId }) {
|
||||||
|
const { containerRef } = useContainerScrollPosition({
|
||||||
|
key: `container-${listId}`
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} style={{ height: '400px', overflow: 'auto' }}>
|
||||||
|
{/* Your scrollable content */}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements Validation
|
||||||
|
|
||||||
|
These hooks implement the following requirements from the specification:
|
||||||
|
|
||||||
|
- **Requirement 6.4**: Filter state management and clearing
|
||||||
|
- **Requirement 7.3**: Visual feedback for user actions (optimistic updates)
|
||||||
|
- **Requirement 7.4**: Scroll position preservation
|
||||||
|
- **Requirement 8.3**: Search clearing and restoration
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Search Debouncing**: Use appropriate debounce times (300ms is a good default)
|
||||||
|
2. **Filter State**: Keep filter state in sync with URL params for shareable links
|
||||||
|
3. **Optimistic Updates**: Always provide rollback on error
|
||||||
|
4. **Scroll Position**: Use unique keys for different views to avoid conflicts
|
||||||
|
5. **Error Handling**: Always handle errors in async callbacks
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
These hooks are designed to be testable with React Testing Library:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { renderHook, act } from '@testing-library/react'
|
||||||
|
import { useSearch } from '@/hooks'
|
||||||
|
|
||||||
|
test('useSearch debounces queries', async () => {
|
||||||
|
const onSearch = jest.fn()
|
||||||
|
const { result } = renderHook(() => useSearch({ onSearch, debounceMs: 100 }))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setQuery('test')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(onSearch).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 150))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(onSearch).toHaveBeenCalledWith('test')
|
||||||
|
})
|
||||||
|
```
|
||||||
10
hooks/index.ts
Normal file
10
hooks/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Custom hooks for client-side state management
|
||||||
|
* Implements task 9 from the Ultimate Lists App specification
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { useSearch } from './useSearch'
|
||||||
|
export { useFilters } from './useFilters'
|
||||||
|
export type { FilterState } from './useFilters'
|
||||||
|
export { useOptimisticUpdate, useOptimisticList } from './useOptimisticUpdate'
|
||||||
|
export { useScrollPosition, useContainerScrollPosition } from './useScrollPosition'
|
||||||
97
hooks/useFilters.ts
Normal file
97
hooks/useFilters.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useCallback, useMemo } from 'react'
|
||||||
|
|
||||||
|
export interface FilterState {
|
||||||
|
priority: 'low' | 'medium' | 'high' | null
|
||||||
|
completed: boolean | null
|
||||||
|
tags: string[]
|
||||||
|
dueDateRange: { start: Date | null; end: Date | null }
|
||||||
|
sortBy: 'createdAt' | 'dueDate' | 'priority' | 'title'
|
||||||
|
sortOrder: 'asc' | 'desc'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseFiltersOptions {
|
||||||
|
initialFilters?: Partial<FilterState>
|
||||||
|
onFiltersChange?: (filters: FilterState) => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseFiltersReturn {
|
||||||
|
filters: FilterState
|
||||||
|
setFilters: (filters: FilterState) => void
|
||||||
|
updateFilter: <K extends keyof FilterState>(key: K, value: FilterState[K]) => void
|
||||||
|
clearFilters: () => void
|
||||||
|
hasActiveFilters: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FILTERS: FilterState = {
|
||||||
|
priority: null,
|
||||||
|
completed: null,
|
||||||
|
tags: [],
|
||||||
|
dueDateRange: { start: null, end: null },
|
||||||
|
sortBy: 'createdAt',
|
||||||
|
sortOrder: 'desc'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for managing filter state
|
||||||
|
* Validates: Requirements 6.4
|
||||||
|
*/
|
||||||
|
export function useFilters(options: UseFiltersOptions = {}): UseFiltersReturn {
|
||||||
|
const { initialFilters = {}, onFiltersChange } = options
|
||||||
|
|
||||||
|
const [filters, setFiltersState] = useState<FilterState>({
|
||||||
|
...DEFAULT_FILTERS,
|
||||||
|
...initialFilters
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if any filters are active (excluding sort options)
|
||||||
|
const hasActiveFilters = useMemo(() => {
|
||||||
|
return (
|
||||||
|
filters.priority !== null ||
|
||||||
|
filters.completed !== null ||
|
||||||
|
filters.tags.length > 0 ||
|
||||||
|
filters.dueDateRange.start !== null ||
|
||||||
|
filters.dueDateRange.end !== null
|
||||||
|
)
|
||||||
|
}, [filters])
|
||||||
|
|
||||||
|
// Set filters with callback
|
||||||
|
const setFilters = useCallback((newFilters: FilterState) => {
|
||||||
|
setFiltersState(newFilters)
|
||||||
|
if (onFiltersChange) {
|
||||||
|
onFiltersChange(newFilters)
|
||||||
|
}
|
||||||
|
}, [onFiltersChange])
|
||||||
|
|
||||||
|
// Update a single filter property
|
||||||
|
const updateFilter = useCallback(<K extends keyof FilterState>(
|
||||||
|
key: K,
|
||||||
|
value: FilterState[K]
|
||||||
|
) => {
|
||||||
|
setFiltersState(prev => {
|
||||||
|
const newFilters = { ...prev, [key]: value }
|
||||||
|
if (onFiltersChange) {
|
||||||
|
onFiltersChange(newFilters)
|
||||||
|
}
|
||||||
|
return newFilters
|
||||||
|
})
|
||||||
|
}, [onFiltersChange])
|
||||||
|
|
||||||
|
// Clear all filters (reset to defaults)
|
||||||
|
const clearFilters = useCallback(() => {
|
||||||
|
const clearedFilters = { ...DEFAULT_FILTERS }
|
||||||
|
setFiltersState(clearedFilters)
|
||||||
|
if (onFiltersChange) {
|
||||||
|
onFiltersChange(clearedFilters)
|
||||||
|
}
|
||||||
|
}, [onFiltersChange])
|
||||||
|
|
||||||
|
return {
|
||||||
|
filters,
|
||||||
|
setFilters,
|
||||||
|
updateFilter,
|
||||||
|
clearFilters,
|
||||||
|
hasActiveFilters
|
||||||
|
}
|
||||||
|
}
|
||||||
138
hooks/useOptimisticUpdate.ts
Normal file
138
hooks/useOptimisticUpdate.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useCallback, useTransition } from 'react'
|
||||||
|
|
||||||
|
interface UseOptimisticUpdateOptions<T> {
|
||||||
|
onUpdate: (data: T) => Promise<void>
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseOptimisticUpdateReturn<T> {
|
||||||
|
optimisticData: T | null
|
||||||
|
isPending: boolean
|
||||||
|
error: Error | null
|
||||||
|
performUpdate: (data: T) => Promise<void>
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for optimistic UI updates
|
||||||
|
* Provides immediate UI feedback while server action is processing
|
||||||
|
* Validates: Requirements 7.3
|
||||||
|
*/
|
||||||
|
export function useOptimisticUpdate<T>(
|
||||||
|
options: UseOptimisticUpdateOptions<T>
|
||||||
|
): UseOptimisticUpdateReturn<T> {
|
||||||
|
const { onUpdate, onError } = options
|
||||||
|
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
const [optimisticData, setOptimisticData] = useState<T | null>(null)
|
||||||
|
const [error, setError] = useState<Error | null>(null)
|
||||||
|
|
||||||
|
const performUpdate = useCallback(async (data: T) => {
|
||||||
|
// Clear any previous errors
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
// Set optimistic data immediately for instant UI feedback
|
||||||
|
setOptimisticData(data)
|
||||||
|
|
||||||
|
// Perform the actual update in a transition
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await onUpdate(data)
|
||||||
|
// Clear optimistic data after successful update
|
||||||
|
setOptimisticData(null)
|
||||||
|
} catch (err) {
|
||||||
|
const error = err instanceof Error ? err : new Error('Update failed')
|
||||||
|
setError(error)
|
||||||
|
// Clear optimistic data on error
|
||||||
|
setOptimisticData(null)
|
||||||
|
|
||||||
|
if (onError) {
|
||||||
|
onError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [onUpdate, onError])
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setOptimisticData(null)
|
||||||
|
setError(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
optimisticData,
|
||||||
|
isPending,
|
||||||
|
error,
|
||||||
|
performUpdate,
|
||||||
|
reset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simplified hook for optimistic list updates
|
||||||
|
* Useful for managing lists of items with optimistic updates
|
||||||
|
*/
|
||||||
|
export function useOptimisticList<T extends { id: string }>(
|
||||||
|
initialData: T[],
|
||||||
|
onUpdate: (data: T[]) => Promise<void>
|
||||||
|
) {
|
||||||
|
const [optimisticList, setOptimisticList] = useState<T[]>(initialData)
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
const addItem = useCallback((item: T) => {
|
||||||
|
setOptimisticList(prev => [...prev, item])
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await onUpdate([...optimisticList, item])
|
||||||
|
} catch (error) {
|
||||||
|
// Rollback on error
|
||||||
|
setOptimisticList(initialData)
|
||||||
|
console.error('Failed to add item:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [optimisticList, initialData, onUpdate])
|
||||||
|
|
||||||
|
const updateItem = useCallback((id: string, updates: Partial<T>) => {
|
||||||
|
setOptimisticList(prev =>
|
||||||
|
prev.map(item => (item.id === id ? { ...item, ...updates } : item))
|
||||||
|
)
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const updatedList = optimisticList.map(item =>
|
||||||
|
item.id === id ? { ...item, ...updates } : item
|
||||||
|
)
|
||||||
|
await onUpdate(updatedList)
|
||||||
|
} catch (error) {
|
||||||
|
// Rollback on error
|
||||||
|
setOptimisticList(initialData)
|
||||||
|
console.error('Failed to update item:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [optimisticList, initialData, onUpdate])
|
||||||
|
|
||||||
|
const removeItem = useCallback((id: string) => {
|
||||||
|
setOptimisticList(prev => prev.filter(item => item.id !== id))
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const updatedList = optimisticList.filter(item => item.id !== id)
|
||||||
|
await onUpdate(updatedList)
|
||||||
|
} catch (error) {
|
||||||
|
// Rollback on error
|
||||||
|
setOptimisticList(initialData)
|
||||||
|
console.error('Failed to remove item:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [optimisticList, initialData, onUpdate])
|
||||||
|
|
||||||
|
return {
|
||||||
|
optimisticList,
|
||||||
|
isPending,
|
||||||
|
addItem,
|
||||||
|
updateItem,
|
||||||
|
removeItem
|
||||||
|
}
|
||||||
|
}
|
||||||
208
hooks/useScrollPosition.ts
Normal file
208
hooks/useScrollPosition.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
|
|
||||||
|
interface UseScrollPositionOptions {
|
||||||
|
key: string
|
||||||
|
enabled?: boolean
|
||||||
|
debounceMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for preserving scroll position across navigation
|
||||||
|
* Validates: Requirements 7.4
|
||||||
|
*/
|
||||||
|
export function useScrollPosition(options: UseScrollPositionOptions) {
|
||||||
|
const { key, enabled = true, debounceMs = 100 } = options
|
||||||
|
|
||||||
|
const scrollPositionRef = useRef<number>(0)
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
const isRestoringRef = useRef(false)
|
||||||
|
|
||||||
|
// Save scroll position to sessionStorage
|
||||||
|
const saveScrollPosition = useCallback((position: number) => {
|
||||||
|
if (!enabled) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(`scroll-${key}`, position.toString())
|
||||||
|
scrollPositionRef.current = position
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to save scroll position:', error)
|
||||||
|
}
|
||||||
|
}, [key, enabled])
|
||||||
|
|
||||||
|
// Restore scroll position from sessionStorage
|
||||||
|
const restoreScrollPosition = useCallback(() => {
|
||||||
|
if (!enabled) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const savedPosition = sessionStorage.getItem(`scroll-${key}`)
|
||||||
|
if (savedPosition !== null) {
|
||||||
|
const position = parseInt(savedPosition, 10)
|
||||||
|
if (!isNaN(position)) {
|
||||||
|
isRestoringRef.current = true
|
||||||
|
window.scrollTo(0, position)
|
||||||
|
scrollPositionRef.current = position
|
||||||
|
|
||||||
|
// Reset flag after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
isRestoringRef.current = false
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to restore scroll position:', error)
|
||||||
|
}
|
||||||
|
}, [key, enabled])
|
||||||
|
|
||||||
|
// Clear saved scroll position
|
||||||
|
const clearScrollPosition = useCallback(() => {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(`scroll-${key}`)
|
||||||
|
scrollPositionRef.current = 0
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to clear scroll position:', error)
|
||||||
|
}
|
||||||
|
}, [key])
|
||||||
|
|
||||||
|
// Handle scroll events with debouncing
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
// Don't save position if we're currently restoring
|
||||||
|
if (isRestoringRef.current) return
|
||||||
|
|
||||||
|
// Clear existing timeout
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce the save operation
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
const position = window.scrollY || window.pageYOffset
|
||||||
|
saveScrollPosition(position)
|
||||||
|
}, debounceMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleScroll)
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [enabled, debounceMs, saveScrollPosition])
|
||||||
|
|
||||||
|
// Restore scroll position on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled) {
|
||||||
|
// Use requestAnimationFrame to ensure DOM is ready
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
restoreScrollPosition()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [enabled, restoreScrollPosition])
|
||||||
|
|
||||||
|
return {
|
||||||
|
saveScrollPosition,
|
||||||
|
restoreScrollPosition,
|
||||||
|
clearScrollPosition,
|
||||||
|
currentPosition: scrollPositionRef.current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for preserving scroll position within a specific container element
|
||||||
|
*/
|
||||||
|
export function useContainerScrollPosition(options: UseScrollPositionOptions) {
|
||||||
|
const { key, enabled = true, debounceMs = 100 } = options
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLElement | null>(null)
|
||||||
|
const scrollPositionRef = useRef<number>(0)
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
const isRestoringRef = useRef(false)
|
||||||
|
|
||||||
|
// Save scroll position
|
||||||
|
const saveScrollPosition = useCallback((position: number) => {
|
||||||
|
if (!enabled) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(`container-scroll-${key}`, position.toString())
|
||||||
|
scrollPositionRef.current = position
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to save container scroll position:', error)
|
||||||
|
}
|
||||||
|
}, [key, enabled])
|
||||||
|
|
||||||
|
// Restore scroll position
|
||||||
|
const restoreScrollPosition = useCallback(() => {
|
||||||
|
if (!enabled || !containerRef.current) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const savedPosition = sessionStorage.getItem(`container-scroll-${key}`)
|
||||||
|
if (savedPosition !== null) {
|
||||||
|
const position = parseInt(savedPosition, 10)
|
||||||
|
if (!isNaN(position)) {
|
||||||
|
isRestoringRef.current = true
|
||||||
|
containerRef.current.scrollTop = position
|
||||||
|
scrollPositionRef.current = position
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
isRestoringRef.current = false
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to restore container scroll position:', error)
|
||||||
|
}
|
||||||
|
}, [key, enabled])
|
||||||
|
|
||||||
|
// Clear saved scroll position
|
||||||
|
const clearScrollPosition = useCallback(() => {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(`container-scroll-${key}`)
|
||||||
|
scrollPositionRef.current = 0
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to clear container scroll position:', error)
|
||||||
|
}
|
||||||
|
}, [key])
|
||||||
|
|
||||||
|
// Set up scroll listener on container
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !containerRef.current) return
|
||||||
|
|
||||||
|
const container = containerRef.current
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (isRestoringRef.current) return
|
||||||
|
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
const position = container.scrollTop
|
||||||
|
saveScrollPosition(position)
|
||||||
|
}, debounceMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener('scroll', handleScroll)
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [enabled, debounceMs, saveScrollPosition])
|
||||||
|
|
||||||
|
return {
|
||||||
|
containerRef,
|
||||||
|
saveScrollPosition,
|
||||||
|
restoreScrollPosition,
|
||||||
|
clearScrollPosition,
|
||||||
|
currentPosition: scrollPositionRef.current
|
||||||
|
}
|
||||||
|
}
|
||||||
84
hooks/useSearch.ts
Normal file
84
hooks/useSearch.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
|
||||||
|
interface UseSearchOptions {
|
||||||
|
debounceMs?: number
|
||||||
|
onSearch?: (query: string) => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseSearchReturn {
|
||||||
|
query: string
|
||||||
|
setQuery: (query: string) => void
|
||||||
|
debouncedQuery: string
|
||||||
|
isDebouncing: boolean
|
||||||
|
clearSearch: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for managing search state with debouncing
|
||||||
|
* Validates: Requirements 6.4, 8.3
|
||||||
|
*/
|
||||||
|
export function useSearch(options: UseSearchOptions = {}): UseSearchReturn {
|
||||||
|
const { debounceMs = 300, onSearch } = options
|
||||||
|
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [debouncedQuery, setDebouncedQuery] = useState('')
|
||||||
|
const [isDebouncing, setIsDebouncing] = useState(false)
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
|
// Debounce the query
|
||||||
|
useEffect(() => {
|
||||||
|
// If query is empty, update immediately
|
||||||
|
if (!query.trim()) {
|
||||||
|
setDebouncedQuery('')
|
||||||
|
setIsDebouncing(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDebouncing(true)
|
||||||
|
|
||||||
|
// Clear existing timeout
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new timeout
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setDebouncedQuery(query)
|
||||||
|
setIsDebouncing(false)
|
||||||
|
}, debounceMs)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [query, debounceMs])
|
||||||
|
|
||||||
|
// Call onSearch callback when debounced query changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (onSearch) {
|
||||||
|
onSearch(debouncedQuery)
|
||||||
|
}
|
||||||
|
}, [debouncedQuery, onSearch])
|
||||||
|
|
||||||
|
// Clear search function
|
||||||
|
const clearSearch = useCallback(() => {
|
||||||
|
setQuery('')
|
||||||
|
setDebouncedQuery('')
|
||||||
|
setIsDebouncing(false)
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
setQuery,
|
||||||
|
debouncedQuery,
|
||||||
|
isDebouncing,
|
||||||
|
clearSearch
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user