Skip to content

Commit 2cfac47

Browse files
authored
feat: add keyboard shortcut keys (#214)
* added keyboard shortcut keys * added-keys with keyboard-shortcuts * fixed merge conflicts * fixed tests
1 parent d948a97 commit 2cfac47

6 files changed

Lines changed: 230 additions & 36 deletions

File tree

frontend/src/components/HomeComponents/Tasks/Tasks.tsx

Lines changed: 143 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { useEffect, useState, useCallback } from 'react';
1+
import { useEffect, useState, useCallback, useRef } from 'react';
22
import { Task } from '../../utils/types';
33
import { ReportsView } from './ReportsView';
44
import Fuse from 'fuse.js';
5+
import { useHotkeys } from '@/hooks/useHotkeys';
56
import {
67
Table,
78
TableBody,
@@ -72,6 +73,7 @@ import { debounce } from '@/components/utils/utils';
7273
import { DatePicker } from '@/components/ui/date-picker';
7374
import { format } from 'date-fns';
7475
import { Taskskeleton } from './Task-Skeleton';
76+
import { Key } from '@/components/ui/key-button';
7577

7678
const db = new TasksDatabase();
7779
export let syncTasksWithTwAndDb: () => any;
@@ -135,6 +137,9 @@ export const Tasks = (
135137
const [searchTerm, setSearchTerm] = useState('');
136138
const [debouncedTerm, setDebouncedTerm] = useState('');
137139
const [lastSyncTime, setLastSyncTime] = useState<number | null>(null);
140+
const tableRef = useRef<HTMLDivElement>(null);
141+
const [hotkeysEnabled, setHotkeysEnabled] = useState(false);
142+
const [selectedIndex, setSelectedIndex] = useState(0);
138143

139144
const isOverdue = (due?: string) => {
140145
if (!due) return false;
@@ -182,6 +187,42 @@ export const Tasks = (
182187
const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
183188
const totalPages = Math.ceil(tempTasks.length / tasksPerPage) || 1;
184189

190+
useEffect(() => {
191+
const handler = (e: KeyboardEvent) => {
192+
const target = e.target as HTMLElement;
193+
if (
194+
target instanceof HTMLInputElement ||
195+
target instanceof HTMLTextAreaElement ||
196+
target instanceof HTMLSelectElement ||
197+
_isDialogOpen ||
198+
target.isContentEditable
199+
) {
200+
return;
201+
}
202+
203+
if (e.key === 'ArrowDown') {
204+
e.preventDefault();
205+
setSelectedIndex((prev) => Math.min(prev + 1, currentTasks.length - 1));
206+
}
207+
208+
if (e.key === 'ArrowUp') {
209+
e.preventDefault();
210+
setSelectedIndex((prev) => Math.max(prev - 1, 0));
211+
}
212+
213+
if (e.key === 'e') {
214+
e.preventDefault();
215+
const task = currentTasks[selectedIndex];
216+
if (task) {
217+
document.getElementById(`task-row-${task.id}`)?.click();
218+
}
219+
}
220+
};
221+
222+
window.addEventListener('keydown', handler, true);
223+
return () => window.removeEventListener('keydown', handler, true);
224+
}, [hotkeysEnabled, selectedIndex, currentTasks]);
225+
185226
useEffect(() => {
186227
const hashedKey = hashKey('tasksPerPage', props.email);
187228
const storedTasksPerPage = localStorage.getItem(hashedKey);
@@ -729,6 +770,73 @@ export const Tasks = (
729770
}
730771
};
731772

773+
useHotkeys(['f'], () => {
774+
if (!showReports) {
775+
document.getElementById('search')?.focus();
776+
}
777+
});
778+
useHotkeys(['a'], () => {
779+
if (!showReports) {
780+
document.getElementById('add-new-task')?.click();
781+
}
782+
});
783+
useHotkeys(['r'], () => {
784+
if (!showReports) {
785+
document.getElementById('sync-task')?.click();
786+
}
787+
});
788+
useHotkeys(['c'], () => {
789+
if (!showReports && !_isDialogOpen) {
790+
const task = currentTasks[selectedIndex];
791+
if (!task) return;
792+
// Step 1
793+
const openBtn = document.getElementById(`task-row-${task.id}`);
794+
openBtn?.click();
795+
// Step 2
796+
setTimeout(() => {
797+
const confirmBtn = document.getElementById(
798+
`mark-task-complete-${task.id}`
799+
);
800+
confirmBtn?.click();
801+
}, 200);
802+
} else {
803+
if (_isDialogOpen) {
804+
const task = currentTasks[selectedIndex];
805+
if (!task) return;
806+
const confirmBtn = document.getElementById(
807+
`mark-task-complete-${task.id}`
808+
);
809+
confirmBtn?.click();
810+
}
811+
}
812+
});
813+
814+
useHotkeys(['d'], () => {
815+
if (!showReports && !_isDialogOpen) {
816+
const task = currentTasks[selectedIndex];
817+
if (!task) return;
818+
// Step 1
819+
const openBtn = document.getElementById(`task-row-${task.id}`);
820+
openBtn?.click();
821+
// Step 2
822+
setTimeout(() => {
823+
const confirmBtn = document.getElementById(
824+
`mark-task-as-deleted-${task.id}`
825+
);
826+
confirmBtn?.click();
827+
}, 200);
828+
} else {
829+
if (_isDialogOpen) {
830+
const task = currentTasks[selectedIndex];
831+
if (!task) return;
832+
const confirmBtn = document.getElementById(
833+
`mark-task-as-deleted-${task.id}`
834+
);
835+
confirmBtn?.click();
836+
}
837+
}
838+
});
839+
732840
return (
733841
<section
734842
id="tasks"
@@ -779,7 +887,11 @@ export const Tasks = (
779887
{showReports ? (
780888
<ReportsView tasks={tasks} />
781889
) : (
782-
<>
890+
<div
891+
ref={tableRef}
892+
onMouseEnter={() => setHotkeysEnabled(true)}
893+
onMouseLeave={() => setHotkeysEnabled(false)}
894+
>
783895
{tasks.length != 0 ? (
784896
<>
785897
<div className="mt-10 pl-1 md:pl-4 pr-1 md:pr-4 bg-muted/50 border shadow-md rounded-lg p-4 h-full pt-12 pb-6">
@@ -793,12 +905,14 @@ export const Tasks = (
793905
</h3>
794906
<div className="hidden sm:flex flex-row w-full items-center gap-2 md:gap-4">
795907
<Input
908+
id="search"
796909
type="text"
797910
placeholder="Search tasks..."
798911
value={searchTerm}
799912
onChange={handleSearchChange}
800913
className="flex-1 min-w-[150px]"
801914
data-testid="task-search-bar"
915+
icon={<Key lable="f" />}
802916
/>
803917
<MultiSelectFilter
804918
title="Projects"
@@ -828,10 +942,12 @@ export const Tasks = (
828942
>
829943
<DialogTrigger asChild>
830944
<Button
945+
id="add-new-task"
831946
variant="outline"
832947
onClick={() => setIsAddTaskOpen(true)}
833948
>
834949
Add Task
950+
<Key lable="a" />
835951
</Button>
836952
</DialogTrigger>
837953
<DialogContent>
@@ -1013,13 +1129,15 @@ export const Tasks = (
10131129
</div>
10141130
<div className="flex flex-col items-end gap-2">
10151131
<Button
1132+
id="sync-task"
10161133
variant="outline"
10171134
onClick={() => (
10181135
props.setIsLoading(true),
10191136
syncTasksWithTwAndDb()
10201137
)}
10211138
>
10221139
Sync
1140+
<Key lable="r" />
10231141
</Button>
10241142
</div>
10251143
</div>
@@ -1079,7 +1197,11 @@ export const Tasks = (
10791197
key={index}
10801198
>
10811199
<DialogTrigger asChild>
1082-
<TableRow key={index} className="border-b">
1200+
<TableRow
1201+
id={`task-row-${task.id}`}
1202+
key={index}
1203+
className={`border-b cursor-pointer ${selectedIndex === index ? 'bg-muted/50' : ''}`}
1204+
>
10831205
{/* Display task details */}
10841206
<TableCell className="py-2">
10851207
<span
@@ -1842,13 +1964,15 @@ export const Tasks = (
18421964
onClick={() =>
18431965
handleSaveTags(task)
18441966
}
1967+
aria-label="Save tags"
18451968
>
18461969
<CheckIcon className="h-4 w-4 text-green-500" />
18471970
</Button>
18481971
<Button
18491972
variant="ghost"
18501973
size="icon"
18511974
onClick={handleCancelTags}
1975+
aria-label="Cancel editing tags"
18521976
>
18531977
<XIcon className="h-4 w-4 text-red-500" />
18541978
</Button>
@@ -2033,7 +2157,11 @@ export const Tasks = (
20332157
{task.status == 'pending' ? (
20342158
<Dialog>
20352159
<DialogTrigger asChild className="mr-5">
2036-
<Button>Mark As Completed</Button>
2160+
<Button
2161+
id={`mark-task-complete-${task.id}`}
2162+
>
2163+
Mark As Completed <Key lable="c" />
2164+
</Button>
20372165
</DialogTrigger>
20382166
<DialogContent>
20392167
<DialogTitle>
@@ -2048,14 +2176,15 @@ export const Tasks = (
20482176
<DialogClose asChild>
20492177
<Button
20502178
className="mr-5"
2051-
onClick={() =>
2179+
onClick={() => {
20522180
markTaskAsCompleted(
20532181
props.email,
20542182
props.encryptionSecret,
20552183
props.UUID,
20562184
task.uuid
2057-
)
2058-
}
2185+
);
2186+
setIsDialogOpen(false);
2187+
}}
20592188
>
20602189
Yes
20612190
</Button>
@@ -2074,10 +2203,12 @@ export const Tasks = (
20742203
<Dialog>
20752204
<DialogTrigger asChild>
20762205
<Button
2206+
id={`mark-task-as-deleted-${task.id}`}
20772207
className="mr-4"
20782208
variant={'destructive'}
20792209
>
20802210
<Trash2Icon />
2211+
<Key lable="d" />
20812212
</Button>
20822213
</DialogTrigger>
20832214
<DialogContent>
@@ -2093,14 +2224,15 @@ export const Tasks = (
20932224
<DialogClose asChild>
20942225
<Button
20952226
className="mr-5"
2096-
onClick={() =>
2227+
onClick={() => {
20972228
markTaskAsDeleted(
20982229
props.email,
20992230
props.encryptionSecret,
21002231
props.UUID,
21012232
task.uuid
2102-
)
2103-
}
2233+
);
2234+
setIsDialogOpen(false);
2235+
}}
21042236
>
21052237
Yes
21062238
</Button>
@@ -2358,7 +2490,7 @@ export const Tasks = (
23582490
</div>
23592491
</>
23602492
)}
2361-
</>
2493+
</div>
23622494
)}
23632495
</section>
23642496
);

frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { render, screen, fireEvent, act, within } from '@testing-library/react';
1+
import {
2+
render,
3+
screen,
4+
fireEvent,
5+
act,
6+
within,
7+
waitFor,
8+
} from '@testing-library/react';
29
import { Tasks } from '../Tasks';
310

411
// Mock props for the Tasks component
@@ -225,13 +232,17 @@ describe('Tasks Component', () => {
225232

226233
expect(await screen.findByText('addedtag')).toBeInTheDocument();
227234

228-
const actionContainer = editInput.parentElement as HTMLElement;
229-
const actionButtons = within(actionContainer).getAllByRole('button');
230-
fireEvent.click(actionButtons[0]);
235+
const saveButton = await screen.findByRole('button', {
236+
name: /save tags/i,
237+
});
238+
fireEvent.click(saveButton);
231239

232-
const hooks = require('../hooks');
233-
expect(hooks.editTaskOnBackend).toHaveBeenCalled();
240+
await waitFor(() => {
241+
const hooks = require('../hooks');
242+
expect(hooks.editTaskOnBackend).toHaveBeenCalled();
243+
});
234244

245+
const hooks = require('../hooks');
235246
const callArg = hooks.editTaskOnBackend.mock.calls[0][0];
236247
expect(callArg.tags).toEqual(expect.arrayContaining(['tag1', 'addedtag']));
237248
});
@@ -267,17 +278,19 @@ describe('Tasks Component', () => {
267278
const removeButton = within(badgeContainer).getByText('✖');
268279
fireEvent.click(removeButton);
269280

270-
expect(screen.queryByText('tag2')).not.toBeInTheDocument();
281+
expect(screen.queryByText('tag1')).not.toBeInTheDocument();
271282

272-
const actionContainer = editInput.parentElement as HTMLElement;
273-
274-
const actionButtons = within(actionContainer).getAllByRole('button');
283+
const saveButton = await screen.findByRole('button', {
284+
name: /save tags/i,
285+
});
286+
fireEvent.click(saveButton);
275287

276-
fireEvent.click(actionButtons[0]);
288+
await waitFor(() => {
289+
const hooks = require('../hooks');
290+
expect(hooks.editTaskOnBackend).toHaveBeenCalled();
291+
});
277292

278293
const hooks = require('../hooks');
279-
expect(hooks.editTaskOnBackend).toHaveBeenCalled();
280-
281294
const callArg = hooks.editTaskOnBackend.mock.calls[0][0];
282295

283296
expect(callArg.tags).toEqual(expect.arrayContaining(['newtag', '-tag1']));

frontend/src/components/ui/button.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const buttonVariants = cva(
2020
link: 'text-primary underline-offset-4 hover:underline',
2121
},
2222
size: {
23-
default: 'h-10 px-4 py-2',
23+
default: 'h-10 px-3 py-2',
2424
sm: 'h-9 rounded-md px-3',
2525
lg: 'h-11 rounded-md px-8',
2626
icon: 'h-10 w-10',

0 commit comments

Comments
 (0)