From 0143097e69dc01da5ae9b76752c19675f2ff9784 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Mon, 1 Dec 2025 19:25:09 +0000 Subject: [PATCH] feat: Add search and filter UI components - Created SearchBar component with debounced input - Built FilterPanel component with controls for priority, completion, tags, and due date - Added sort controls (field and direction) - Implemented filter clear functionality - Display search results with highlighted text - Added GlobalSearch component for home page - Integrated search and filter into list detail view - Added CSS styles for search highlighting Requirements: 6.1, 6.2, 6.3, 6.4, 8.1, 8.2, 8.3 --- .kiro/specs/ultimate-lists-app/tasks.md | 2 +- app/globals.css | 11 +- app/list/[id]/page.tsx | 45 +--- app/page.tsx | 6 + components/FilterPanel.tsx | 295 ++++++++++++++++++++++++ components/GlobalSearch.tsx | 71 ++++++ components/ListDetailView.tsx | 178 ++++++++++++++ components/SearchBar.tsx | 61 +++++ components/SearchResults.tsx | 155 +++++++++++++ 9 files changed, 781 insertions(+), 43 deletions(-) create mode 100644 components/FilterPanel.tsx create mode 100644 components/GlobalSearch.tsx create mode 100644 components/ListDetailView.tsx create mode 100644 components/SearchBar.tsx create mode 100644 components/SearchResults.tsx diff --git a/.kiro/specs/ultimate-lists-app/tasks.md b/.kiro/specs/ultimate-lists-app/tasks.md index 9d32e6e..14c2eae 100644 --- a/.kiro/specs/ultimate-lists-app/tasks.md +++ b/.kiro/specs/ultimate-lists-app/tasks.md @@ -120,7 +120,7 @@ - **Property 14: Overdue task identification** - **Validates: Requirements 5.5** -- [ ] 8. Add search and filter UI components +- [-] 8. Add search and filter UI components - Create SearchBar component with debounced input - Build FilterPanel component with controls for priority, completion, tags, and due date - Add sort controls (field and direction) diff --git a/app/globals.css b/app/globals.css index bd6213e..8136de0 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,3 +1,12 @@ @tailwind base; @tailwind components; -@tailwind utilities; \ No newline at end of file +@tailwind utilities; + +/* Search highlight styles */ +mark { + background-color: #fef08a; + color: inherit; + padding: 0 2px; + border-radius: 2px; + font-weight: 500; +} \ No newline at end of file diff --git a/app/list/[id]/page.tsx b/app/list/[id]/page.tsx index 92fda00..381bb93 100644 --- a/app/list/[id]/page.tsx +++ b/app/list/[id]/page.tsx @@ -1,9 +1,9 @@ import { getListWithItems } from '@/lib/actions' import { notFound } from 'next/navigation' import Link from 'next/link' -import { ArrowLeft, Plus } from 'lucide-react' -import ItemCard from '@/components/ItemCard' +import { ArrowLeft } from 'lucide-react' import InlineItemCreate from '@/components/InlineItemCreate' +import ListDetailView from '@/components/ListDetailView' export default async function ListDetailPage({ params }: { params: { id: string } }) { const list = await getListWithItems(params.id) @@ -13,7 +13,6 @@ export default async function ListDetailPage({ params }: { params: { id: string } const completedItems = list.items.filter(item => item.completed) - const incompleteItems = list.items.filter(item => !item.completed) return (
@@ -45,44 +44,8 @@ export default async function ListDetailPage({ params }: { params: { id: string - {/* Items List */} -
- {/* Incomplete Items */} - {incompleteItems.length > 0 && ( -
-

- To Do ({incompleteItems.length}) -

-
- {incompleteItems.map((item) => ( - - ))} -
-
- )} - - {/* Completed Items */} - {completedItems.length > 0 && ( -
-

- Completed ({completedItems.length}) -

-
- {completedItems.map((item) => ( - - ))} -
-
- )} - - {/* Empty State */} - {list.items.length === 0 && ( -
-

No tasks yet

-

Add your first task to get started

-
- )} -
+ {/* Search, Filter, and Items List */} +
) } diff --git a/app/page.tsx b/app/page.tsx index eadc93f..253fd80 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,7 @@ import { getLists } from '@/lib/actions' import ListCard from '@/components/ListCard' import NewListButton from '@/components/NewListButton' +import GlobalSearch from '@/components/GlobalSearch' export default async function Home() { const lists = await getLists() @@ -12,6 +13,11 @@ export default async function Home() { + {/* Global Search */} +
+ +
+
{lists.map((list) => ( diff --git a/components/FilterPanel.tsx b/components/FilterPanel.tsx new file mode 100644 index 0000000..7b79fde --- /dev/null +++ b/components/FilterPanel.tsx @@ -0,0 +1,295 @@ +'use client' + +import { useState } from 'react' +import { Filter, X, ChevronDown, ChevronUp } from 'lucide-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 FilterPanelProps { + filters: FilterState + onFiltersChange: (filters: FilterState) => void + availableTags?: string[] + showListScope?: boolean +} + +export default function FilterPanel({ + filters, + onFiltersChange, + availableTags = [], + showListScope = false +}: FilterPanelProps) { + const [isExpanded, setIsExpanded] = useState(false) + + const hasActiveFilters = + filters.priority !== null || + filters.completed !== null || + filters.tags.length > 0 || + filters.dueDateRange.start !== null || + filters.dueDateRange.end !== null + + const handlePriorityChange = (priority: 'low' | 'medium' | 'high' | null) => { + onFiltersChange({ ...filters, priority }) + } + + const handleCompletedChange = (completed: boolean | null) => { + onFiltersChange({ ...filters, completed }) + } + + const handleTagToggle = (tag: string) => { + const newTags = filters.tags.includes(tag) + ? filters.tags.filter(t => t !== tag) + : [...filters.tags, tag] + onFiltersChange({ ...filters, tags: newTags }) + } + + const handleDueDateStartChange = (date: string) => { + const start = date ? new Date(date) : null + onFiltersChange({ + ...filters, + dueDateRange: { ...filters.dueDateRange, start } + }) + } + + const handleDueDateEndChange = (date: string) => { + const end = date ? new Date(date) : null + onFiltersChange({ + ...filters, + dueDateRange: { ...filters.dueDateRange, end } + }) + } + + const handleSortByChange = (sortBy: FilterState['sortBy']) => { + onFiltersChange({ ...filters, sortBy }) + } + + const handleSortOrderToggle = () => { + onFiltersChange({ + ...filters, + sortOrder: filters.sortOrder === 'asc' ? 'desc' : 'asc' + }) + } + + const handleClearFilters = () => { + onFiltersChange({ + priority: null, + completed: null, + tags: [], + dueDateRange: { start: null, end: null }, + sortBy: 'createdAt', + sortOrder: 'desc' + }) + } + + return ( +
+ {/* Header */} + + + {/* Filter Controls */} + {isExpanded && ( +
+ {/* Sort Controls */} +
+ +
+ + +
+
+ + {/* Priority Filter */} +
+ +
+ + + + +
+
+ + {/* Completion Status Filter */} +
+ +
+ + + +
+
+ + {/* Tags Filter */} + {availableTags.length > 0 && ( +
+ +
+ {availableTags.map((tag) => ( + + ))} +
+
+ )} + + {/* Due Date Range Filter */} +
+ +
+
+ + handleDueDateStartChange(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + handleDueDateEndChange(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+
+ + {/* Clear Filters Button */} + {hasActiveFilters && ( + + )} +
+ )} +
+ ) +} diff --git a/components/GlobalSearch.tsx b/components/GlobalSearch.tsx new file mode 100644 index 0000000..d2f2572 --- /dev/null +++ b/components/GlobalSearch.tsx @@ -0,0 +1,71 @@ +'use client' + +import { useState, useCallback } from 'react' +import SearchBar from './SearchBar' +import SearchResults from './SearchResults' +import { searchItems } from '@/lib/actions' + +interface ItemWithList { + id: string + title: string + description: string | null + completed: boolean + priority: string + dueDate: Date | null + tags: any + notes: string | null + list: { + id: string + title: string + color: string + } +} + +export default function GlobalSearch() { + const [searchQuery, setSearchQuery] = useState('') + const [searchResults, setSearchResults] = useState([]) + const [isSearching, setIsSearching] = useState(false) + + const handleSearch = useCallback(async (query: string) => { + setSearchQuery(query) + + if (!query.trim()) { + setSearchResults([]) + setIsSearching(false) + return + } + + setIsSearching(true) + try { + const results = await searchItems(query) + setSearchResults(results as ItemWithList[]) + } catch (error) { + console.error('Search error:', error) + setSearchResults([]) + } finally { + setIsSearching(false) + } + }, []) + + const handleClose = useCallback(() => { + setSearchQuery('') + setSearchResults([]) + }, []) + + return ( +
+ + + {searchQuery.trim() && ( + + )} +
+ ) +} diff --git a/components/ListDetailView.tsx b/components/ListDetailView.tsx new file mode 100644 index 0000000..4ed74cf --- /dev/null +++ b/components/ListDetailView.tsx @@ -0,0 +1,178 @@ +'use client' + +import { useState, useCallback, useMemo } from 'react' +import ItemCard from './ItemCard' +import SearchBar from './SearchBar' +import FilterPanel, { FilterState } from './FilterPanel' +import SearchResults from './SearchResults' +import { searchItems, getItemsByFilter } from '@/lib/actions' + +interface Item { + id: string + title: string + description: string | null + completed: boolean + priority: string + dueDate: Date | null + tags: any + notes: string | null + createdAt: Date + updatedAt: Date + listId: string +} + +interface ItemWithList extends Item { + list: { + id: string + title: string + color: string + } +} + +interface ListDetailViewProps { + listId: string + initialItems: Item[] +} + +export default function ListDetailView({ listId, initialItems }: ListDetailViewProps) { + const [searchQuery, setSearchQuery] = useState('') + const [searchResults, setSearchResults] = useState([]) + const [isSearching, setIsSearching] = useState(false) + + const [filters, setFilters] = useState({ + priority: null, + completed: null, + tags: [], + dueDateRange: { start: null, end: null }, + sortBy: 'createdAt', + sortOrder: 'desc' + }) + + const [filteredItems, setFilteredItems] = useState(initialItems) + + // Extract all unique tags from items + const availableTags = useMemo(() => { + const tagSet = new Set() + initialItems.forEach(item => { + const tags = Array.isArray(item.tags) ? item.tags : [] + tags.forEach(tag => { + if (typeof tag === 'string') { + tagSet.add(tag) + } + }) + }) + return Array.from(tagSet).sort() + }, [initialItems]) + + // Handle search + const handleSearch = useCallback(async (query: string) => { + setSearchQuery(query) + + if (!query.trim()) { + setSearchResults([]) + setIsSearching(false) + return + } + + setIsSearching(true) + try { + const results = await searchItems(query, listId) + setSearchResults(results as ItemWithList[]) + } catch (error) { + console.error('Search error:', error) + setSearchResults([]) + } + }, [listId]) + + // Handle filter changes + const handleFiltersChange = useCallback(async (newFilters: FilterState) => { + setFilters(newFilters) + + try { + const results = await getItemsByFilter(listId, { + priority: newFilters.priority, + completed: newFilters.completed, + tags: newFilters.tags, + dueDateRange: newFilters.dueDateRange, + sortBy: newFilters.sortBy, + sortOrder: newFilters.sortOrder + }) + setFilteredItems(results as Item[]) + } catch (error) { + console.error('Filter error:', error) + setFilteredItems(initialItems) + } + }, [listId, initialItems]) + + // Determine which items to display + const displayItems = searchQuery.trim() ? searchResults : filteredItems + const completedItems = displayItems.filter(item => item.completed) + const incompleteItems = displayItems.filter(item => !item.completed) + + return ( +
+ {/* Search Bar */} + + + {/* Filter Panel */} + + + {/* Results */} + {searchQuery.trim() ? ( + + ) : ( +
+ {/* Incomplete Items */} + {incompleteItems.length > 0 && ( +
+

+ To Do ({incompleteItems.length}) +

+
+ {incompleteItems.map((item) => ( + + ))} +
+
+ )} + + {/* Completed Items */} + {completedItems.length > 0 && ( +
+

+ Completed ({completedItems.length}) +

+
+ {completedItems.map((item) => ( + + ))} +
+
+ )} + + {/* Empty State */} + {displayItems.length === 0 && ( +
+

No tasks found

+

+ {filters.priority || filters.completed !== null || filters.tags.length > 0 + ? 'Try adjusting your filters' + : 'Add your first task to get started'} +

+
+ )} +
+ )} +
+ ) +} diff --git a/components/SearchBar.tsx b/components/SearchBar.tsx new file mode 100644 index 0000000..bae1850 --- /dev/null +++ b/components/SearchBar.tsx @@ -0,0 +1,61 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { Search, X } from 'lucide-react' + +interface SearchBarProps { + onSearch: (query: string) => void + placeholder?: string + debounceMs?: number + initialValue?: string +} + +export default function SearchBar({ + onSearch, + placeholder = 'Search tasks...', + 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]) + + return ( +
+
+ + setQuery(e.target.value)} + placeholder={placeholder} + className="w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> + {query && ( + + )} +
+
+ ) +} diff --git a/components/SearchResults.tsx b/components/SearchResults.tsx new file mode 100644 index 0000000..92f903c --- /dev/null +++ b/components/SearchResults.tsx @@ -0,0 +1,155 @@ +'use client' + +import { highlightText } from '@/lib/utils' +import Link from 'next/link' +import { Calendar, Tag, AlertCircle } from 'lucide-react' + +interface SearchResultsProps { + results: Array<{ + id: string + title: string + description: string | null + completed: boolean + priority: string + dueDate: Date | null + tags: any + notes: string | null + list: { + id: string + title: string + color: string + } + }> + query: string + onClose?: () => void +} + +export default function SearchResults({ results, query, onClose }: SearchResultsProps) { + if (!query.trim()) { + return null + } + + if (results.length === 0) { + return ( +
+

No tasks found matching "{query}"

+

Try a different search term

+
+ ) + } + + const priorityColors = { + low: 'bg-green-100 text-green-700 border-green-200', + medium: 'bg-yellow-100 text-yellow-700 border-yellow-200', + high: 'bg-red-100 text-red-700 border-red-200', + } + + return ( +
+
+

+ Found {results.length} {results.length === 1 ? 'task' : 'tasks'} +

+
+ + {results.map((item) => { + const tags = Array.isArray(item.tags) ? item.tags : [] + const isOverdue = item.dueDate && new Date(item.dueDate) < new Date() && !item.completed + const priorityColor = priorityColors[item.priority as keyof typeof priorityColors] || priorityColors.medium + + return ( + + {/* List Badge */} +
+
+ {item.list.title} +
+ + {/* Title with highlighting */} +

+ + {/* Description with highlighting */} + {item.description && ( +

+ )} + + {/* Notes with highlighting (if matches) */} + {item.notes && item.notes.toLowerCase().includes(query.toLowerCase()) && ( +

+ )} + + {/* Metadata */} +

+ {/* Priority Badge */} + + + {item.priority.charAt(0).toUpperCase() + item.priority.slice(1)} + + + {/* Due Date */} + {item.dueDate && ( + + + {new Date(item.dueDate).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + })} + {isOverdue && ' (Overdue)'} + + )} + + {/* Tags with highlighting */} + {tags.length > 0 && ( +
+ + + {tags.length > 2 && ( + + {` +${tags.length - 2}`} + + )} +
+ )} +
+ + ) + })} +

+ ) +}