From bf11dec7d93c0edbd1339cf13d0bb473ce669837 Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Mon, 26 Jan 2026 04:50:43 -0600 Subject: [PATCH] feat(US-602): filter and search - Add FilterBar component with search, project, and type filters - Debounced search (300ms) on title + description - Project dropdown auto-populated from tasks - Type dropdown with icons - Active filter count badge - URL sync for filter persistence - Add Badge UI component --- docs/sprint-6.md | 11 +- web/src/components/board/FilterBar.tsx | 200 +++++++++++++++++++++++ web/src/components/board/KanbanBoard.tsx | 49 +++++- web/src/components/ui/badge.tsx | 36 ++++ 4 files changed, 288 insertions(+), 8 deletions(-) create mode 100644 web/src/components/board/FilterBar.tsx create mode 100644 web/src/components/ui/badge.tsx diff --git a/docs/sprint-6.md b/docs/sprint-6.md index 6c7ef54b..848e619d 100644 --- a/docs/sprint-6.md +++ b/docs/sprint-6.md @@ -12,7 +12,7 @@ | ID | Title | Status | Notes | |----|-------|--------|-------| | US-601 | Keyboard shortcuts | ✅ Complete | c, j/k, Enter, Esc, 1-4, ? | -| US-602 | Filter and search | ⏳ Todo | Project/type dropdowns, text search | +| US-602 | Filter and search | ✅ Complete | Project/type dropdowns, text search | | US-603 | Task templates | ⏳ Todo | New from Template option | | US-604 | Bulk actions | ⏳ Todo | Multi-select, bulk status/archive/delete | | US-605 | Activity log | ⏳ Todo | Toggleable sidebar | @@ -35,3 +35,12 @@ - Added keyboard icon to header - Created `KeyboardShortcutsDialog` help modal - Task selection highlighting with ring indicator + +**US-602: Filter and search** ✅ +- Created FilterBar component above board +- Search input with debounce (300ms) +- Project filter dropdown (auto-populated from tasks) +- Type filter dropdown (code/research/content/automation) +- Active filter count badge with "Clear all" button +- URL persistence (filters sync to query params) +- Added Badge UI component diff --git a/web/src/components/board/FilterBar.tsx b/web/src/components/board/FilterBar.tsx new file mode 100644 index 00000000..d87b15bc --- /dev/null +++ b/web/src/components/board/FilterBar.tsx @@ -0,0 +1,200 @@ +import { useState, useEffect, useMemo } from 'react'; +import { Search, X, Filter } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Badge } from '@/components/ui/badge'; +import type { Task, TaskType } from '@veritas-kanban/shared'; + +export interface FilterState { + search: string; + project: string | null; + type: TaskType | null; +} + +interface FilterBarProps { + tasks: Task[]; + filters: FilterState; + onFiltersChange: (filters: FilterState) => void; +} + +const typeLabels: Record = { + code: '💻 Code', + research: '🔍 Research', + content: '📝 Content', + automation: '⚡ Automation', +}; + +export function FilterBar({ tasks, filters, onFiltersChange }: FilterBarProps) { + const [searchInput, setSearchInput] = useState(filters.search); + + // Debounce search input + useEffect(() => { + const timer = setTimeout(() => { + if (searchInput !== filters.search) { + onFiltersChange({ ...filters, search: searchInput }); + } + }, 300); + return () => clearTimeout(timer); + }, [searchInput, filters, onFiltersChange]); + + // Sync search input with filters (e.g., when cleared externally) + useEffect(() => { + if (filters.search !== searchInput) { + setSearchInput(filters.search); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filters.search]); + + // Get unique projects from tasks + const projects = useMemo(() => { + const projectSet = new Set(); + tasks.forEach(task => { + if (task.project) { + projectSet.add(task.project); + } + }); + return Array.from(projectSet).sort(); + }, [tasks]); + + // Count active filters + const activeFilterCount = [ + filters.search, + filters.project, + filters.type, + ].filter(Boolean).length; + + const clearAllFilters = () => { + setSearchInput(''); + onFiltersChange({ search: '', project: null, type: null }); + }; + + return ( +
+ {/* Search */} +
+ + setSearchInput(e.target.value)} + className="pl-9 pr-9" + /> + {searchInput && ( + + )} +
+ + {/* Project filter */} + + + {/* Type filter */} + + + {/* Active filter indicator & clear */} + {activeFilterCount > 0 && ( +
+ + + {activeFilterCount} active + + +
+ )} +
+ ); +} + +// URL sync helpers +export function filtersToSearchParams(filters: FilterState): URLSearchParams { + const params = new URLSearchParams(); + if (filters.search) params.set('q', filters.search); + if (filters.project) params.set('project', filters.project); + if (filters.type) params.set('type', filters.type); + return params; +} + +export function searchParamsToFilters(params: URLSearchParams): FilterState { + return { + search: params.get('q') || '', + project: params.get('project') || null, + type: (params.get('type') as TaskType) || null, + }; +} + +// Filter function +export function filterTasks(tasks: Task[], filters: FilterState): Task[] { + return tasks.filter((task) => { + // Search filter (title + description) + if (filters.search) { + const searchLower = filters.search.toLowerCase(); + const titleMatch = task.title.toLowerCase().includes(searchLower); + const descMatch = task.description?.toLowerCase().includes(searchLower); + if (!titleMatch && !descMatch) return false; + } + + // Project filter + if (filters.project && task.project !== filters.project) { + return false; + } + + // Type filter + if (filters.type && task.type !== filters.type) { + return false; + } + + return true; + }); +} diff --git a/web/src/components/board/KanbanBoard.tsx b/web/src/components/board/KanbanBoard.tsx index b15940ff..974bfceb 100644 --- a/web/src/components/board/KanbanBoard.tsx +++ b/web/src/components/board/KanbanBoard.tsx @@ -13,9 +13,16 @@ import { useSensor, useSensors, } from '@dnd-kit/core'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { TaskCard } from '@/components/task/TaskCard'; import { useKeyboard } from '@/hooks/useKeyboard'; +import { + FilterBar, + type FilterState, + filterTasks, + filtersToSearchParams, + searchParamsToFilters, +} from './FilterBar'; const COLUMNS: { id: TaskStatus; title: string }[] = [ { id: 'todo', title: 'To Do' }, @@ -61,12 +68,19 @@ function LoadingSkeleton() { export function KanbanBoard() { const { data: tasks, isLoading, error } = useTasks(); - const tasksByStatus = useTasksByStatus(tasks); const updateTask = useUpdateTask(); const [activeTask, setActiveTask] = useState(null); const [selectedTask, setSelectedTask] = useState(null); const [detailOpen, setDetailOpen] = useState(false); + // Initialize filters from URL + const [filters, setFilters] = useState(() => { + if (typeof window !== 'undefined') { + return searchParamsToFilters(new URLSearchParams(window.location.search)); + } + return { search: '', project: null, type: null }; + }); + const { selectedTaskId, setTasks, @@ -74,12 +88,27 @@ export function KanbanBoard() { setOnMoveTask, } = useKeyboard(); - // Register tasks with keyboard context + // Sync filters to URL useEffect(() => { - if (tasks) { - setTasks(tasks); - } - }, [tasks, setTasks]); + const params = filtersToSearchParams(filters); + const newUrl = params.toString() + ? `${window.location.pathname}?${params.toString()}` + : window.location.pathname; + window.history.replaceState({}, '', newUrl); + }, [filters]); + + // Filter tasks + const filteredTasks = useMemo(() => { + return tasks ? filterTasks(tasks, filters) : []; + }, [tasks, filters]); + + // Group filtered tasks by status + const tasksByStatus = useTasksByStatus(filteredTasks); + + // Register filtered tasks with keyboard context + useEffect(() => { + setTasks(filteredTasks); + }, [filteredTasks, setTasks]); // Handler for opening a task const handleTaskClick = useCallback((task: Task) => { @@ -168,6 +197,12 @@ export function KanbanBoard() { return ( <> + + , + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants }