diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fc77746d..0193ba48 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -135,6 +135,7 @@ "framer-motion": "^11.3.8", "fs.realpath": "^1.0.0", "function-bind": "^1.1.2", + "fuse.js": "^7.1.0", "gensync": "^1.0.0-beta.2", "get-caller-file": "^2.0.5", "get-nonce": "^1.0.1", @@ -7756,6 +7757,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz", + "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6645c50c..6e9efecf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -129,6 +129,7 @@ "framer-motion": "^11.3.8", "fs.realpath": "^1.0.0", "function-bind": "^1.1.2", + "fuse.js": "^7.1.0", "gensync": "^1.0.0-beta.2", "get-caller-file": "^2.0.5", "get-nonce": "^1.0.1", diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index 50bce8c8..fb6c598c 100644 --- a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx +++ b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx @@ -1,6 +1,7 @@ import { useEffect, useState, useCallback } from 'react'; import { Task } from '../../utils/types'; import { ReportsView } from './ReportsView'; +import Fuse from 'fuse.js'; import { Table, TableBody, @@ -128,6 +129,7 @@ export const Tasks = ( const [isEditingEndDate, setIsEditingEndDate] = useState(false); const [editedEndDate, setEditedEndDate] = useState(''); const [searchTerm, setSearchTerm] = useState(''); + const [debouncedTerm, setDebouncedTerm] = useState(''); const [lastSyncTime, setLastSyncTime] = useState(null); const isOverdue = (due?: string) => { @@ -153,25 +155,7 @@ export const Tasks = ( // Debounced search handler const debouncedSearch = debounce((value: string) => { - if (!value) { - setTempTasks( - selectedProjects.length === 0 && - selectedStatuses.length === 0 && - selectedTags.length === 0 - ? tasks - : tempTasks - ); - return; - } - const lowerValue = value.toLowerCase(); - const filtered = tasks.filter( - (task) => - task.description.toLowerCase().includes(lowerValue) || - (task.project && task.project.toLowerCase().includes(lowerValue)) || - (task.tags && - task.tags.some((tag) => tag.toLowerCase().includes(lowerValue))) - ); - setTempTasks(sortWithOverdueOnTop(filtered)); + setDebouncedTerm(value); setCurrentPage(1); }, 300); @@ -586,6 +570,7 @@ export const Tasks = ( }); }; + // Master filter useEffect(() => { let filteredTasks = tasks; @@ -596,7 +581,7 @@ export const Tasks = ( ); } - //Status filter + // Status filter if (selectedStatuses.length > 0) { filteredTasks = filteredTasks.filter((task) => selectedStatuses.includes(task.status) @@ -611,11 +596,25 @@ export const Tasks = ( ); } - filteredTasks = sortWithOverdueOnTop(filteredTasks); + // Fuzzy search + if (debouncedTerm.trim() !== '') { + const fuseOptions = { + keys: ['description', 'project', 'tags'], + threshold: 0.4, + ignoreLocation: true, + includeScore: false, + }; - // Sort + set + const fuse = new Fuse(filteredTasks, fuseOptions); + const results = fuse.search(debouncedTerm); + + filteredTasks = results.map((r) => r.item); + } + + // Keep overdue tasks always on top + filteredTasks = sortWithOverdueOnTop(filteredTasks); setTempTasks(filteredTasks); - }, [selectedProjects, selectedTags, selectedStatuses, tasks]); + }, [selectedProjects, selectedTags, selectedStatuses, tasks, debouncedTerm]); const handleEditTagsClick = (task: Task) => { setEditedTags(task.tags || []); diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx index 2ba866d9..e4622342 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, within } from '@testing-library/react'; +import { render, screen, fireEvent, act, within } from '@testing-library/react'; import { Tasks } from '../Tasks'; // Mock props for the Tasks component @@ -48,8 +48,8 @@ jest.mock('../hooks', () => ({ where: jest.fn(() => ({ equals: jest.fn(() => ({ // Mock 12 tasks to test pagination - toArray: jest.fn().mockResolvedValue( - Array.from({ length: 12 }, (_, i) => ({ + toArray: jest.fn().mockResolvedValue([ + ...Array.from({ length: 12 }, (_, i) => ({ id: i + 1, description: `Task ${i + 1}`, status: 'pending', @@ -57,8 +57,34 @@ jest.mock('../hooks', () => ({ tags: i % 3 === 0 ? ['tag1'] : ['tag2'], uuid: `uuid-${i + 1}`, due: i === 0 ? '20200101T120000Z' : undefined, - })) - ), + })), + { + id: 13, + description: + 'Prepare quarterly financial analysis report for review', + status: 'pending', + project: 'Finance', + tags: ['report', 'analysis'], + uuid: 'uuid-corp-1', + }, + { + id: 14, + description: 'Schedule client onboarding meeting with Sales team', + status: 'pending', + project: 'Sales', + tags: ['meeting', 'client'], + uuid: 'uuid-corp-2', + }, + { + id: 15, + description: + 'Draft technical documentation for API integration module', + status: 'pending', + project: 'Engineering', + tags: ['documentation', 'api'], + uuid: 'uuid-corp-3', + }, + ]), })), })), }, @@ -275,4 +301,26 @@ describe('Tasks Component', () => { const overdueBadge = await screen.findByText('Overdue'); expect(overdueBadge).toBeInTheDocument(); }); + test('filters tasks with fuzzy search (handles typos)', async () => { + jest.useFakeTimers(); + + render(); + expect(await screen.findByText('Task 12')).toBeInTheDocument(); + + const dropdown = screen.getByLabelText('Show:'); + fireEvent.change(dropdown, { target: { value: '50' } }); + + const searchBar = screen.getByPlaceholderText('Search tasks...'); + fireEvent.change(searchBar, { target: { value: 'fiace' } }); + + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(await screen.findByText('Finance')).toBeInTheDocument(); + expect(screen.queryByText('Engineering')).not.toBeInTheDocument(); + expect(screen.queryByText('Sales')).not.toBeInTheDocument(); + + jest.useRealTimers(); + }); });