mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-07 12:17:50 +00:00
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:
parent
b46f62ccc0
commit
bf11dec7d9
4 changed files with 288 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
200
web/src/components/board/FilterBar.tsx
Normal file
200
web/src/components/board/FilterBar.tsx
Normal 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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
36
web/src/components/ui/badge.tsx
Normal file
36
web/src/components/ui/badge.tsx
Normal 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 }
|
||||
Loading…
Add table
Reference in a new issue