diff --git a/frontend/jest.config.cjs b/frontend/jest.config.cjs index a018c6e0..d0d317fb 100644 --- a/frontend/jest.config.cjs +++ b/frontend/jest.config.cjs @@ -11,7 +11,10 @@ module.exports = { transformIgnorePatterns: ['/node_modules/(?!react-toastify)'], setupFilesAfterEnv: ['/jest.setup.ts'], collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'], - testMatch: ['**/__tests__/**/*.{ts,tsx}', '**/?(*.)+(spec|test).{ts,tsx}'], + testMatch: [ + '**/__tests__/**/*.+(test|spec).{ts,tsx}', + '**/?(*.)+(spec|test).{ts,tsx}', + ], transform: { '^.+\\.tsx?$': [ 'ts-jest', diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index 43b53077..c500fb29 100644 --- a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx +++ b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx @@ -116,7 +116,9 @@ export const Tasks = ( ); const [autoSyncOnEdit, setAutoSyncOnEdit] = useState(true); const tableRef = useRef(null); - const [hotkeysEnabled, setHotkeysEnabled] = useState(false); + const [isMouseOver, setIsMouseOver] = useState(false); + const [activeContext, setActiveContext] = useState(null); + const hotkeysEnabled = activeContext === 'TASKS' || isMouseOver; const [selectedIndex, setSelectedIndex] = useState(0); const { state: editState, @@ -151,8 +153,27 @@ export const Tasks = ( const paginate = (pageNumber: number) => setCurrentPage(pageNumber); const totalPages = Math.ceil(tempTasks.length / tasksPerPage) || 1; + useEffect(() => { + const handleGlobalPointerDown = (e: PointerEvent) => { + if (tableRef.current && !tableRef.current.contains(e.target as Node)) { + setActiveContext(null); + } + }; + + document.addEventListener('pointerdown', handleGlobalPointerDown, true); + return () => { + document.removeEventListener( + 'pointerdown', + handleGlobalPointerDown, + true + ); + }; + }, []); + useEffect(() => { const handler = (e: KeyboardEvent) => { + if (!hotkeysEnabled) return; + const target = e.target as HTMLElement; if ( target instanceof HTMLInputElement || @@ -1022,83 +1043,115 @@ 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(['p'], () => { - if (!showReports) { - document.getElementById('projects')?.click(); - } - }); - useHotkeys(['s'], () => { - if (!showReports) { - document.getElementById('status')?.click(); - } - }); - useHotkeys(['t'], () => { - if (!showReports) { - document.getElementById('tags')?.click(); - } - }); - useHotkeys(['c'], () => { - if (!showReports && !_isDialogOpen) { - const task = currentTasks[selectedIndex]; - if (!task) return; - const openBtn = document.getElementById(`task-row-${task.id}`); - openBtn?.click(); - setTimeout(() => { - const confirmBtn = document.getElementById( - `mark-task-complete-${task.id}` - ); - confirmBtn?.click(); - }, 200); - } else { - if (_isDialogOpen) { + useHotkeys( + ['f'], + () => { + if (!showReports) { + document.getElementById('search')?.focus(); + } + }, + hotkeysEnabled + ); + useHotkeys( + ['a'], + () => { + if (!showReports) { + document.getElementById('add-new-task')?.click(); + } + }, + hotkeysEnabled + ); + useHotkeys( + ['r'], + () => { + if (!showReports) { + document.getElementById('sync-task')?.click(); + } + }, + hotkeysEnabled + ); + useHotkeys( + ['p'], + () => { + if (!showReports) { + document.getElementById('projects')?.click(); + } + }, + hotkeysEnabled + ); + useHotkeys( + ['s'], + () => { + if (!showReports) { + document.getElementById('status')?.click(); + } + }, + hotkeysEnabled + ); + useHotkeys( + ['t'], + () => { + if (!showReports) { + document.getElementById('tags')?.click(); + } + }, + hotkeysEnabled + ); + useHotkeys( + ['c'], + () => { + if (!showReports && !_isDialogOpen) { const task = currentTasks[selectedIndex]; if (!task) return; - const confirmBtn = document.getElementById( - `mark-task-complete-${task.id}` - ); - confirmBtn?.click(); + const openBtn = document.getElementById(`task-row-${task.id}`); + openBtn?.click(); + 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(); + } } - } - }); + }, + hotkeysEnabled + ); - useHotkeys(['d'], () => { - if (!showReports && !_isDialogOpen) { - const task = currentTasks[selectedIndex]; - if (!task) return; - const openBtn = document.getElementById(`task-row-${task.id}`); - openBtn?.click(); - setTimeout(() => { - const confirmBtn = document.getElementById( - `mark-task-as-deleted-${task.id}` - ); - confirmBtn?.click(); - }, 200); - } else { - if (_isDialogOpen) { + useHotkeys( + ['d'], + () => { + if (!showReports && !_isDialogOpen) { const task = currentTasks[selectedIndex]; if (!task) return; - const confirmBtn = document.getElementById( - `mark-task-as-deleted-${task.id}` - ); - confirmBtn?.click(); + const openBtn = document.getElementById(`task-row-${task.id}`); + openBtn?.click(); + 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(); + } } - } - }); + }, + hotkeysEnabled + ); return (
setHotkeysEnabled(true)} - onMouseLeave={() => setHotkeysEnabled(false)} + data-testid="tasks-table-container" + onPointerDown={() => setActiveContext('TASKS')} + onMouseEnter={() => setIsMouseOver(true)} + onMouseLeave={() => setIsMouseOver(false)} > {tasks.length != 0 ? ( <> diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx index a9df099f..c43be02d 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx @@ -7,6 +7,7 @@ import { waitFor, } from '@testing-library/react'; import { Tasks } from '../Tasks'; +import { enableHotkeysViaHover } from './helper'; // Mock props for the Tasks component const mockProps = { @@ -1747,6 +1748,7 @@ describe('Tasks Component', () => { render(); await screen.findByText('Task 1'); const taskRows = screen.getAllByTestId(/task-row-/); + enableHotkeysViaHover(); expect(taskRows[0]).toHaveAttribute('data-selected', 'true'); expect(taskRows[1]).toHaveAttribute('data-selected', 'false'); @@ -1761,6 +1763,7 @@ describe('Tasks Component', () => { render(); await screen.findByText('Task 1'); const taskRows = screen.getAllByTestId(/task-row-/); + enableHotkeysViaHover(); fireEvent.keyDown(window, { key: 'ArrowDown' }); fireEvent.keyDown(window, { key: 'ArrowDown' }); @@ -1779,6 +1782,7 @@ describe('Tasks Component', () => { await screen.findByText('Task 1'); const taskRows = screen.getAllByTestId(/task-row-/); + enableHotkeysViaHover(); for (let i = 0; i < taskRows.length + 2; i++) { fireEvent.keyDown(window, { key: 'ArrowDown' }); @@ -1795,6 +1799,7 @@ describe('Tasks Component', () => { await screen.findByText('Task 1'); const taskRows = screen.getAllByTestId(/task-row-/); const middleIndex = Math.floor(taskRows.length / 2); + enableHotkeysViaHover(); for (let i = 0; i < middleIndex; i++) { fireEvent.keyDown(window, { key: 'ArrowDown' }); @@ -1806,131 +1811,222 @@ describe('Tasks Component', () => { expect(taskRows[0]).toHaveAttribute('data-selected', 'true'); }); }); + }); + + describe('Hotkey Shortcuts', () => { + test('pressing "a" opens the Add Task dialog', async () => { + render(); + await screen.findByText('Task 1'); + enableHotkeysViaHover(); + + fireEvent.keyDown(window, { key: 'a' }); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText(/add a new task/i)).toBeInTheDocument(); + }); - describe('Hotkey Shortcuts', () => { - test('pressing "a" opens the Add Task dialog', async () => { + test.each([ + ['c', 'complete', 'markTaskAsCompleted'], + ['d', 'delete', 'markTaskAsDeleted'], + ])( + 'pressing "%s" opens confirmation dialog and triggers %s on confirmation', + async (key, _action, fn) => { render(); await screen.findByText('Task 1'); + enableHotkeysViaHover(); - fireEvent.keyDown(window, { key: 'a' }); - - const dialog = await screen.findByRole('dialog'); - expect(within(dialog).getByText(/add a new task/i)).toBeInTheDocument(); - }); + fireEvent.keyDown(window, { key }); - test.each([ - ['c', 'complete', 'markTaskAsCompleted'], - ['d', 'delete', 'markTaskAsDeleted'], - ])( - 'pressing %s attempts to open task dialog and trigger %s action', - async (key, _action, fn) => { - render(); - await screen.findByText('Task 1'); + const yesButton = await screen.findByRole('button', { + name: /^yes$/i, + }); + fireEvent.click(yesButton); - fireEvent.keyDown(window, { key }); + expect(jest.requireMock('../tasks-utils')[fn]).toHaveBeenCalled(); + } + ); - const yesButton = await screen.findByRole('button', { - name: /^yes$/i, - }); - fireEvent.click(yesButton); + test('pressing "Enter" key opens the selected task dialog', async () => { + render(); + await screen.findByText('Task 1'); + enableHotkeysViaHover(); - expect(jest.requireMock('../tasks-utils')[fn]).toHaveBeenCalled(); - } + const taskRows = screen.getAllByTestId(/task-row-/); + const selectedRow = taskRows.find( + (row) => row.getAttribute('data-selected') === 'true' ); + const selectedTaskId = selectedRow + ?.getAttribute('data-testid') + ?.replace('task-row-', ''); - test('pressing "Enter" key opens the selected task dialog', async () => { - render(); - await screen.findByText('Task 1'); + fireEvent.keyDown(window, { key: 'Enter' }); - const taskRows = screen.getAllByTestId(/task-row-/); - const selectedRow = taskRows.find( - (row) => row.getAttribute('data-selected') === 'true' - ); - const selectedTaskId = selectedRow - ?.getAttribute('data-testid') - ?.replace('task-row-', ''); + const dialog = await screen.findByRole('dialog'); + const idCell = within(dialog).getByText('ID:').closest('tr'); + expect(within(idCell!).getByText(selectedTaskId!)).toBeInTheDocument(); + }); + + test('pressing "f" focuses the search input', async () => { + render(); + await screen.findByText('Task 1'); + enableHotkeysViaHover(); - fireEvent.keyDown(window, { key: 'Enter' }); + fireEvent.keyDown(window, { key: 'f' }); - const dialog = await screen.findByRole('dialog'); - const idCell = within(dialog).getByText('ID:').closest('tr'); - expect(within(idCell!).getByText(selectedTaskId!)).toBeInTheDocument(); - }); + const searchInput = screen.getByPlaceholderText('Search tasks...'); + expect(document.activeElement).toBe(searchInput); + }); - test('pressing "f" focuses the search input', async () => { - render(); - await screen.findByText('Task 1'); + test('pressing "r" triggers sync', async () => { + render(); + await screen.findByText('Task 1'); + enableHotkeysViaHover(); - fireEvent.keyDown(window, { key: 'f' }); + fireEvent.keyDown(window, { key: 'r' }); - const searchInput = screen.getByPlaceholderText('Search tasks...'); - expect(document.activeElement).toBe(searchInput); - }); + expect(mockProps.setIsLoading).toHaveBeenCalledWith(true); + expect( + jest.requireMock('../hooks').fetchTaskwarriorTasks + ).toHaveBeenCalled(); + }); - test('pressing "r" triggers sync', async () => { - render(); - await screen.findByText('Task 1'); + test.each([ + ['p', 'projects'], + ['s', 'status'], + ['t', 'tags'], + ])('pressing "%s" opens the %s filter', async (key, filterName) => { + render(); + await screen.findByText('Task 1'); + enableHotkeysViaHover(); - fireEvent.keyDown(window, { key: 'r' }); + const filterButton = screen.getByTestId(`multi-select-${filterName}`); + expect(filterButton).toHaveAttribute('aria-expanded', 'false'); - expect(mockProps.setIsLoading).toHaveBeenCalledWith(true); - expect( - jest.requireMock('../hooks').fetchTaskwarriorTasks - ).toHaveBeenCalled(); - }); + fireEvent.keyDown(window, { key }); + + expect(filterButton).toHaveAttribute('aria-expanded', 'true'); + }); + + test('hotkeys are disabled when input is focused', async () => { + render(); + await screen.findByText('Task 1'); + enableHotkeysViaHover(); + + const searchInput = screen.getByPlaceholderText('Search tasks...'); + searchInput.focus(); + + fireEvent.keyDown(searchInput, { key: 'r' }); + + expect(mockProps.setIsLoading).not.toHaveBeenCalledWith(true); + }); + }); - test.each([ - ['p', 'projects'], - ['s', 'status'], - ['t', 'tags'], - ])('pressing "%s" opens the %s filter', async (key, filterName) => { + describe('Complete/Delete Hotkeys When Dialog Open', () => { + test.each([ + ['c', 'complete', 'markTaskAsCompleted'], + ['d', 'delete', 'markTaskAsDeleted'], + ])( + 'pressing "%s" opens confirmation dialog and triggers %s on confirmation', + async (key, _action, fn) => { render(); - await screen.findByText('Task 1'); - const filterButton = screen.getByTestId(`multi-select-${filterName}`); - expect(filterButton).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(await screen.findByText('Task 1')); + await screen.findByRole('dialog'); + enableHotkeysViaHover(); fireEvent.keyDown(window, { key }); - expect(filterButton).toHaveAttribute('aria-expanded', 'true'); - }); + const yesButton = await screen.findByRole('button', { + name: /^yes$/i, + }); + fireEvent.click(yesButton); - test('hotkeys are disabled when input is focused', async () => { - render(); - await screen.findByText('Task 1'); + expect(jest.requireMock('../tasks-utils')[fn]).toHaveBeenCalled(); + } + ); + }); + + describe('Hotkeys Enable/Disable on Hover and Active Context', () => { + test('hotkeys are disabled by default (mouse not over task table)', async () => { + render(); + await screen.findByText('Task 1'); - const searchInput = screen.getByPlaceholderText('Search tasks...'); - searchInput.focus(); + fireEvent.keyDown(window, { key: 'f' }); - fireEvent.keyDown(searchInput, { key: 'r' }); + const searchInput = screen.getByPlaceholderText('Search tasks...'); + expect(document.activeElement).not.toBe(searchInput); + }); - expect(mockProps.setIsLoading).not.toHaveBeenCalledWith(true); - }); + test('hotkeys are enabled when mouse enters task table', async () => { + render(); + await screen.findByText('Task 1'); + + const taskContainer = screen.getByTestId('tasks-table-container'); + fireEvent.mouseEnter(taskContainer); + + fireEvent.keyDown(window, { key: 'f' }); + + const searchInput = screen.getByPlaceholderText('Search tasks...'); + expect(document.activeElement).toBe(searchInput); }); - describe('Complete/Delete Hotkeys When Dialog Open', () => { - test.each([ - ['c', 'complete', 'markTaskAsCompleted'], - ['d', 'delete', 'markTaskAsDeleted'], - ])( - 'pressing "%s" with dialog open triggers %s action on confirmation', - async (key, _action, fn) => { - render(); - await screen.findByText('Task 1'); + test('hotkeys are disabled when mouse leaves task table', async () => { + render(); + await screen.findByText('Task 1'); - fireEvent.click(screen.getByText('Task 1')); - await screen.findByRole('dialog'); + const taskContainer = screen.getByTestId('tasks-table-container'); - fireEvent.keyDown(window, { key }); + fireEvent.mouseEnter(taskContainer); + fireEvent.mouseLeave(taskContainer); - const yesButton = await screen.findByRole('button', { - name: /^yes$/i, - }); - fireEvent.click(yesButton); + fireEvent.keyDown(window, { key: 'f' }); - expect(jest.requireMock('../tasks-utils')[fn]).toHaveBeenCalled(); - } - ); + const searchInput = screen.getByPlaceholderText('Search tasks...'); + expect(document.activeElement).not.toBe(searchInput); + }); + + test('hotkeys are enabled when user clicks/taps on task table', async () => { + render(); + await screen.findByText('Task 1'); + + const taskContainer = screen.getByTestId('tasks-table-container'); + + fireEvent.pointerDown(taskContainer); + fireEvent.keyDown(window, { key: 'f' }); + + const searchInput = screen.getByPlaceholderText('Search tasks...'); + + expect(document.activeElement).toBe(searchInput); + }); + + test('hotkeys remain enabled after mouse leaves if user clicked on task table', async () => { + render(); + await screen.findByText('Task 1'); + + const taskContainer = screen.getByTestId('tasks-table-container'); + + fireEvent.pointerDown(taskContainer); + fireEvent.mouseLeave(taskContainer); + fireEvent.keyDown(window, { key: 'f' }); + + const searchInput = screen.getByPlaceholderText('Search tasks...'); + + expect(document.activeElement).toBe(searchInput); + }); + + test('hotkeys are disabled when user clicks outside task table', async () => { + render(); + await screen.findByText('Task 1'); + + const taskContainer = screen.getByTestId('tasks-table-container'); + + fireEvent.pointerDown(taskContainer); + fireEvent.pointerDown(document.body); + fireEvent.keyDown(window, { key: 'f' }); + + const searchInput = screen.getByPlaceholderText('Search tasks...'); + + expect(document.activeElement).not.toBe(searchInput); }); }); }); diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/helper.ts b/frontend/src/components/HomeComponents/Tasks/__tests__/helper.ts new file mode 100644 index 00000000..657a84db --- /dev/null +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/helper.ts @@ -0,0 +1,6 @@ +import { screen, fireEvent } from '@testing-library/react'; + +export const enableHotkeysViaHover = () => { + const taskContainer = screen.getByTestId('tasks-table-container'); + fireEvent.mouseEnter(taskContainer); +}; diff --git a/frontend/src/components/utils/__tests__/use-hotkeys.test.ts b/frontend/src/components/utils/__tests__/use-hotkeys.test.ts index 25b55a09..5c5fcea4 100644 --- a/frontend/src/components/utils/__tests__/use-hotkeys.test.ts +++ b/frontend/src/components/utils/__tests__/use-hotkeys.test.ts @@ -216,4 +216,43 @@ describe('useHotkeys', () => { removeEventListenerSpy.mockRestore(); }); + + it('should call callback when enabled is true', () => { + renderHook(() => useHotkeys(['s'], callback, true)); + + const event = new KeyboardEvent('keydown', { + key: 's', + bubbles: true, + }); + + window.dispatchEvent(event); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should not call callback when enabled is false', () => { + renderHook(() => useHotkeys(['s'], callback, false)); + + const event = new KeyboardEvent('keydown', { + key: 's', + bubbles: true, + }); + + window.dispatchEvent(event); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should default enabled to true when not provided', () => { + renderHook(() => useHotkeys(['s'], callback)); + + const event = new KeyboardEvent('keydown', { + key: 's', + bubbles: true, + }); + + window.dispatchEvent(event); + + expect(callback).toHaveBeenCalledTimes(1); + }); }); diff --git a/frontend/src/components/utils/use-hotkeys.ts b/frontend/src/components/utils/use-hotkeys.ts index e10612b7..6aedaf3c 100644 --- a/frontend/src/components/utils/use-hotkeys.ts +++ b/frontend/src/components/utils/use-hotkeys.ts @@ -1,7 +1,13 @@ import { useEffect } from 'react'; -export function useHotkeys(keys: string[], callback: () => void) { +export function useHotkeys( + keys: string[], + callback: () => void, + enabled: boolean = true +) { useEffect(() => { + if (!enabled) return; + const handler = (e: KeyboardEvent) => { const target = e.target as HTMLElement; if ( @@ -29,5 +35,5 @@ export function useHotkeys(keys: string[], callback: () => void) { window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); - }, [keys, callback]); + }, [keys, callback, enabled]); }