diff --git a/app/(dashboard)/tasks/actions.ts b/app/(dashboard)/tasks/actions.ts index fbc4e1c..582f9ae 100644 --- a/app/(dashboard)/tasks/actions.ts +++ b/app/(dashboard)/tasks/actions.ts @@ -59,6 +59,61 @@ export async function getAllTasks() { } } +// Get filtered tasks with search and filter options +export async function getFilteredTasks(filters: { + search?: string; + status?: string[]; + priority?: string[]; + assigneeId?: number; +}) { + try { + const where: { + OR?: Array<{ name: { contains: string; mode: "insensitive" } } | { description: { contains: string; mode: "insensitive" } }>; + status?: { in: string[] }; + priority?: { in: string[] }; + assigneeId?: number; + } = {}; + + // Add search filter (case-insensitive search on name and description) + if (filters.search) { + where.OR = [ + { name: { contains: filters.search, mode: "insensitive" } }, + { description: { contains: filters.search, mode: "insensitive" } }, + ]; + } + + // Add status filter + if (filters.status && filters.status.length > 0) { + where.status = { in: filters.status }; + } + + // Add priority filter + if (filters.priority && filters.priority.length > 0) { + where.priority = { in: filters.priority }; + } + + // Add assignee filter + if (filters.assigneeId) { + where.assigneeId = filters.assigneeId; + } + + const tasks = await prisma.task.findMany({ + where, + include: { + assignee: { select: { id: true, name: true, email: true } }, + creator: { select: { id: true, name: true, email: true } }, + }, + orderBy: [ + { createdAt: "desc" }, + { id: "desc" } + ], + }); + return { tasks, error: null }; + } catch { + return { tasks: [], error: "Failed to fetch filtered tasks." }; + } +} + // Delete a task by ID export async function deleteTask(taskId: number) { try { diff --git a/app/(dashboard)/tasks/page.tsx b/app/(dashboard)/tasks/page.tsx index bda261d..44a8267 100644 --- a/app/(dashboard)/tasks/page.tsx +++ b/app/(dashboard)/tasks/page.tsx @@ -2,7 +2,7 @@ import { Suspense } from "react" import { Button } from "@/components/ui/button" import { Plus } from "lucide-react" import Link from "next/link" -import { TaskList } from "@/components/task-list" +import { TasksPageClient } from "@/components/tasks-page-with-filters" import { poppins } from "@/lib/fonts" import { getAllTasks } from "@/app/(dashboard)/tasks/actions" @@ -30,7 +30,7 @@ export default async function TasksPage() { Loading tasks...}> - + ) diff --git a/components/task-filters.tsx b/components/task-filters.tsx new file mode 100644 index 0000000..4b9e6ec --- /dev/null +++ b/components/task-filters.tsx @@ -0,0 +1,174 @@ +"use client" + +import { useState, useEffect } from "react" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Search, X } from "lucide-react" +import { getAllUsers } from "@/app/login/actions" +import type { User } from "@/app/generated/prisma/client" + +interface TaskFiltersProps { + onFilterChange: (filters: { + search: string; + status: string[]; + priority: string[]; + assigneeId: number | undefined; + }) => void; +} + +const STATUS_OPTIONS = [ + { value: "todo", label: "Todo" }, + { value: "in_progress", label: "In Progress" }, + { value: "review", label: "Review" }, + { value: "done", label: "Done" }, +] + +const PRIORITY_OPTIONS = [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, +] + +export function TaskFilters({ onFilterChange }: TaskFiltersProps) { + const [search, setSearch] = useState("") + const [selectedStatuses, setSelectedStatuses] = useState([]) + const [selectedPriorities, setSelectedPriorities] = useState([]) + const [selectedAssignee, setSelectedAssignee] = useState(undefined) + const [users, setUsers] = useState[]>([]) + + useEffect(() => { + // Fetch users for assignee filter + getAllUsers() + .then(setUsers) + .catch((error) => { + console.error("Failed to load users:", error) + setUsers([]) + }) + }, []) + + useEffect(() => { + // Notify parent component when filters change + // Note: onFilterChange should be wrapped in useCallback in parent component + onFilterChange({ + search, + status: selectedStatuses, + priority: selectedPriorities, + assigneeId: selectedAssignee, + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [search, selectedStatuses, selectedPriorities, selectedAssignee]) + + const toggleStatus = (status: string) => { + setSelectedStatuses(prev => + prev.includes(status) + ? prev.filter(s => s !== status) + : [...prev, status] + ) + } + + const togglePriority = (priority: string) => { + setSelectedPriorities(prev => + prev.includes(priority) + ? prev.filter(p => p !== priority) + : [...prev, priority] + ) + } + + const clearFilters = () => { + setSearch("") + setSelectedStatuses([]) + setSelectedPriorities([]) + setSelectedAssignee(undefined) + } + + const hasActiveFilters = search || selectedStatuses.length > 0 || selectedPriorities.length > 0 || selectedAssignee + + return ( +
+
+

Filters

+ {hasActiveFilters && ( + + )} +
+ +
+ {/* Search Input */} +
+ +
+ + setSearch(e.target.value)} + className="pl-10" + /> +
+
+ + {/* Status Filter */} +
+ +
+ {STATUS_OPTIONS.map((status) => ( + toggleStatus(status.value)} + > + {status.label} + + ))} +
+
+ + {/* Priority Filter */} +
+ +
+ {PRIORITY_OPTIONS.map((priority) => ( + togglePriority(priority.value)} + > + {priority.label} + + ))} +
+
+ + {/* Assignee Filter */} +
+ + +
+
+
+ ) +} diff --git a/components/tasks-page-with-filters.tsx b/components/tasks-page-with-filters.tsx new file mode 100644 index 0000000..42c148c --- /dev/null +++ b/components/tasks-page-with-filters.tsx @@ -0,0 +1,62 @@ +"use client" + +import { useState, useCallback } from "react" +import { TaskList } from "@/components/task-list" +import { TaskFilters } from "@/components/task-filters" +import { getFilteredTasks } from "@/app/(dashboard)/tasks/actions" +import type { Task as PrismaTask, User } from "@/app/generated/prisma/client" + +type TaskWithProfile = PrismaTask & { + assignee?: Pick | null; +} + +interface TasksPageClientProps { + initialTasks: TaskWithProfile[] +} + +export function TasksPageClient({ initialTasks }: TasksPageClientProps) { + const [tasks, setTasks] = useState(initialTasks) + const [isLoading, setIsLoading] = useState(false) + + const handleFilterChange = useCallback(async (filters: { + search: string; + status: string[]; + priority: string[]; + assigneeId: number | undefined; + }) => { + setIsLoading(true) + try { + const { tasks: filteredTasks, error } = await getFilteredTasks({ + search: filters.search || undefined, + status: filters.status.length > 0 ? filters.status : undefined, + priority: filters.priority.length > 0 ? filters.priority : undefined, + assigneeId: filters.assigneeId, + }) + + if (!error && filteredTasks) { + setTasks(filteredTasks) + } + } catch (err) { + console.error("Error filtering tasks:", err) + } finally { + setIsLoading(false) + } + }, []) + + return ( +
+
+ +
+
+ {isLoading ? ( +
+

Loading tasks...

+
+ ) : ( + + )} +
+
+ ) +} diff --git a/package-lock.json b/package-lock.json index e3ba769..b08e317 100644 --- a/package-lock.json +++ b/package-lock.json @@ -115,7 +115,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2071,7 +2070,6 @@ "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright": "1.56.1" }, @@ -3312,6 +3310,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -3321,7 +3320,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", @@ -3411,7 +3411,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3660,7 +3661,6 @@ "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3671,7 +3671,6 @@ "integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -3759,7 +3758,6 @@ "integrity": "sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.39.0", "@typescript-eslint/types": "8.39.0", @@ -4285,7 +4283,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4871,7 +4868,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -5749,6 +5745,7 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -6178,7 +6175,6 @@ "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -6353,7 +6349,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8104,7 +8099,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -9575,6 +9569,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -10363,7 +10358,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -10699,6 +10693,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -10714,6 +10709,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -10726,7 +10722,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/prisma": { "version": "6.13.0", @@ -10735,7 +10732,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/config": "6.13.0", "@prisma/engines": "6.13.0" @@ -10880,7 +10876,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -10892,8 +10887,7 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-redux": { "version": "9.2.0", @@ -11084,8 +11078,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -12082,7 +12075,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12370,7 +12362,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/tests/e2e/task-filters.spec.ts b/tests/e2e/task-filters.spec.ts new file mode 100644 index 0000000..82ba393 --- /dev/null +++ b/tests/e2e/task-filters.spec.ts @@ -0,0 +1,121 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Task Search and Filter', () => { + test.beforeEach(async ({ page }) => { + // Navigate to tasks page + await page.goto('/tasks'); + // Wait for tasks to load + await expect(page.locator('h2', { hasText: 'Tasks' })).toBeVisible(); + }); + + test('displays filter panel', async ({ page }) => { + // Check that filter panel is visible + await expect(page.locator('h3', { hasText: 'Filters' })).toBeVisible(); + + // Check that search input is visible + await expect(page.locator('input#search')).toBeVisible(); + + // Check that status filters are visible + await expect(page.locator('text=Status')).toBeVisible(); + await expect(page.locator('text=Todo')).toBeVisible(); + await expect(page.locator('text=In Progress')).toBeVisible(); + + // Check that priority filters are visible + await expect(page.locator('text=Priority')).toBeVisible(); + await expect(page.locator('text=Low')).toBeVisible(); + await expect(page.locator('text=Medium')).toBeVisible(); + await expect(page.locator('text=High')).toBeVisible(); + }); + + test('filters tasks by search term', async ({ page }) => { + // Get initial task count + const initialCards = await page.locator('[data-testid^="task-card-"]').count(); + expect(initialCards).toBeGreaterThan(0); + + // Type in search box - search for a common word that should exist + await page.fill('input#search', 'task'); + + // Wait a bit for debounce/filtering + await page.waitForTimeout(500); + + // Tasks should still be visible (assuming there are tasks with "task" in name/description) + const filteredCards = await page.locator('[data-testid^="task-card-"]').count(); + expect(filteredCards).toBeGreaterThanOrEqual(0); + }); + + test('filters tasks by status', async ({ page }) => { + // Get initial task count + const initialCards = await page.locator('[data-testid^="task-card-"]').count(); + expect(initialCards).toBeGreaterThan(0); + + // Click on "Done" status badge + const doneBadge = page.locator('div').filter({ hasText: /^Status$/ }).locator('..').locator('text=Done').first(); + await doneBadge.click(); + + // Wait for filtering + await page.waitForTimeout(500); + + // Verify that only tasks with "Done" status are shown or no tasks if none exist + const visibleCards = await page.locator('[data-testid^="task-card-"]').count(); + expect(visibleCards).toBeGreaterThanOrEqual(0); + }); + + test('filters tasks by priority', async ({ page }) => { + // Get initial task count + const initialCards = await page.locator('[data-testid^="task-card-"]').count(); + expect(initialCards).toBeGreaterThan(0); + + // Click on "High" priority badge + const highBadge = page.locator('div').filter({ hasText: /^Priority$/ }).locator('..').locator('text=High').first(); + await highBadge.click(); + + // Wait for filtering + await page.waitForTimeout(500); + + // Verify filtering occurred + const visibleCards = await page.locator('[data-testid^="task-card-"]').count(); + expect(visibleCards).toBeGreaterThanOrEqual(0); + }); + + test('clears all filters', async ({ page }) => { + // Apply some filters + await page.fill('input#search', 'test'); + const todoBadge = page.locator('div').filter({ hasText: /^Status$/ }).locator('..').locator('text=Todo').first(); + await todoBadge.click(); + + // Wait for filtering + await page.waitForTimeout(500); + + // Click "Clear All" button + const clearButton = page.locator('button', { hasText: 'Clear All' }); + if (await clearButton.isVisible()) { + await clearButton.click(); + + // Wait for clearing + await page.waitForTimeout(500); + + // Verify search input is cleared + await expect(page.locator('input#search')).toHaveValue(''); + } + }); + + test('combines multiple filters', async ({ page }) => { + // Apply search filter + await page.fill('input#search', 'implement'); + + // Click on status filter + const todoBadge = page.locator('div').filter({ hasText: /^Status$/ }).locator('..').locator('text=Todo').first(); + await todoBadge.click(); + + // Click on priority filter + const highBadge = page.locator('div').filter({ hasText: /^Priority$/ }).locator('..').locator('text=High').first(); + await highBadge.click(); + + // Wait for filtering + await page.waitForTimeout(500); + + // Verify that filtering occurred (may result in 0 tasks if no match) + const visibleCards = await page.locator('[data-testid^="task-card-"]').count(); + expect(visibleCards).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/tests/unit/task-filters.test.tsx b/tests/unit/task-filters.test.tsx new file mode 100644 index 0000000..ec856ab --- /dev/null +++ b/tests/unit/task-filters.test.tsx @@ -0,0 +1,160 @@ +// Mock server actions +jest.mock('@/app/login/actions', () => ({ + getAllUsers: jest.fn(async () => [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' } + ]) +})) + +import React from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { TaskFilters } from '@/components/task-filters' + +describe('TaskFilters', () => { + const mockOnFilterChange = jest.fn() + + beforeEach(() => { + mockOnFilterChange.mockClear() + }) + + test('renders all filter controls', async () => { + render() + + // Check for filter sections + expect(screen.getByText('Filters')).toBeInTheDocument() + expect(screen.getByLabelText(/search/i)).toBeInTheDocument() + expect(screen.getByText('Status')).toBeInTheDocument() + expect(screen.getByText('Priority')).toBeInTheDocument() + expect(screen.getByText('Assignee')).toBeInTheDocument() + + // Check for status badges + expect(screen.getByText('Todo')).toBeInTheDocument() + expect(screen.getByText('In Progress')).toBeInTheDocument() + expect(screen.getByText('Review')).toBeInTheDocument() + expect(screen.getByText('Done')).toBeInTheDocument() + + // Check for priority badges + expect(screen.getByText('Low')).toBeInTheDocument() + expect(screen.getByText('Medium')).toBeInTheDocument() + expect(screen.getByText('High')).toBeInTheDocument() + }) + + test('calls onFilterChange when search input changes', async () => { + const user = userEvent.setup() + render() + + const searchInput = screen.getByLabelText(/search/i) + await user.type(searchInput, 'test task') + + await waitFor(() => { + expect(mockOnFilterChange).toHaveBeenCalled() + }) + + // Check that the last call includes the search term + const lastCall = mockOnFilterChange.mock.calls[mockOnFilterChange.mock.calls.length - 1][0] + expect(lastCall.search).toBe('test task') + }) + + test('toggles status filter when badge is clicked', async () => { + const user = userEvent.setup() + render() + + const todoBadge = screen.getByText('Todo') + await user.click(todoBadge) + + await waitFor(() => { + const lastCall = mockOnFilterChange.mock.calls[mockOnFilterChange.mock.calls.length - 1][0] + expect(lastCall.status).toContain('todo') + }) + + // Click again to deselect + await user.click(todoBadge) + + await waitFor(() => { + const lastCall = mockOnFilterChange.mock.calls[mockOnFilterChange.mock.calls.length - 1][0] + expect(lastCall.status).not.toContain('todo') + }) + }) + + test('toggles priority filter when badge is clicked', async () => { + const user = userEvent.setup() + render() + + const highBadge = screen.getByText('High') + await user.click(highBadge) + + await waitFor(() => { + const lastCall = mockOnFilterChange.mock.calls[mockOnFilterChange.mock.calls.length - 1][0] + expect(lastCall.priority).toContain('high') + }) + + // Click again to deselect + await user.click(highBadge) + + await waitFor(() => { + const lastCall = mockOnFilterChange.mock.calls[mockOnFilterChange.mock.calls.length - 1][0] + expect(lastCall.priority).not.toContain('high') + }) + }) + + test('allows multiple status selections', async () => { + const user = userEvent.setup() + render() + + // Select multiple statuses + await user.click(screen.getByText('Todo')) + await user.click(screen.getByText('Done')) + + await waitFor(() => { + const lastCall = mockOnFilterChange.mock.calls[mockOnFilterChange.mock.calls.length - 1][0] + expect(lastCall.status).toContain('todo') + expect(lastCall.status).toContain('done') + }) + }) + + test('allows multiple priority selections', async () => { + const user = userEvent.setup() + render() + + // Select multiple priorities + await user.click(screen.getByText('High')) + await user.click(screen.getByText('Low')) + + await waitFor(() => { + const lastCall = mockOnFilterChange.mock.calls[mockOnFilterChange.mock.calls.length - 1][0] + expect(lastCall.priority).toContain('high') + expect(lastCall.priority).toContain('low') + }) + }) + + test('clears all filters when Clear All is clicked', async () => { + const user = userEvent.setup() + render() + + // Apply some filters + const searchInput = screen.getByLabelText(/search/i) + await user.type(searchInput, 'test') + await user.click(screen.getByText('Todo')) + await user.click(screen.getByText('High')) + + // Wait for filters to be applied + await waitFor(() => { + const lastCall = mockOnFilterChange.mock.calls[mockOnFilterChange.mock.calls.length - 1][0] + expect(lastCall.search).toBe('test') + }) + + // Click Clear All + const clearButton = screen.getByText(/clear all/i) + await user.click(clearButton) + + // Verify all filters are cleared + await waitFor(() => { + const lastCall = mockOnFilterChange.mock.calls[mockOnFilterChange.mock.calls.length - 1][0] + expect(lastCall.search).toBe('') + expect(lastCall.status).toHaveLength(0) + expect(lastCall.priority).toHaveLength(0) + }) + }) +})