diff --git a/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx b/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx index 18ed7f7e..fec867c9 100644 --- a/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx +++ b/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { DatePicker } from '@/components/ui/date-picker'; @@ -22,10 +22,18 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { AddTaskDialogProps } from '@/components/utils/types'; +import { + AddFieldKey, + AddTaskDialogProps, + FieldKey, +} from '@/components/utils/types'; import { format } from 'date-fns'; +import { ADDTASKDIALOG_FIELDS } from './constants'; +import { useAddTaskDialogKeyboard } from './UseTaskDialogKeyboard'; +import { useAddTaskDialogFocusMap } from './UseTaskDialogFocusMap'; export const AddTaskdialog = ({ + onOpenChange, isOpen, setIsOpen, newTask, @@ -42,6 +50,60 @@ export const AddTaskdialog = ({ const [dependencySearch, setDependencySearch] = useState(''); const [showDependencyResults, setShowDependencyResults] = useState(false); + const inputRefs = useRef< + Partial< + Record< + FieldKey, + HTMLInputElement | HTMLButtonElement | HTMLSelectElement | null + > + > + >({}); + const [focusedFieldIndex, setFocusedFieldIndex] = useState(0); + + const focusedField = ADDTASKDIALOG_FIELDS[focusedFieldIndex]; + + const handleDialogOpenChange = (open: boolean) => { + onOpenChange?.(open); + setIsOpen(open); + }; + + useEffect(() => { + const element = inputRefs.current[focusedField]; + if (!element) return; + + element.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }); + }, [focusedField]); + + const focusMap = useAddTaskDialogFocusMap({ + fields: ADDTASKDIALOG_FIELDS, + inputRefs: inputRefs as any, + }); + + useEffect(() => { + focusMap(focusedField); + }, [focusedField, focusMap]); + + const closeDialog = () => setIsOpen(false); + + const onEnter = (field: AddFieldKey) => { + const element = inputRefs.current[field]; + if (!element) return; + + element.focus(); + element.click(); + }; + + const handleDialogKeyDown = useAddTaskDialogKeyboard({ + fields: ADDTASKDIALOG_FIELDS, + focusedFieldIndex, + setFocusedFieldIndex, + onEnter, + closeDialog, + }); + const getFilteredTasks = () => { const availableTasks = allTasks.filter( (task) => @@ -117,9 +179,10 @@ export const AddTaskdialog = ({ }; return ( - + - + @@ -142,295 +205,342 @@ export const AddTaskdialog = ({ Fill in the details below to add a new task. -
-
- -
- - setNewTask({ - ...newTask, - description: e.target.value, - }) - } - required - className="col-span-6" - /> +
+
+
+ +
+ (inputRefs.current.description = element)} + id="description" + name="description" + type="text" + value={newTask.description} + onChange={(e) => + setNewTask({ + ...newTask, + description: e.target.value, + }) + } + required + className="col-span-6" + /> +
-
-
- -
- + Priority + +
+ +
-
-
- -
- { + if (value === '__CREATE_NEW__') { + setIsCreatingNewProject(true); + setNewTask({ ...newTask, project: '' }); + } else if (value === '__NONE__') { + setIsCreatingNewProject(false); + setNewTask({ ...newTask, project: '' }); + } else { + setIsCreatingNewProject(false); + setNewTask({ ...newTask, project: value }); } - > - {isCreatingNewProject - ? newTask.project - ? `New: ${newTask.project}` - : '+ Create new project…' - : undefined} - - - e.stopPropagation()} - className="max-h-60 overflow-y-auto" + }} > - - + Create new project… - - No project - {uniqueProjects.map((project: string) => ( - - {project} + { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + } + if (e.key === 'Enter') { + (e.currentTarget as HTMLButtonElement).click(); + } + }} + ref={(element) => (inputRefs.current.project = element)} + id="project" + data-testid="project-select" + > + + {isCreatingNewProject + ? newTask.project + ? `New: ${newTask.project}` + : '+ Create new project…' + : undefined} + + + e.stopPropagation()} + className="max-h-60 overflow-y-auto" + > + + + Create new project… - ))} - - + No project + {uniqueProjects.map((project: string) => ( + + {project} + + ))} + + - {isCreatingNewProject && ( - - setNewTask({ ...newTask, project: e.target.value }) + {isCreatingNewProject && ( + + setNewTask({ ...newTask, project: e.target.value }) + } + /> + )} +
+
+
+ +
+ (inputRefs.current.due = element)} + date={ + newTask.due + ? new Date( + newTask.due.includes('T') + ? newTask.due + : `${newTask.due}T00:00:00` + ) + : undefined } + onDateTimeChange={(date, hasTime) => { + setNewTask({ + ...newTask, + due: date + ? hasTime + ? date.toISOString() + : format(date, 'yyyy-MM-dd') + : '', + }); + }} + placeholder="Select due date and time" /> - )} -
-
-
- -
- { - setNewTask({ - ...newTask, - due: date - ? hasTime - ? date.toISOString() - : format(date, 'yyyy-MM-dd') - : '', - }); - }} - placeholder="Select due date and time" - /> +
-
-
- -
- { - setNewTask({ - ...newTask, - start: date - ? hasTime - ? date.toISOString() - : format(date, 'yyyy-MM-dd') - : '', - }); - }} - placeholder="Select start date and time" - /> +
+ +
+ (inputRefs.current.start = element)} + date={ + newTask.start + ? new Date( + newTask.start.includes('T') + ? newTask.start + : `${newTask.start}T00:00:00` + ) + : undefined + } + onDateTimeChange={(date, hasTime) => { + setNewTask({ + ...newTask, + start: date + ? hasTime + ? date.toISOString() + : format(date, 'yyyy-MM-dd') + : '', + }); + }} + placeholder="Select start date and time" + /> +
-
-
- -
- { - setNewTask({ - ...newTask, - end: date ? format(date, 'yyyy-MM-dd') : '', - }); - }} - placeholder="Select an end date" - /> +
+ +
+ (inputRefs.current.end = element)} + date={newTask.end ? new Date(newTask.end) : undefined} + onDateChange={(date) => { + setNewTask({ + ...newTask, + end: date ? format(date, 'yyyy-MM-dd') : '', + }); + }} + placeholder="Select an end date" + /> +
-
-
- -
- { - setNewTask({ - ...newTask, - entry: date - ? hasTime - ? date.toISOString() - : format(date, 'yyyy-MM-dd') - : '', - }); - }} - placeholder="Select entry date and time" - /> +
+ +
+ (inputRefs.current.entry = element)} + date={ + newTask.entry + ? new Date( + newTask.entry.includes('T') + ? newTask.entry + : `${newTask.entry}T00:00:00` + ) + : undefined + } + onDateTimeChange={(date, hasTime) => { + setNewTask({ + ...newTask, + entry: date + ? hasTime + ? date.toISOString() + : format(date, 'yyyy-MM-dd') + : '', + }); + }} + placeholder="Select entry date and time" + /> +
-
-
- -
- { - setNewTask({ - ...newTask, - wait: date - ? hasTime - ? date.toISOString() - : format(date, 'yyyy-MM-dd') - : '', - }); - }} - placeholder="Select wait date and time" - /> +
+ +
+ (inputRefs.current.wait = element)} + date={ + newTask.wait + ? new Date( + newTask.wait.includes('T') + ? newTask.wait + : `${newTask.wait}T00:00:00` + ) + : undefined + } + onDateTimeChange={(date, hasTime) => { + setNewTask({ + ...newTask, + wait: date + ? hasTime + ? date.toISOString() + : format(date, 'yyyy-MM-dd') + : '', + }); + }} + placeholder="Select wait date and time" + /> +
-
-
- -
- +
+ +
+ +
-
-
- -
- setTagInput(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleAddTag()} - required - className="col-span-6" - /> +
+ +
+ (inputRefs.current.tags = element)} + id="tags" + name="tags" + placeholder="Add a tag" + value={tagInput} + onChange={(e) => setTagInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAddTag()} + required + className="col-span-6" + /> +
-
-
{newTask.tags.length > 0 && (
@@ -450,25 +560,30 @@ export const AddTaskdialog = ({
)} -
-
- -
- setAnnotationInput(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleAddAnnotation()} - className="col-span-6" - /> + +
+ +
+ (inputRefs.current.annotations = element)} + id="annotations" + name="annotations" + placeholder="Add an annotation" + value={annotationInput} + onChange={(e) => setAnnotationInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAddAnnotation()} + className="col-span-3" + /> +
-
-
{newTask.annotations.length > 0 && (
@@ -489,115 +604,122 @@ export const AddTaskdialog = ({
)} -
-
- -
- {/* Search input */} - { - setDependencySearch(e.target.value); - setShowDependencyResults(e.target.value.trim() !== ''); - }} - onFocus={() => - setShowDependencyResults(dependencySearch.trim() !== '') - } - /> - {/* Search results dropdown */} - {showDependencyResults && ( -
- {(() => { - const filteredTasks = getFilteredTasks(); +
+ +
+ {/* Search input */} + (inputRefs.current.depends = element)} + placeholder="Search and select tasks this depends on..." + value={dependencySearch} + onChange={(e) => { + setDependencySearch(e.target.value); + setShowDependencyResults(e.target.value.trim() !== ''); + }} + onFocus={() => + setShowDependencyResults(dependencySearch.trim() !== '') + } + /> - if (filteredTasks.length === 0) { - return ( -
- No tasks found matching your search -
- ); - } + {/* Search results dropdown */} + {showDependencyResults && ( +
+ {(() => { + const filteredTasks = getFilteredTasks(); - return filteredTasks.map((task) => ( -
{ - e.preventDefault(); // prevents blur - setNewTask({ - ...newTask, - depends: [...newTask.depends, task.uuid], - }); - setDependencySearch(''); - setShowDependencyResults(false); - }} - > -
- - #{task.id} - - - {task.description} - - {task.project && ( - - {task.project} - - )} -
-
- )); - })()} -
- )} + if (filteredTasks.length === 0) { + return ( +
+ No tasks found matching your search +
+ ); + } - {/* Display selected dependencies */} - {newTask.depends.length > 0 && ( -
- {newTask.depends.map((taskUuid) => { - const dependentTask = allTasks.find( - (t) => t.uuid === taskUuid - ); - return ( - - - #{dependentTask?.id || '?'}{' '} - {dependentTask?.description?.substring(0, 20) || - taskUuid.substring(0, 8)} - {dependentTask?.description && - dependentTask.description.length > 20 && - '...'} - - - - ); - })} -
- )} +
+ + #{task.id} + + + {task.description} + + {task.project && ( + + {task.project} + + )} +
+
+ )); + })()} +
+ )} + + {/* Display selected dependencies */} + {newTask.depends.length > 0 && ( +
+ {newTask.depends.map((taskUuid) => { + const dependentTask = allTasks.find( + (t) => t.uuid === taskUuid + ); + return ( + + + #{dependentTask?.id || '?'}{' '} + {dependentTask?.description?.substring(0, 20) || + taskUuid.substring(0, 8)} + {dependentTask?.description && + dependentTask.description.length > 20 && + '...'} + + + + ); + })} +
+ )} +
diff --git a/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx b/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx index 2c2be1e6..8c46fe17 100644 --- a/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx +++ b/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx @@ -38,7 +38,7 @@ import CopyToClipboard from 'react-copy-to-clipboard'; import { formattedDate, handleCopy } from './tasks-utils'; import { useEffect, useRef, useState } from 'react'; import { useTaskDialogKeyboard } from './UseTaskDialogKeyboard'; -import { FIELDS } from './constants'; +import { EDITTASKDIALOG_FIELDS } from './constants'; import { useTaskDialogFocusMap } from './UseTaskDialogFocusMap'; export const TaskDialog = ({ @@ -97,7 +97,7 @@ export const TaskDialog = ({ editState.isEditingRecur || editState.isEditingAnnotations; - const focusedField = FIELDS[focusedFieldIndex]; + const focusedField = EDITTASKDIALOG_FIELDS[focusedFieldIndex]; const stopEditing = () => { onUpdateState({ @@ -145,12 +145,12 @@ export const TaskDialog = ({ ]); const focusMap = useTaskDialogFocusMap({ - fields: FIELDS, + fields: EDITTASKDIALOG_FIELDS, inputRefs: inputRefs, }); const handleDialogKeyDown = useTaskDialogKeyboard({ - fields: FIELDS, + fields: EDITTASKDIALOG_FIELDS, focusedFieldIndex: focusedFieldIndex, setFocusedFieldIndex: setFocusedFieldIndex, isEditingAny: isEditingAny, diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index 78a3f940..4463de8e 100644 --- a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx +++ b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx @@ -1119,6 +1119,7 @@ export const Tasks = ( />
({ [fields, inputRefs] ); } + +export function useAddTaskDialogFocusMap({ + fields, + inputRefs, +}: UseTaskDialogFocusMapProps) { + return React.useCallback( + (field: F[number]) => { + const element = inputRefs.current[field]; + if (!element) return; + + element.focus(); + }, + [fields, inputRefs] + ); +} diff --git a/frontend/src/components/HomeComponents/Tasks/UseTaskDialogKeyboard.tsx b/frontend/src/components/HomeComponents/Tasks/UseTaskDialogKeyboard.tsx index 7c5d027f..af8c8b94 100644 --- a/frontend/src/components/HomeComponents/Tasks/UseTaskDialogKeyboard.tsx +++ b/frontend/src/components/HomeComponents/Tasks/UseTaskDialogKeyboard.tsx @@ -1,4 +1,7 @@ -import { UseTaskDialogKeyboardProps } from '@/components/utils/types'; +import { + AddTaskProps, + UseTaskDialogKeyboardProps, +} from '@/components/utils/types'; import React from 'react'; export function useTaskDialogKeyboard({ @@ -55,3 +58,49 @@ export function useTaskDialogKeyboard({ ] ); } + +export function useAddTaskDialogKeyboard({ + fields, + focusedFieldIndex, + setFocusedFieldIndex, + onEnter, + closeDialog, +}: AddTaskProps) { + return React.useCallback( + (e: React.KeyboardEvent) => { + const target = e.target as HTMLElement; + + const isTyping = + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable; + + if (isTyping && (e.key === 'Enter' || e.key === 'Escape')) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setFocusedFieldIndex((i) => Math.min(i + 1, fields.length - 1)); + break; + + case 'ArrowUp': + e.preventDefault(); + setFocusedFieldIndex((i) => Math.max(i - 1, 0)); + break; + + case 'Enter': + e.preventDefault(); + e.stopPropagation(); + const field = fields[focusedFieldIndex]; + onEnter(field); + break; + + case 'Escape': + e.preventDefault(); + closeDialog(); + break; + } + }, + [fields, focusedFieldIndex, setFocusedFieldIndex, onEnter, closeDialog] + ); +} diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/AddTaskDialog.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/AddTaskDialog.test.tsx index 51d11ab6..4c97f6da 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/AddTaskDialog.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/AddTaskDialog.test.tsx @@ -927,4 +927,57 @@ describe('AddTaskDialog Component', () => { }); }); }); + + describe('Testing Shortcuts', () => { + beforeEach(() => { + Element.prototype.scrollIntoView = jest.fn(); + }); + + test('ArrowDown moves focus to next field', async () => { + render(); + + const dialog = await screen.findByRole('dialog'); + fireEvent.keyDown(dialog, { key: 'ArrowDown' }); + + const prioritySelect = screen.getByLabelText(/priority/i); + const priorityRow = prioritySelect.closest('div.grid'); + expect(priorityRow).toHaveClass('bg-black/15'); + }); + + test('Enter focuses priority select when priority row is focused', async () => { + render(); + + const dialog = await screen.findByRole('dialog'); + fireEvent.keyDown(dialog, { key: 'ArrowDown' }); + fireEvent.keyDown(dialog, { key: 'Enter' }); + + const prioritySelect = screen.getByLabelText(/priority/i); + expect(prioritySelect).toHaveFocus(); + }); + + test('Arrow keys do navigate while editing', () => { + render(); + + const dialog = screen.getByRole('dialog'); + fireEvent.keyDown(dialog, { key: 'Enter' }); + fireEvent.keyDown(dialog, { key: 'ArrowDown' }); + + const descriptionRow = screen.getByText(/priority/i); + expect(descriptionRow).toBeInTheDocument(); + }); + + test('DateTimePicker is visible when any date field is in edit mode', async () => { + render(); + + const dialog = screen.getByRole('dialog'); + fireEvent.keyDown(dialog, { key: 'ArrowDown' }); + fireEvent.keyDown(dialog, { key: 'ArrowDown' }); + fireEvent.keyDown(dialog, { key: 'ArrowDown' }); + fireEvent.keyDown(dialog, { key: 'Enter' }); + + expect( + screen.getByPlaceholderText('Select due date and time') + ).toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx index 36886824..83436dc0 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx @@ -225,11 +225,7 @@ describe('Tasks Component', () => { render(); - await waitFor(async () => { - expect(await screen.findByText('Task 1')).toBeInTheDocument(); - }); - - expect(screen.getByLabelText('Show:')).toHaveValue('20'); + expect(await screen.findByLabelText('Show:')).toHaveValue('20'); }); test('updates pagination when "Tasks per Page" is changed', async () => { diff --git a/frontend/src/components/HomeComponents/Tasks/constants.ts b/frontend/src/components/HomeComponents/Tasks/constants.ts index 15305563..d03eeb0c 100644 --- a/frontend/src/components/HomeComponents/Tasks/constants.ts +++ b/frontend/src/components/HomeComponents/Tasks/constants.ts @@ -1,4 +1,4 @@ -export const FIELDS = [ +export const EDITTASKDIALOG_FIELDS = [ 'description', 'due', 'start', @@ -12,3 +12,18 @@ export const FIELDS = [ 'recur', 'annotations', ] as const; + +export const ADDTASKDIALOG_FIELDS = [ + 'description', + 'priority', + 'project', + 'due', + 'start', + 'end', + 'entry', + 'wait', + 'recur', + 'tags', + 'annotations', + 'depends', +] as const; diff --git a/frontend/src/components/utils/types.ts b/frontend/src/components/utils/types.ts index 54d4482b..d7279701 100644 --- a/frontend/src/components/utils/types.ts +++ b/frontend/src/components/utils/types.ts @@ -1,4 +1,7 @@ -import { FIELDS } from '../HomeComponents/Tasks/constants'; +import { + ADDTASKDIALOG_FIELDS, + EDITTASKDIALOG_FIELDS, +} from '../HomeComponents/Tasks/constants'; export interface User { name: string; @@ -115,6 +118,7 @@ export interface TaskFormData { } export interface AddTaskDialogProps { + onOpenChange: (open: boolean) => void; isOpen: boolean; setIsOpen: (value: boolean) => void; newTask: TaskFormData; @@ -172,7 +176,17 @@ export interface UseTaskDialogKeyboardProps { stopEditing: () => void; } -export type FieldKey = (typeof FIELDS)[number]; +export type AddTaskProps = { + fields: F; + focusedFieldIndex: number; + setFocusedFieldIndex: React.Dispatch>; + onEnter: (field: F[number]) => void; + closeDialog: () => void; +}; + +export type AddFieldKey = (typeof ADDTASKDIALOG_FIELDS)[number]; + +export type FieldKey = (typeof EDITTASKDIALOG_FIELDS)[number]; export type RefMap = Record;