diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index 504963d7..bd38422c 100644 --- a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx +++ b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx @@ -1,7 +1,8 @@ -import { useEffect, useState, useCallback } from 'react'; +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, TableBody, @@ -72,6 +73,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; @@ -135,6 +137,9 @@ export const Tasks = ( const [searchTerm, setSearchTerm] = useState(''); const [debouncedTerm, setDebouncedTerm] = 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; @@ -182,6 +187,42 @@ 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 || + _isDialogOpen || + 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); @@ -729,6 +770,73 @@ 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 && !_isDialogOpen) { + 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(); + }, 200); + } else { + if (_isDialogOpen) { + const task = currentTasks[selectedIndex]; + if (!task) return; + const confirmBtn = document.getElementById( + `mark-task-complete-${task.id}` + ); + confirmBtn?.click(); + } + } + }); + + useHotkeys(['d'], () => { + if (!showReports && !_isDialogOpen) { + 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(); + }, 200); + } else { + if (_isDialogOpen) { + const task = currentTasks[selectedIndex]; + if (!task) return; + const confirmBtn = document.getElementById( + `mark-task-as-deleted-${task.id}` + ); + confirmBtn?.click(); + } + } + }); + return (
) : ( - <> +
setHotkeysEnabled(true)} + onMouseLeave={() => setHotkeysEnabled(false)} + > {tasks.length != 0 ? ( <>
@@ -793,12 +905,14 @@ export const Tasks = (
} /> @@ -1013,6 +1129,7 @@ export const Tasks = (
@@ -1079,7 +1197,11 @@ export const Tasks = ( key={index} > - + {/* Display task details */} handleSaveTags(task) } + aria-label="Save tags" > @@ -1849,6 +1972,7 @@ export const Tasks = ( variant="ghost" size="icon" onClick={handleCancelTags} + aria-label="Cancel editing tags" > @@ -2033,7 +2157,11 @@ export const Tasks = ( {task.status == 'pending' ? ( - + @@ -2048,14 +2176,15 @@ export const Tasks = ( @@ -2074,10 +2203,12 @@ export const Tasks = ( @@ -2093,14 +2224,15 @@ export const Tasks = ( @@ -2358,7 +2490,7 @@ 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'])); 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} */} + + ); +}; 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]); +}