From f70e9a670b2a22a0f7913476abe0a4313cf82fba Mon Sep 17 00:00:00 2001 From: Neeraj-gagat Date: Thu, 20 Nov 2025 18:34:57 +0530 Subject: [PATCH 1/4] added keyboard shortcut keys --- .../components/HomeComponents/Tasks/Tasks.tsx | 113 +++++++++++++++++- frontend/src/hooks/useHotkeys.ts | 33 +++++ 2 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 frontend/src/hooks/useHotkeys.ts diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index 2ab48ed5..a1633bdf 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 { useEffect, useState, useCallback, useRef } from 'react'; import { Task } from '../../utils/types'; import { ReportsView } from './ReportsView'; +import { useHotkeys } from '@/hooks/useHotkeys'; import { Table, TableBody, @@ -128,6 +129,9 @@ export const Tasks = ( const [editedEndDate, setEditedEndDate] = useState(''); const [searchTerm, setSearchTerm] = useState(''); const [lastSyncTime, setLastSyncTime] = useState(null); + const tableRef = useRef(null); + const [hotkeysEnabled, setHotkeysEnabled] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); const isOverdue = (due?: string) => { if (!due) return false; @@ -196,6 +200,41 @@ export const Tasks = ( const paginate = (pageNumber: number) => setCurrentPage(pageNumber); const totalPages = Math.ceil(tempTasks.length / tasksPerPage) || 1; + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + if ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + target.isContentEditable + ) { + return; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((prev) => Math.min(prev + 1, currentTasks.length - 1)); + } + + if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + } + + if (e.key === 'e') { + e.preventDefault(); + const task = currentTasks[selectedIndex]; + if (task) { + document.getElementById(`task-row-${task.id}`)?.click(); + } + } + }; + + window.addEventListener('keydown', handler, true); + return () => window.removeEventListener('keydown', handler, true); + }, [hotkeysEnabled, selectedIndex, currentTasks]); + useEffect(() => { const hashedKey = hashKey('tasksPerPage', props.email); const storedTasksPerPage = localStorage.getItem(hashedKey); @@ -678,6 +717,54 @@ export const Tasks = ( } }; + useHotkeys(['f'], () => { + if (!showReports) { + document.getElementById('search')?.focus(); + } + }); + useHotkeys(['a'], () => { + if (!showReports) { + document.getElementById('add-new-task')?.click(); + } + }); + useHotkeys(['r'], () => { + if (!showReports) { + document.getElementById('sync-task')?.click(); + } + }); + useHotkeys(['c'], () => { + if (!showReports) { + const task = currentTasks[selectedIndex]; + if (!task) return; + // Step 1 + const openBtn = document.getElementById(`task-row-${task.id}`); + openBtn?.click(); + // Step 2 + setTimeout(() => { + const confirmBtn = document.getElementById( + `mark-task-complete-${task.id}` + ); + confirmBtn?.click(); + }, 150); + } + }); + useHotkeys(['d'], () => { + if (!showReports) { + const task = currentTasks[selectedIndex]; + if (!task) return; + // Step 1 + const openBtn = document.getElementById(`task-row-${task.id}`); + openBtn?.click(); + // Step 2 + setTimeout(() => { + const confirmBtn = document.getElementById( + `mark-task-as-deleted-${task.id}` + ); + confirmBtn?.click(); + }, 150); + } + }); + return (
) : ( - <> +
setHotkeysEnabled(true)} + onMouseLeave={() => setHotkeysEnabled(false)} + > {tasks.length != 0 ? ( <>
@@ -742,6 +833,7 @@ export const Tasks = (
+ @@ -1784,6 +1886,7 @@ export const Tasks = (
)} - +
)}
); diff --git a/frontend/src/hooks/useHotkeys.ts b/frontend/src/hooks/useHotkeys.ts new file mode 100644 index 00000000..e10612b7 --- /dev/null +++ b/frontend/src/hooks/useHotkeys.ts @@ -0,0 +1,33 @@ +import { useEffect } from 'react'; + +export function useHotkeys(keys: string[], callback: () => void) { + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + if ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + target.isContentEditable + ) { + return; + } + + if ( + keys.every( + (k) => + (k === 'ctrl' && e.ctrlKey) || + (k === 'shift' && e.shiftKey) || + (k === 'alt' && e.altKey) || + e.key.toLowerCase() === k.toLowerCase() + ) + ) { + e.preventDefault(); + callback(); + } + }; + + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [keys, callback]); +} From c34ef681d8a764eb2d65ec74842cc241a9c7d9b3 Mon Sep 17 00:00:00 2001 From: Neeraj-gagat Date: Tue, 25 Nov 2025 21:57:15 +0530 Subject: [PATCH 2/4] added-keys with keyboard-shortcuts --- .../components/HomeComponents/Tasks/Tasks.tsx | 35 +++++++++++++++++-- frontend/src/components/ui/button.tsx | 2 +- frontend/src/components/ui/input.tsx | 27 ++++++++------ frontend/src/components/ui/key-button.tsx | 11 ++++++ 4 files changed, 60 insertions(+), 15 deletions(-) create mode 100644 frontend/src/components/ui/key-button.tsx diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index a1633bdf..9a2c7fed 100644 --- a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx +++ b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx @@ -72,6 +72,7 @@ import { debounce } from '@/components/utils/utils'; import { DatePicker } from '@/components/ui/date-picker'; import { format } from 'date-fns'; import { Taskskeleton } from './Task-Skeleton'; +import { Key } from '@/components/ui/key-button'; const db = new TasksDatabase(); export let syncTasksWithTwAndDb: () => any; @@ -207,6 +208,7 @@ export const Tasks = ( target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || + _isDialogOpen || target.isContentEditable ) { return; @@ -733,7 +735,7 @@ export const Tasks = ( } }); useHotkeys(['c'], () => { - if (!showReports) { + if (!showReports && !_isDialogOpen) { const task = currentTasks[selectedIndex]; if (!task) return; // Step 1 @@ -745,11 +747,23 @@ export const Tasks = ( `mark-task-complete-${task.id}` ); confirmBtn?.click(); + setIsDialogOpen(false); }, 150); + } else { + if (_isDialogOpen) { + const task = currentTasks[selectedIndex]; + if (!task) return; + const confirmBtn = document.getElementById( + `mark-task-complete-${task.id}` + ); + confirmBtn?.click(); + setIsDialogOpen(false); + } } }); + useHotkeys(['d'], () => { - if (!showReports) { + if (!showReports && !_isDialogOpen) { const task = currentTasks[selectedIndex]; if (!task) return; // Step 1 @@ -761,7 +775,18 @@ export const Tasks = ( `mark-task-as-deleted-${task.id}` ); confirmBtn?.click(); + setIsDialogOpen(false); }, 150); + } else { + if (_isDialogOpen) { + const task = currentTasks[selectedIndex]; + if (!task) return; + const confirmBtn = document.getElementById( + `mark-task-as-deleted-${task.id}` + ); + confirmBtn?.click(); + setIsDialogOpen(false); + } } }); @@ -840,6 +865,7 @@ export const Tasks = ( onChange={handleSearchChange} className="flex-1 min-w-[150px]" data-testid="task-search-bar" + icon={} /> setIsAddTaskOpen(true)} > Add Task + @@ -1060,6 +1087,7 @@ export const Tasks = ( )} > Sync + @@ -1844,7 +1872,7 @@ export const Tasks = ( @@ -1891,6 +1919,7 @@ export const Tasks = ( variant={'destructive'} > + diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index db40a07d..a2fcce20 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -20,7 +20,7 @@ const buttonVariants = cva( link: 'text-primary underline-offset-4 hover:underline', }, size: { - default: 'h-10 px-4 py-2', + default: 'h-10 px-3 py-2', sm: 'h-9 rounded-md px-3', lg: 'h-11 rounded-md px-8', icon: 'h-10 w-10', diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx index 688b7c89..832c8ce5 100644 --- a/frontend/src/components/ui/input.tsx +++ b/frontend/src/components/ui/input.tsx @@ -3,20 +3,25 @@ import * as React from 'react'; import { cn } from '@/components/utils/utils'; export interface InputProps - extends React.InputHTMLAttributes {} + extends React.InputHTMLAttributes { + icon?: React.ReactNode; +} const Input = React.forwardRef( - ({ className, type, ...props }, ref) => { + ({ className, type, icon, ...props }, ref) => { return ( - +
+ + {icon && {icon}} +
); } ); diff --git a/frontend/src/components/ui/key-button.tsx b/frontend/src/components/ui/key-button.tsx new file mode 100644 index 00000000..056fcf50 --- /dev/null +++ b/frontend/src/components/ui/key-button.tsx @@ -0,0 +1,11 @@ +export const Key = ({ lable }: { lable: string }) => { + return ( + {lable} + {/* {key} */} + + ); +}; From 40f0d97a5df55b11c2094740282339ca1ea0ea58 Mon Sep 17 00:00:00 2001 From: Neeraj-gagat Date: Tue, 25 Nov 2025 22:43:31 +0530 Subject: [PATCH 3/4] fixed merge conflicts --- backend/controllers/edit_task.go | 3 +- backend/models/request_body.go | 1 + backend/utils/tw/edit_task.go | 10 +- backend/utils/tw/taskwarrior_test.go | 8 +- frontend/package-lock.json | 10 + frontend/package.json | 1 + .../HomeComponents/SetupGuide/SetupGuide.tsx | 2 +- .../components/HomeComponents/Tasks/Tasks.tsx | 482 +++++++++++--- .../Tasks/__tests__/Tasks.test.tsx | 169 ++++- .../Tasks/__tests__/tasks-utils.test.ts | 2 +- .../components/HomeComponents/Tasks/hooks.ts | 3 + frontend/src/components/HomePage.tsx | 2 +- .../components/__tests__/HomePage.test.tsx | 588 +++++++++++++++++- 13 files changed, 1169 insertions(+), 112 deletions(-) diff --git a/backend/controllers/edit_task.go b/backend/controllers/edit_task.go index c0122ab3..8647ee04 100644 --- a/backend/controllers/edit_task.go +++ b/backend/controllers/edit_task.go @@ -50,6 +50,7 @@ func EditTaskHandler(w http.ResponseWriter, r *http.Request) { entry := requestBody.Entry wait := requestBody.Wait end := requestBody.End + depends := requestBody.Depends if taskID == "" { http.Error(w, "taskID is required", http.StatusBadRequest) @@ -61,7 +62,7 @@ func EditTaskHandler(w http.ResponseWriter, r *http.Request) { Name: "Edit Task", Execute: func() error { logStore.AddLog("INFO", fmt.Sprintf("Editing task ID: %s", taskID), uuid, "Edit Task") - err := tw.EditTaskInTaskwarrior(uuid, description, email, encryptionSecret, taskID, tags, project, start, entry, wait, end) + err := tw.EditTaskInTaskwarrior(uuid, description, email, encryptionSecret, taskID, tags, project, start, entry, wait, end, depends) if err != nil { logStore.AddLog("ERROR", fmt.Sprintf("Failed to edit task ID %s: %v", taskID, err), uuid, "Edit Task") return err diff --git a/backend/models/request_body.go b/backend/models/request_body.go index 2744249d..c6f1ceba 100644 --- a/backend/models/request_body.go +++ b/backend/models/request_body.go @@ -35,6 +35,7 @@ type EditTaskRequestBody struct { Entry string `json:"entry"` Wait string `json:"wait"` End string `json:"end"` + Depends []string `json:"depends"` } type CompleteTaskRequestBody struct { Email string `json:"email"` diff --git a/backend/utils/tw/edit_task.go b/backend/utils/tw/edit_task.go index 1e40cfad..63c74aa8 100644 --- a/backend/utils/tw/edit_task.go +++ b/backend/utils/tw/edit_task.go @@ -7,7 +7,7 @@ import ( "strings" ) -func EditTaskInTaskwarrior(uuid, description, email, encryptionSecret, taskID string, tags []string, project string, start string, entry string, wait string, end string) error { +func EditTaskInTaskwarrior(uuid, description, email, encryptionSecret, taskID string, tags []string, project string, start string, entry string, wait string, end string, depends []string) error { if err := utils.ExecCommand("rm", "-rf", "/root/.task"); err != nil { return fmt.Errorf("error deleting Taskwarrior data: %v", err) } @@ -94,6 +94,14 @@ func EditTaskInTaskwarrior(uuid, description, email, encryptionSecret, taskID st } } + // Handle depends + if len(depends) > 0 { + dependsStr := strings.Join(depends, ",") + if err := utils.ExecCommand("task", taskID, "modify", "depends:"+dependsStr); err != nil { + return fmt.Errorf("failed to set depends %s: %v", dependsStr, err) + } + } + // Sync Taskwarrior again if err := SyncTaskwarrior(tempDir); err != nil { return err diff --git a/backend/utils/tw/taskwarrior_test.go b/backend/utils/tw/taskwarrior_test.go index cf55f6a6..fe8b0b63 100644 --- a/backend/utils/tw/taskwarrior_test.go +++ b/backend/utils/tw/taskwarrior_test.go @@ -23,7 +23,7 @@ func TestSyncTaskwarrior(t *testing.T) { } func TestEditTaskInATaskwarrior(t *testing.T) { - err := EditTaskInTaskwarrior("uuid", "description", "email", "encryptionSecret", "taskuuid", nil, "project", "2025-11-29T18:30:00.000Z", "2025-11-29T18:30:00.000Z", "2025-11-29T18:30:00.000Z", "2025-11-30T18:30:00.000Z") + err := EditTaskInTaskwarrior("uuid", "description", "email", "encryptionSecret", "taskuuid", nil, "project", "2025-11-29T18:30:00.000Z", "2025-11-29T18:30:00.000Z", "2025-11-29T18:30:00.000Z", "2025-11-30T18:30:00.000Z", nil) if err != nil { t.Errorf("EditTaskInTaskwarrior() failed: %v", err) } else { @@ -68,7 +68,7 @@ func TestAddTaskWithTags(t *testing.T) { } func TestEditTaskWithTagAddition(t *testing.T) { - err := EditTaskInTaskwarrior("uuid", "description", "email", "encryptionSecret", "taskuuid", []string{"+urgent", "+important"}, "project", "2025-11-29T18:30:00.000Z", "2025-11-29T18:30:00.000Z", "2025-11-29T18:30:00.000Z", "2025-11-30T18:30:00.000Z") + err := EditTaskInTaskwarrior("uuid", "description", "email", "encryptionSecret", "taskuuid", []string{"+urgent", "+important"}, "project", "2025-11-29T18:30:00.000Z", "2025-11-29T18:30:00.000Z", "2025-11-29T18:30:00.000Z", "2025-11-30T18:30:00.000Z", nil) if err != nil { t.Errorf("EditTaskInTaskwarrior with tag addition failed: %v", err) } else { @@ -77,7 +77,7 @@ func TestEditTaskWithTagAddition(t *testing.T) { } func TestEditTaskWithTagRemoval(t *testing.T) { - err := EditTaskInTaskwarrior("uuid", "description", "email", "encryptionSecret", "taskuuid", []string{"-work", "-lowpriority"}, "project", "2025-11-29T18:30:00.000Z", "2025-11-29T18:30:00.000Z", "2025-11-29T18:30:00.000Z", "2025-11-30T18:30:00.000Z") + err := EditTaskInTaskwarrior("uuid", "description", "email", "encryptionSecret", "taskuuid", []string{"-work", "-lowpriority"}, "project", "2025-11-29T18:30:00.000Z", "2025-11-29T18:30:00.000Z", "2025-11-29T18:30:00.000Z", "2025-11-30T18:30:00.000Z", nil) if err != nil { t.Errorf("EditTaskInTaskwarrior with tag removal failed: %v", err) } else { @@ -86,7 +86,7 @@ func TestEditTaskWithTagRemoval(t *testing.T) { } func TestEditTaskWithMixedTagOperations(t *testing.T) { - err := EditTaskInTaskwarrior("uuid", "description", "email", "encryptionSecret", "taskuuid", []string{"+urgent", "-work", "normal"}, "project", "2025-11-29T18:30:00.000Z", "2025-11-29T18:30:00.000Z", "2025-11-29T18:30:00.000Z", "2025-11-30T18:30:00.000Z") + err := EditTaskInTaskwarrior("uuid", "description", "email", "encryptionSecret", "taskuuid", []string{"+urgent", "-work", "normal"}, "project", "2025-11-29T18:30:00.000Z", "2025-11-29T18:30:00.000Z", "2025-11-29T18:30:00.000Z", "2025-11-30T18:30:00.000Z", nil) if err != nil { t.Errorf("EditTaskInTaskwarrior with mixed tag operations failed: %v", err) } else { 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/SetupGuide/SetupGuide.tsx b/frontend/src/components/HomeComponents/SetupGuide/SetupGuide.tsx index 07a194ee..35b807cf 100644 --- a/frontend/src/components/HomeComponents/SetupGuide/SetupGuide.tsx +++ b/frontend/src/components/HomeComponents/SetupGuide/SetupGuide.tsx @@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'; export const SetupGuide = (props: Props) => { const downloadConfigFile = () => { - const configContent = exportConfigSetup(props); // already a string + const configContent = exportConfigSetup(props); const blob = new Blob([configContent], { type: 'text/plain;charset=utf-8', }); diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index 9a2c7fed..103a4dc0 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, useRef } from 'react'; import { Task } from '../../utils/types'; import { ReportsView } from './ReportsView'; +import Fuse from 'fuse.js'; import { useHotkeys } from '@/hooks/useHotkeys'; import { Table, @@ -113,6 +114,7 @@ export const Tasks = ( const [editedTags, setEditedTags] = useState( _selectedTask?.tags || [] ); + const [editTagInput, setEditTagInput] = useState(''); const [isEditingTags, setIsEditingTags] = useState(false); const [isEditingPriority, setIsEditingPriority] = useState(false); const [editedPriority, setEditedPriority] = useState('NONE'); @@ -128,7 +130,12 @@ export const Tasks = ( const [editedEntryDate, setEditedEntryDate] = useState(''); const [isEditingEndDate, setIsEditingEndDate] = useState(false); const [editedEndDate, setEditedEndDate] = useState(''); + const [isEditingDepends, setIsEditingDepends] = useState(false); + const [editedDepends, setEditedDepends] = useState([]); + const [dependsDropdownOpen, setDependsDropdownOpen] = useState(false); + const [dependsSearchTerm, setDependsSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState(''); + const [debouncedTerm, setDebouncedTerm] = useState(''); const [lastSyncTime, setLastSyncTime] = useState(null); const tableRef = useRef(null); const [hotkeysEnabled, setHotkeysEnabled] = useState(false); @@ -137,7 +144,6 @@ export const Tasks = ( const isOverdue = (due?: string) => { if (!due) return false; - // Taskwarrior format: 20251115T183000Z const parsed = new Date( due.replace( /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/, @@ -145,7 +151,6 @@ export const Tasks = ( ) ); - // Convert to local date (ignore time) const dueDate = new Date(parsed); dueDate.setHours(0, 0, 0, 0); @@ -155,27 +160,8 @@ export const Tasks = ( return dueDate < today; }; - // 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); @@ -250,7 +236,6 @@ export const Tasks = ( } }, [_selectedTask]); - // Load last sync time from localStorage on mount useEffect(() => { const hashedKey = hashKey('lastSyncTime', props.email); const storedLastSyncTime = localStorage.getItem(hashedKey); @@ -262,10 +247,8 @@ export const Tasks = ( // Update the displayed time every 10 seconds useEffect(() => { const interval = setInterval(() => { - // Force re-render by updating the state setLastSyncTime((prevTime) => prevTime); - }, 10000); // Update every 10 seconds - + }, 10000); return () => clearInterval(interval); }, []); @@ -392,7 +375,8 @@ export const Tasks = ( start: string, entry: string, wait: string, - end: string + end: string, + depends: string[] ) { try { await editTaskOnBackend({ @@ -408,6 +392,7 @@ export const Tasks = ( entry, wait, end, + depends, }); console.log('Task edited successfully!'); @@ -430,7 +415,6 @@ export const Tasks = ( const newOrder = sortOrder === 'asc' ? 'desc' : 'asc'; setSortOrder(newOrder); const sorted = sortTasks([...tasks], newOrder); - // Keep both states in sync so the table (which renders from tempTasks) reflects the new order setTasks(sorted); setTempTasks(sorted); setCurrentPage(1); @@ -454,7 +438,8 @@ export const Tasks = ( task.start, task.entry || '', task.wait || '', - task.end || '' + task.end || '', + task.depends || [] ); setIsEditing(false); }; @@ -472,7 +457,8 @@ export const Tasks = ( task.start, task.entry || '', task.wait || '', - task.end || '' + task.end || '', + task.depends || [] ); setIsEditingProject(false); }; @@ -491,7 +477,8 @@ export const Tasks = ( task.start, task.entry || '', task.wait, - task.end || '' + task.end || '', + task.depends || [] ); setIsEditingWaitDate(false); @@ -511,7 +498,8 @@ export const Tasks = ( task.start, task.entry || '', task.wait || '', - task.end || '' + task.end || '', + task.depends || [] ); setIsEditingStartDate(false); @@ -531,7 +519,8 @@ export const Tasks = ( task.start, task.entry, task.wait, - task.end + task.end, + task.depends || [] ); setIsEditingEntryDate(false); @@ -551,12 +540,45 @@ export const Tasks = ( task.start, task.entry, task.wait, - task.end + task.end, + task.depends || [] ); setIsEditingEndDate(false); }; + const handleDependsSaveClick = (task: Task) => { + task.depends = editedDepends; + + handleEditTaskOnBackend( + props.email, + props.encryptionSecret, + props.UUID, + task.description, + task.tags, + task.id.toString(), + task.project, + task.start, + task.entry || '', + task.wait || '', + task.end || '', + task.depends + ); + + setIsEditingDepends(false); + setDependsDropdownOpen(false); + }; + + const handleAddDependency = (uuid: string) => { + if (!editedDepends.includes(uuid)) { + setEditedDepends([...editedDepends, uuid]); + } + }; + + const handleRemoveDependency = (uuid: string) => { + setEditedDepends(editedDepends.filter((dep) => dep !== uuid)); + }; + const handleCancelClick = () => { setIsEditing(false); }; @@ -576,6 +598,10 @@ export const Tasks = ( setEditedEntryDate(''); setIsEditingEndDate(false); setEditedEndDate(''); + setIsEditingDepends(false); + setEditedDepends([]); + setDependsDropdownOpen(false); + setDependsSearchTerm(''); } else { setSelectedTask(task); setEditedDescription(task?.description || ''); @@ -591,6 +617,14 @@ export const Tasks = ( } }; + // Handle adding a tag while editing + const handleAddEditTag = () => { + if (editTagInput && !editedTags.includes(editTagInput, 0)) { + setEditedTags([...editedTags, editTagInput]); + setEditTagInput(''); + } + }; + // Handle removing a tag const handleRemoveTag = (tagToRemove: string) => { setNewTask({ @@ -599,6 +633,11 @@ export const Tasks = ( }); }; + // Handle removing a tag while editing task + const handleRemoveEditTag = (tagToRemove: string) => { + setEditedTags(editedTags.filter((tag) => tag !== tagToRemove)); + }; + const sortWithOverdueOnTop = (tasks: Task[]) => { return [...tasks].sort((a, b) => { const aOverdue = a.status === 'pending' && isOverdue(a.due); @@ -608,13 +647,13 @@ export const Tasks = ( if (aOverdue && !bOverdue) return -1; if (!aOverdue && bOverdue) return 1; - // Otherwise fall back to ID sort DESC (latest first) - return b.id - a.id; + // Otherwise fall back to ID sort and status sort + return 0; }); }; useEffect(() => { - let filteredTasks = tasks; + let filteredTasks = [...tasks]; // Project filter if (selectedProjects.length > 0) { @@ -623,7 +662,7 @@ export const Tasks = ( ); } - //Status filter + // Status filter if (selectedStatuses.length > 0) { filteredTasks = filteredTasks.filter((task) => selectedStatuses.includes(task.status) @@ -638,11 +677,24 @@ export const Tasks = ( ); } - filteredTasks = sortWithOverdueOnTop(filteredTasks); + // Fuzzy search + if (debouncedTerm.trim() !== '') { + const fuseOptions = { + keys: ['description', 'project', 'tags'], + threshold: 0.4, + ignoreLocation: true, + includeScore: false, + }; + + const fuse = new Fuse(filteredTasks, fuseOptions); + const results = fuse.search(debouncedTerm); - // Sort + set + filteredTasks = results.map((r) => r.item); + } + + filteredTasks = sortWithOverdueOnTop(filteredTasks); setTempTasks(filteredTasks); - }, [selectedProjects, selectedTags, selectedStatuses, tasks]); + }, [selectedProjects, selectedTags, selectedStatuses, tasks, debouncedTerm]); const handleEditTagsClick = (task: Task) => { setEditedTags(task.tags || []); @@ -650,13 +702,12 @@ export const Tasks = ( }; const handleSaveTags = (task: Task) => { - const currentTags = task.tags || []; // Default to an empty array if tags are null + const currentTags = task.tags || []; const removedTags = currentTags.filter((tag) => !editedTags.includes(tag)); - const updatedTags = editedTags.filter((tag) => tag.trim() !== ''); // Remove any empty tags - const tagsToRemove = removedTags.map((tag) => `-${tag}`); // Prefix `-` for removed tags - const finalTags = [...updatedTags, ...tagsToRemove]; // Combine updated and removed tags + const updatedTags = editedTags.filter((tag) => tag.trim() !== ''); + const tagsToRemove = removedTags.map((tag) => `-${tag}`); + const finalTags = [...updatedTags, ...tagsToRemove]; console.log(finalTags); - // Call the backend function with updated tags handleEditTaskOnBackend( props.email, props.encryptionSecret, @@ -668,10 +719,12 @@ export const Tasks = ( task.start, task.entry || '', task.wait || '', - task.end || '' + task.end || '', + task.depends || [] ); - setIsEditingTags(false); // Exit editing mode + setIsEditingTags(false); + setEditTagInput(''); }; const handleCancelTags = () => { @@ -679,14 +732,12 @@ export const Tasks = ( setEditedTags([]); }; const handleEditPriorityClick = (task: Task) => { - // Convert empty priority to "NONE" for the select component setEditedPriority(task.priority || 'NONE'); setIsEditingPriority(true); }; const handleSavePriority = async (task: Task) => { try { - // Convert "NONE" to empty string for backend const priorityValue = editedPriority === 'NONE' ? '' : editedPriority; await modifyTaskOnBackend({ @@ -955,7 +1006,7 @@ export const Tasks = ( priority: e.target.value, }) } - className="border rounded-md px-2 py-1 w-full bg-black text-white" + className="border rounded-md px-2 py-1 w-full bg-white text-black dark:bg-black dark:text-white transition-colors" > @@ -1031,19 +1082,22 @@ export const Tasks = (
{newTask.tags.length > 0 && ( -
- {newTask.tags.map((tag, index) => ( - - {tag} - - - ))} +
+
+
+ {newTask.tags.map((tag, index) => ( + + {tag} + + + ))} +
)}
@@ -1138,6 +1192,9 @@ export const Tasks = ( ) : ( currentTasks.map((task: Task, index: number) => ( handleDialogOpenChange(_isDialogOpen, task) } @@ -1540,7 +1597,191 @@ export const Tasks = ( Depends: - {task.depends} + + {!isEditingDepends ? ( +
+ {(task.depends || []).map( + (depUuid) => { + const depTask = tasks.find( + (t) => t.uuid === depUuid + ); + return ( + { + if (depTask) { + setIsDialogOpen( + false + ); + setTimeout(() => { + setSelectedTask( + depTask + ); + setIsDialogOpen( + true + ); + }, 100); + } + }} + > + {depTask?.description || + depUuid.substring(0, 8)} + + ); + } + )} + +
+ ) : ( +
+
+ {editedDepends.map( + (depUuid) => { + const depTask = tasks.find( + (t) => t.uuid === depUuid + ); + return ( + + + {depTask?.description || + depUuid.substring( + 0, + 8 + )} + + + + ); + } + )} +
+
+
+ + {dependsDropdownOpen && ( +
+ + setDependsSearchTerm( + e.target.value + ) + } + className="m-2 w-[calc(100%-1rem)]" + /> + {tasks + .filter( + (t) => + t.uuid !== + task.uuid && + t.status === + 'pending' && + !editedDepends.includes( + t.uuid + ) && + t.description + .toLowerCase() + .includes( + dependsSearchTerm.toLowerCase() + ) + ) + .map((t) => ( +
{ + handleAddDependency( + t.uuid + ); + setDependsSearchTerm( + '' + ); + }} + > + + + {t.description} + +
+ ))} +
+ )} +
+ + +
+
+ )} +
Recur: @@ -1687,45 +1928,94 @@ export const Tasks = ( Tags: {isEditingTags ? ( -
- - setEditedTags( - e.target.value - .split(',') - .map((tag) => tag.trim()) - ) - } - className="flex-grow mr-2" - /> - - +
+
+ { + // For allowing only alphanumeric characters + if ( + e.target.value.length > 1 + ) { + /^[a-zA-Z0-9]*$/.test( + e.target.value.trim() + ) + ? setEditTagInput( + e.target.value.trim() + ) + : ''; + } else { + /^[a-zA-Z]*$/.test( + e.target.value.trim() + ) + ? setEditTagInput( + e.target.value.trim() + ) + : ''; + } + }} + placeholder="Add a tag (press enter to add)" + className="flex-grow mr-2" + onKeyDown={(e) => + e.key === 'Enter' && + handleAddEditTag() + } + /> + + +
+
+ {editedTags != null && + editedTags.length > 0 && ( +
+
+ {editedTags.map( + (tag, index) => ( + + {tag} + + + ) + )} +
+
+ )} +
) : ( -
+
{task.tags !== null && task.tags.length >= 1 ? ( task.tags.map((tag, index) => ( {tag} diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx index 546c14f1..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 } 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', + }, + ]), })), })), }, @@ -146,6 +172,117 @@ describe('Tasks Component', () => { expect(screen.getByTestId('current-page')).toHaveTextContent('1'); }); + test('shows tags as badges in task dialog and allows editing (add on Enter)', async () => { + render(); + + expect(await screen.findByText('Task 1')).toBeInTheDocument(); + + const taskRow = screen.getByText('Task 1'); + fireEvent.click(taskRow); + + expect(await screen.findByText('Tags:')).toBeInTheDocument(); + + expect(screen.getByText('tag1')).toBeInTheDocument(); + + const tagsLabel = screen.getByText('Tags:'); + const tagsRow = tagsLabel.closest('tr') as HTMLElement; + const pencilButton = within(tagsRow).getByRole('button'); + fireEvent.click(pencilButton); + + const editInput = await screen.findByPlaceholderText( + 'Add a tag (press enter to add)' + ); + + fireEvent.change(editInput, { target: { value: 'newtag' } }); + fireEvent.keyDown(editInput, { key: 'Enter', code: 'Enter' }); + + expect(await screen.findByText('newtag')).toBeInTheDocument(); + + expect((editInput as HTMLInputElement).value).toBe(''); + }); + + test('adds a tag while editing and saves updated tags to backend', async () => { + render(); + + expect(await screen.findByText('Task 1')).toBeInTheDocument(); + + const taskRow = screen.getByText('Task 1'); + fireEvent.click(taskRow); + + expect(await screen.findByText('Tags:')).toBeInTheDocument(); + + const tagsLabel = screen.getByText('Tags:'); + const tagsRow = tagsLabel.closest('tr') as HTMLElement; + const pencilButton = within(tagsRow).getByRole('button'); + fireEvent.click(pencilButton); + + const editInput = await screen.findByPlaceholderText( + 'Add a tag (press enter to add)' + ); + + fireEvent.change(editInput, { target: { value: 'addedtag' } }); + fireEvent.keyDown(editInput, { key: 'Enter', code: 'Enter' }); + + expect(await screen.findByText('addedtag')).toBeInTheDocument(); + + const actionContainer = editInput.parentElement as HTMLElement; + const actionButtons = within(actionContainer).getAllByRole('button'); + fireEvent.click(actionButtons[0]); + + const hooks = require('../hooks'); + expect(hooks.editTaskOnBackend).toHaveBeenCalled(); + + const callArg = hooks.editTaskOnBackend.mock.calls[0][0]; + expect(callArg.tags).toEqual(expect.arrayContaining(['tag1', 'addedtag'])); + }); + + test('removes a tag while editing and saves updated tags to backend', async () => { + render(); + + expect(await screen.findByText('Task 1')).toBeInTheDocument(); + + const taskRow = screen.getByText('Task 1'); + fireEvent.click(taskRow); + + expect(await screen.findByText('Tags:')).toBeInTheDocument(); + + const tagsLabel = screen.getByText('Tags:'); + const tagsRow = tagsLabel.closest('tr') as HTMLElement; + const pencilButton = within(tagsRow).getByRole('button'); + fireEvent.click(pencilButton); + + const editInput = await screen.findByPlaceholderText( + 'Add a tag (press enter to add)' + ); + + fireEvent.change(editInput, { target: { value: 'newtag' } }); + fireEvent.keyDown(editInput, { key: 'Enter', code: 'Enter' }); + + expect(await screen.findByText('newtag')).toBeInTheDocument(); + + const tagBadge = screen.getByText('tag1'); + const badgeContainer = (tagBadge.closest('div') || + tagBadge.parentElement) as HTMLElement; + + const removeButton = within(badgeContainer).getByText('✖'); + fireEvent.click(removeButton); + + expect(screen.queryByText('tag2')).not.toBeInTheDocument(); + + const actionContainer = editInput.parentElement as HTMLElement; + + const actionButtons = within(actionContainer).getAllByRole('button'); + + fireEvent.click(actionButtons[0]); + + const hooks = require('../hooks'); + expect(hooks.editTaskOnBackend).toHaveBeenCalled(); + + const callArg = hooks.editTaskOnBackend.mock.calls[0][0]; + + expect(callArg.tags).toEqual(expect.arrayContaining(['newtag', '-tag1'])); + }); + test('shows red background on task ID and Overdue badge for overdue tasks', async () => { render(); @@ -164,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(); + }); }); diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/tasks-utils.test.ts b/frontend/src/components/HomeComponents/Tasks/__tests__/tasks-utils.test.ts index 7ac2d540..59438ea9 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/tasks-utils.test.ts +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/tasks-utils.test.ts @@ -36,7 +36,7 @@ const createTask = ( email: '', start: '', wait: '', - depends: [''], + depends: [], rtype: '', recur: '', }); diff --git a/frontend/src/components/HomeComponents/Tasks/hooks.ts b/frontend/src/components/HomeComponents/Tasks/hooks.ts index 06601fae..11034d51 100644 --- a/frontend/src/components/HomeComponents/Tasks/hooks.ts +++ b/frontend/src/components/HomeComponents/Tasks/hooks.ts @@ -93,6 +93,7 @@ export const editTaskOnBackend = async ({ entry, wait, end, + depends, }: { email: string; encryptionSecret: string; @@ -106,6 +107,7 @@ export const editTaskOnBackend = async ({ entry: string; wait: string; end: string; + depends: string[]; }) => { const response = await fetch(`${backendURL}edit-task`, { method: 'POST', @@ -121,6 +123,7 @@ export const editTaskOnBackend = async ({ entry, wait, end, + depends, }), headers: { 'Content-Type': 'application/json', diff --git a/frontend/src/components/HomePage.tsx b/frontend/src/components/HomePage.tsx index bbb68b1d..24ca1be1 100644 --- a/frontend/src/components/HomePage.tsx +++ b/frontend/src/components/HomePage.tsx @@ -77,7 +77,6 @@ export const HomePage: React.FC = () => { const data = JSON.parse(event.data); if (data.status === 'success') { getTasks(userInfo.email, userInfo.encryption_secret, userInfo.uuid); - } else if (data.status === 'success') { if (data.job === 'Add Task') { console.log('Task added successfully'); toast.success('Task added successfully!', { @@ -156,6 +155,7 @@ export const HomePage: React.FC = () => { return; } + /* istanbul ignore if */ if (typeof window === 'undefined') { return; } diff --git a/frontend/src/components/__tests__/HomePage.test.tsx b/frontend/src/components/__tests__/HomePage.test.tsx index b1ce1b38..9da583ea 100644 --- a/frontend/src/components/__tests__/HomePage.test.tsx +++ b/frontend/src/components/__tests__/HomePage.test.tsx @@ -2,29 +2,87 @@ import { render, screen, waitFor } from '@testing-library/react'; import { HomePage } from '../HomePage'; // Mock dependencies +let receivedNavbarProps: any = null; +let mockSocket: any; +let lastDriverConfig: any = null; +const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); +const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); +const mockFetchTaskwarriorTasks = jest.fn(); +const mockToastError = jest.fn(); +const mockToastSuccess = jest.fn(); +const mockedNavigate = jest.fn(); +const mockDrive = jest.fn(); +const mockDestroy = jest.fn(); + jest.mock('../HomeComponents/Navbar/Navbar', () => ({ - Navbar: () =>
Mocked Navbar
, + Navbar: (props: any) => { + receivedNavbarProps = props; + return
Mocked Navbar
; + }, })); + jest.mock('../HomeComponents/Hero/Hero', () => ({ Hero: () =>
Mocked Hero
, })); + jest.mock('../HomeComponents/Footer/Footer', () => ({ Footer: () =>
Mocked Footer
, })); + jest.mock('../HomeComponents/SetupGuide/SetupGuide', () => ({ SetupGuide: () =>
Mocked SetupGuide
, })); + jest.mock('../HomeComponents/FAQ/FAQ', () => ({ FAQ: () =>
Mocked FAQ
, })); + jest.mock('../HomeComponents/Tasks/Tasks', () => ({ Tasks: () =>
Mocked Tasks
, })); -const mockedNavigate = jest.fn(); +jest.mock('../HomeComponents/Tasks/hooks', () => ({ + fetchTaskwarriorTasks: (...args: any[]) => mockFetchTaskwarriorTasks(...args), +})); + +jest.mock('react-toastify', () => ({ + toast: { + error: (...args: any[]) => mockToastError(...args), + success: (...args: any[]) => mockToastSuccess(...args), + }, +})); + +jest.mock('driver.js', () => { + return { + driver: jest.fn((config) => { + lastDriverConfig = config; + return { + drive: mockDrive, + destroy: mockDestroy, + isActive: jest.fn(() => true), + }; + }), + }; +}); + +beforeEach(() => { + mockSocket = { + onopen: null, + onclose: null, + onmessage: null, + onerror: null, + close: jest.fn(), + }; + + (global as any).WebSocket = jest.fn(() => mockSocket); +}); + jest.mock('react-router', () => ({ useNavigate: () => mockedNavigate, })); + jest.mock('@/components/utils/URLs', () => ({ url: { backendURL: 'http://mocked-backend-url/', @@ -49,8 +107,13 @@ global.fetch = jest.fn(() => ) as jest.Mock; describe('HomePage', () => { + beforeEach(() => { + mockDestroy.mockClear(); + }); + afterEach(() => { jest.clearAllMocks(); + consoleErrorSpy.mockClear(); }); it('renders correctly when user info is fetched successfully', async () => { @@ -91,4 +154,525 @@ describe('HomePage', () => { expect(mockedNavigate).toHaveBeenCalledWith('/'); }); }); + + // Tasks Fetching Tests + + describe('Task Fetching', () => { + it('calls fetchTaskwarriorTasks with correct parameters when user info is loaded', async () => { + render(); + + await waitFor(() => { + expect(mockFetchTaskwarriorTasks).toHaveBeenCalledTimes(1); + expect(mockFetchTaskwarriorTasks).toHaveBeenCalledWith({ + email: 'mocked-email', + encryptionSecret: 'mocked-encryption-secret', + UUID: 'mocked-uuid', + backendURL: 'http://mocked-backend-url/', + }); + }); + }); + + it('updates tasks state when fetchTaskwarriorTasks returns data', async () => { + const mockTasks = [ + { id: 1, description: 'Test task 1' }, + { id: 2, description: 'Test task 2' }, + ]; + mockFetchTaskwarriorTasks.mockResolvedValueOnce(mockTasks); + + render(); + + await waitFor(() => { + expect(receivedNavbarProps.email).toBe('mocked-email'); + expect(receivedNavbarProps.encryptionSecret).toBe( + 'mocked-encryption-secret' + ); + expect(receivedNavbarProps.UUID).toBe('mocked-uuid'); + expect(receivedNavbarProps.tasks).toEqual(mockTasks); + }); + }); + + it('sets tasks to empty array when fetchTaskwarriorTasks returns null', async () => { + mockFetchTaskwarriorTasks.mockResolvedValueOnce(null); + + render(); + + await waitFor(() => { + expect(receivedNavbarProps.tasks).toEqual([]); + }); + }); + + it('handles fetchTaskwarriorTasks error with toast.error and resets loading state', async () => { + mockFetchTaskwarriorTasks.mockRejectedValueOnce(new Error('Test error')); + + render(); + + await waitFor(() => { + expect(receivedNavbarProps.isLoading).toBe(false); + expect(mockToastError).toHaveBeenCalled(); + }); + }); + + it('toggles loading state correctly during task fetch', async () => { + const mockTasks = [ + { id: 1, description: 'Test task 1' }, + { id: 2, description: 'Test task 2' }, + ]; + mockFetchTaskwarriorTasks.mockResolvedValueOnce(mockTasks); + + render(); + + await waitFor(() => { + expect(receivedNavbarProps.isLoading).toBe(true); + }); + + await waitFor(() => { + expect(receivedNavbarProps.isLoading).toBe(false); + }); + }); + }); + + // WebSocket Tests + describe('WebSocket Behavior', () => { + it('creates WebSocket with the correct URL', async () => { + render(); + + await waitFor(() => { + expect((global as any).WebSocket).toHaveBeenCalledTimes(1); + expect((global as any).WebSocket).toHaveBeenCalledWith( + 'ws://mocked-backend-url/ws?clientID=mocked-uuid' + ); + }); + }); + + it('does not create the WebSocket when userInfo is missing', async () => { + //Mock fetch to return null user info + (fetch as jest.Mock).mockImplementationOnce(() => { + Promise.resolve({ + ok: true, + json: () => Promise.resolve(null), + }); + }); + + render(); + + await waitFor(() => { + expect((global as any).WebSocket).not.toHaveBeenCalled(); + }); + }); + + it('does not create the WebSocket when userInfo.uuid is missing', async () => { + (fetch as jest.Mock).mockImplementationOnce(() => { + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + picture: 'mocked-picture', + email: 'mocked-email', + encryptionSecret: 'mocked-encryption-secret', + name: 'mock-name', + uuid: null, + }), + }); + }); + + render(); + + await waitFor(() => { + expect((global as any).WebSocket).not.toHaveBeenCalled(); + }); + }); + + it('refreshes tasks when WebSocket receives a success status message', async () => { + mockFetchTaskwarriorTasks.mockResolvedValue([]); + + render(); + + await waitFor(() => { + expect((global as any).WebSocket).toHaveBeenCalledTimes(1); + }); + + const messageEvent = { + data: JSON.stringify({ status: 'success' }), + }; + + mockSocket.onmessage(messageEvent); + + await waitFor(() => { + expect(mockFetchTaskwarriorTasks).toHaveBeenCalledTimes(2); + }); + }); + + test.each([ + { job: 'Add Task' }, + { job: 'Edit Task' }, + { job: 'Delete Task' }, + { job: 'Complete Task' }, + ])( + 'shows success toast when WebSocket receives success with job "%s"', + async ({ job }) => { + mockFetchTaskwarriorTasks.mockResolvedValueOnce([]); + + render(); + + await waitFor(() => { + expect((global as any).WebSocket).toHaveBeenCalledTimes(1); + }); + + const messageEvent = { + data: JSON.stringify({ + status: 'success', + job, + }), + }; + + mockSocket.onmessage(messageEvent); + + await waitFor(() => { + expect(mockToastSuccess).toHaveBeenCalled(); + }); + } + ); + + it('shows error toast when WebSocket receives a failure status message', async () => { + mockFetchTaskwarriorTasks.mockResolvedValueOnce([]); + + render(); + + await waitFor(() => { + expect((global as any).WebSocket).toHaveBeenCalledTimes(1); + }); + + const messageEvent = { + data: JSON.stringify({ + status: 'failure', + job: 'Any Action', + }), + }; + + mockSocket.onmessage(messageEvent); + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalled(); + }); + }); + + it('handles malformed JSON in WebSocket message without crashing', async () => { + mockFetchTaskwarriorTasks.mockResolvedValueOnce([]); + + render(); + + await waitFor(() => { + expect((global as any).WebSocket).toHaveBeenCalledTimes(1); + }); + + mockSocket.onmessage({ data: 'NOT_JSON' }); + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + }); + + expect(mockToastError).not.toHaveBeenCalled(); + expect(mockToastSuccess).not.toHaveBeenCalled(); + }); + + it('closes WebSocket on Component unmount', async () => { + mockFetchTaskwarriorTasks.mockResolvedValueOnce([]); + + const { unmount } = render(); + + await waitFor(() => { + expect((global as any).WebSocket).toHaveBeenCalledTimes(1); + }); + + unmount(); + + expect(mockSocket.close).toHaveBeenCalledTimes(1); + }); + + it('handles success status with unknown job without throwing', async () => { + render(); + + await waitFor(() => { + expect((global as any).WebSocket).toHaveBeenCalled(); + }); + + const event = { + data: JSON.stringify({ status: 'success', job: 'UnknownJob' }), + }; + + expect(() => mockSocket.onmessage(event)).not.toThrow(); + }); + + it('handles success status with no job field gracefully', async () => { + render(); + + await waitFor(() => { + expect((global as any).WebSocket).toHaveBeenCalled(); + }); + + const event = { + data: JSON.stringify({ status: 'success' }), + }; + + mockSocket.onmessage(event); + + expect(mockToastSuccess).not.toHaveBeenCalled(); + expect(mockToastError).not.toHaveBeenCalled(); + }); + + it('triggers WebSocket onopen without errors', async () => { + render(); + + await waitFor(() => { + expect((global as any).WebSocket).toHaveBeenCalled(); + }); + + expect(() => mockSocket.onopen()).not.toThrow(); + }); + + it('handles WebSocket error event without crashing', async () => { + render(); + + await waitFor(() => { + expect((global as any).WebSocket).toHaveBeenCalled(); + }); + + expect(() => mockSocket.onerror('test error')).not.toThrow(); + }); + + it('handles WebSocket onclose without crashing', async () => { + render(); + + await waitFor(() => { + expect((global as any).WebSocket).toHaveBeenCalled(); + }); + + expect(() => mockSocket.onclose()).not.toThrow(); + }); + }); + + // Onboarding Tour Tests + + describe('Onboarding Tour', () => { + it('does not start the tour if userInfo.email is missing', async () => { + jest.useFakeTimers(); + localStorage.clear(); + + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + uuid: 'mocked-uuid', + name: 'Mock User', + encryption_secret: 'mock-secret', + picture: 'mocked-pic', + // email missing + }), + }); + + render(); + + await waitFor(() => { + expect((global as any).WebSocket).not.toHaveBeenCalled(); + }); + + expect(mockDrive).not.toHaveBeenCalled(); + + expect(localStorage.length).toBe(0); + + jest.useRealTimers(); + }); + + it('starts the tour for first-time users', async () => { + jest.useFakeTimers(); + + mockFetchTaskwarriorTasks.mockResolvedValueOnce([]); + + localStorage.clear(); + render(); + + await waitFor(() => { + expect((global as any).WebSocket).toHaveBeenCalledTimes(1); + }); + + jest.runAllTimers(); + + expect(mockDrive).toHaveBeenCalledTimes(1); + + jest.useRealTimers(); + }); + + it('does not start the tour multiple times if already started', async () => { + jest.useFakeTimers(); + localStorage.clear(); + + const { rerender } = render(); + + await waitFor(() => { + expect((global as any).WebSocket).toHaveBeenCalledTimes(1); + }); + + jest.runAllTimers(); + + const initialDriveCalls = mockDrive.mock.calls.length; + + rerender(); + + jest.runAllTimers(); + + expect(mockDrive).toHaveBeenCalledTimes(initialDriveCalls); + + jest.useRealTimers(); + }); + + it('does NOT start the tour if already seen', async () => { + mockFetchTaskwarriorTasks.mockResolvedValueOnce([]); + + localStorage.setItem('ccsync-home-tour-mocked-email', 'seen'); + + render(); + + await waitFor(() => { + expect((global as any).WebSocket).toHaveBeenCalledTimes(1); + }); + + jest.runAllTimers(); + + expect(mockDrive).not.toHaveBeenCalled(); + }); + + it('marks tour as seen when onDestroyed is called', async () => { + mockFetchTaskwarriorTasks.mockResolvedValueOnce([]); + + localStorage.clear(); + + render(); + + await waitFor(() => { + expect((global as any).WebSocket).toHaveBeenCalledTimes(1); + }); + + jest.runAllTimers(); + + expect(lastDriverConfig).not.toBeNull(); + + lastDriverConfig.onDestroyed(); + + expect(localStorage.getItem('ccsync-home-tour-mocked-email')).toBe( + 'seen' + ); + }); + + it('marks tour as seen when onCloseClick is called', async () => { + mockFetchTaskwarriorTasks.mockResolvedValueOnce([]); + + localStorage.clear(); + + render(); + + await waitFor(() => { + expect((global as any).WebSocket).toHaveBeenCalledTimes(1); + }); + + jest.runAllTimers(); + + expect(lastDriverConfig).not.toBeNull(); + + lastDriverConfig.onCloseClick(); + + expect(localStorage.getItem('ccsync-home-tour-mocked-email')).toBe( + 'seen' + ); + + expect(mockDestroy).toHaveBeenCalledTimes(1); + }); + + it('clears the tour timeout on component unmount', async () => { + mockFetchTaskwarriorTasks.mockResolvedValueOnce([]); + + localStorage.clear(); + + const { unmount } = render(); + + await waitFor(() => { + expect((global as any).WebSocket).toHaveBeenCalledTimes(1); + }); + + unmount(); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + expect(mockDrive).not.toHaveBeenCalled(); + }); + + it('adds skip button inside popover and handles its click', async () => { + jest.useFakeTimers(); + localStorage.clear(); + + mockFetchTaskwarriorTasks.mockResolvedValueOnce([]); + + render(); + + await waitFor(() => { + expect((global as any).WebSocket).toHaveBeenCalledTimes(1); + }); + + jest.runAllTimers(); + + const mockPopover = { + footerButtons: document.createElement('div'), + }; + + lastDriverConfig.onPopoverRender(mockPopover); + + const skipBtn = mockPopover.footerButtons.querySelector( + '[data-driver-skip-button]' + ) as HTMLButtonElement; + expect(skipBtn).toBeTruthy(); + expect(skipBtn?.textContent).toBe('Skip'); + + skipBtn?.click(); + + expect(localStorage.getItem('ccsync-home-tour-mocked-email')).toBe( + 'seen' + ); + + expect(mockDestroy).toHaveBeenCalled(); + + jest.useRealTimers(); + }); + + it('does not add skip button twice', () => { + const mockPopover = { + footerButtons: document.createElement('div'), + }; + + lastDriverConfig.onPopoverRender(mockPopover); + lastDriverConfig.onPopoverRender(mockPopover); + + const skipButtons = mockPopover.footerButtons.querySelectorAll( + '[data-driver-skip-button]' + ); + + expect(skipButtons.length).toBe(1); + }); + }); + + describe('Rendering', () => { + it('renders all required section IDs', async () => { + mockFetchTaskwarriorTasks.mockResolvedValueOnce([]); + + render(); + + // Wait for user info to load + await waitFor(() => { + expect(document.getElementById('home-navbar')).toBeTruthy(); + }); + + expect(document.getElementById('home-navbar')).toBeTruthy(); + expect(document.getElementById('home-hero')).toBeTruthy(); + expect(document.getElementById('home-tasks')).toBeTruthy(); + expect(document.getElementById('home-setup-guide')).toBeTruthy(); + expect(document.getElementById('home-faq')).toBeTruthy(); + }); + }); }); From 763d181bc944a990129d6973a0164878e363d83b Mon Sep 17 00:00:00 2001 From: Neeraj-gagat Date: Wed, 26 Nov 2025 18:01:39 +0530 Subject: [PATCH 4/4] fixed tests --- .../components/HomeComponents/Tasks/Tasks.tsx | 24 ++++++------ .../Tasks/__tests__/Tasks.test.tsx | 39 ++++++++++++------- 2 files changed, 38 insertions(+), 25 deletions(-) diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index 103a4dc0..bd38422c 100644 --- a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx +++ b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx @@ -798,8 +798,7 @@ export const Tasks = ( `mark-task-complete-${task.id}` ); confirmBtn?.click(); - setIsDialogOpen(false); - }, 150); + }, 200); } else { if (_isDialogOpen) { const task = currentTasks[selectedIndex]; @@ -808,7 +807,6 @@ export const Tasks = ( `mark-task-complete-${task.id}` ); confirmBtn?.click(); - setIsDialogOpen(false); } } }); @@ -826,8 +824,7 @@ export const Tasks = ( `mark-task-as-deleted-${task.id}` ); confirmBtn?.click(); - setIsDialogOpen(false); - }, 150); + }, 200); } else { if (_isDialogOpen) { const task = currentTasks[selectedIndex]; @@ -836,7 +833,6 @@ export const Tasks = ( `mark-task-as-deleted-${task.id}` ); confirmBtn?.click(); - setIsDialogOpen(false); } } }); @@ -1968,6 +1964,7 @@ export const Tasks = ( onClick={() => handleSaveTags(task) } + aria-label="Save tags" > @@ -1975,6 +1972,7 @@ export const Tasks = ( variant="ghost" size="icon" onClick={handleCancelTags} + aria-label="Cancel editing tags" > @@ -2178,14 +2176,15 @@ export const Tasks = ( @@ -2225,14 +2224,15 @@ export const Tasks = ( diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx index e4622342..c7fa95ce 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx @@ -1,4 +1,11 @@ -import { render, screen, fireEvent, act, within } from '@testing-library/react'; +import { + render, + screen, + fireEvent, + act, + within, + waitFor, +} from '@testing-library/react'; import { Tasks } from '../Tasks'; // Mock props for the Tasks component @@ -225,13 +232,17 @@ describe('Tasks Component', () => { expect(await screen.findByText('addedtag')).toBeInTheDocument(); - const actionContainer = editInput.parentElement as HTMLElement; - const actionButtons = within(actionContainer).getAllByRole('button'); - fireEvent.click(actionButtons[0]); + const saveButton = await screen.findByRole('button', { + name: /save tags/i, + }); + fireEvent.click(saveButton); - const hooks = require('../hooks'); - expect(hooks.editTaskOnBackend).toHaveBeenCalled(); + await waitFor(() => { + const hooks = require('../hooks'); + expect(hooks.editTaskOnBackend).toHaveBeenCalled(); + }); + const hooks = require('../hooks'); const callArg = hooks.editTaskOnBackend.mock.calls[0][0]; expect(callArg.tags).toEqual(expect.arrayContaining(['tag1', 'addedtag'])); }); @@ -267,17 +278,19 @@ describe('Tasks Component', () => { const removeButton = within(badgeContainer).getByText('✖'); fireEvent.click(removeButton); - expect(screen.queryByText('tag2')).not.toBeInTheDocument(); + expect(screen.queryByText('tag1')).not.toBeInTheDocument(); - const actionContainer = editInput.parentElement as HTMLElement; - - const actionButtons = within(actionContainer).getAllByRole('button'); + const saveButton = await screen.findByRole('button', { + name: /save tags/i, + }); + fireEvent.click(saveButton); - fireEvent.click(actionButtons[0]); + await waitFor(() => { + const hooks = require('../hooks'); + expect(hooks.editTaskOnBackend).toHaveBeenCalled(); + }); const hooks = require('../hooks'); - expect(hooks.editTaskOnBackend).toHaveBeenCalled(); - const callArg = hooks.editTaskOnBackend.mock.calls[0][0]; expect(callArg.tags).toEqual(expect.arrayContaining(['newtag', '-tag1']));