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
This commit is contained in:
Brad Groux 2026-01-26 04:50:43 -06:00
parent b46f62ccc0
commit bf11dec7d9
4 changed files with 288 additions and 8 deletions

View file

@ -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

View file

@ -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<TaskType, string> = {
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<string>();
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 (
<div className="flex items-center gap-3 mb-4">
{/* Search */}
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search tasks..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-9 pr-9"
/>
{searchInput && (
<button
onClick={() => setSearchInput('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Project filter */}
<Select
value={filters.project || 'all'}
onValueChange={(value) =>
onFiltersChange({ ...filters, project: value === 'all' ? null : value })
}
>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="All Projects" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Projects</SelectItem>
{projects.map((project) => (
<SelectItem key={project} value={project}>
{project}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Type filter */}
<Select
value={filters.type || 'all'}
onValueChange={(value) =>
onFiltersChange({ ...filters, type: value === 'all' ? null : (value as TaskType) })
}
>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
{(Object.entries(typeLabels) as [TaskType, string][]).map(([type, label]) => (
<SelectItem key={type} value={type}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Active filter indicator & clear */}
{activeFilterCount > 0 && (
<div className="flex items-center gap-2">
<Badge variant="secondary" className="gap-1">
<Filter className="h-3 w-3" />
{activeFilterCount} active
</Badge>
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
className="text-muted-foreground hover:text-foreground"
>
Clear all
</Button>
</div>
)}
</div>
);
}
// 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;
});
}

View file

@ -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<Task | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
// Initialize filters from URL
const [filters, setFilters] = useState<FilterState>(() => {
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 (
<>
<FilterBar
tasks={tasks || []}
filters={filters}
onFiltersChange={setFilters}
/>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}

View file

@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }