From 9a6e0b7673fb796508394b3422bb482f808da35a Mon Sep 17 00:00:00 2001 From: Rajat yadav Date: Fri, 9 Jan 2026 12:24:18 +0530 Subject: [PATCH 1/4] feat: create TagMultiSelect component for reusable tag selection --- .../HomeComponents/Tasks/TagMultiSelect.tsx | 179 ++++++++++++++++++ frontend/src/components/utils/types.ts | 9 + 2 files changed, 188 insertions(+) create mode 100644 frontend/src/components/HomeComponents/Tasks/TagMultiSelect.tsx diff --git a/frontend/src/components/HomeComponents/Tasks/TagMultiSelect.tsx b/frontend/src/components/HomeComponents/Tasks/TagMultiSelect.tsx new file mode 100644 index 00000000..78a93583 --- /dev/null +++ b/frontend/src/components/HomeComponents/Tasks/TagMultiSelect.tsx @@ -0,0 +1,179 @@ +import { useState, useRef, useEffect } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { TagMultiSelectProps } from '@/components/utils/types'; +import { ChevronDown, Plus } from 'lucide-react'; + +export const TagMultiSelect = ({ + availableTags, + selectedTags, + onTagsChange, + placeholder = 'Select or create tags', + disabled = false, + className = '', +}: TagMultiSelectProps) => { + const [isOpen, setIsOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const dropdownRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + setSearchTerm(''); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const getFilteredTags = () => { + return availableTags.filter( + (tag) => + tag.toLowerCase().includes(searchTerm.toLowerCase()) && + !selectedTags.includes(tag) + ); + }; + + const handleTagSelect = (tag: string) => { + if (!selectedTags.includes(tag)) { + onTagsChange([...selectedTags, tag]); + } + setSearchTerm(''); + }; + + const handleTagRemove = (tagToRemove: string) => { + onTagsChange(selectedTags.filter((tag) => tag !== tagToRemove)); + }; + + const handleNewTagCreate = () => { + const trimmedTerm = searchTerm.trim(); + if ( + trimmedTerm && + !selectedTags.includes(trimmedTerm) && + !availableTags.includes(trimmedTerm) + ) { + onTagsChange([...selectedTags, trimmedTerm]); + setSearchTerm(''); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + const filteredTags = getFilteredTags(); + if (filteredTags.length > 0) { + handleTagSelect(filteredTags[0]); + } else if (searchTerm.trim()) { + handleNewTagCreate(); + } + } else if (e.key === 'Escape') { + setIsOpen(false); + setSearchTerm(''); + } + }; + + const showCreateOption = + searchTerm.trim() && + !availableTags.includes(searchTerm.trim()) && + !selectedTags.includes(searchTerm.trim()); + + return ( +
+ + + {isOpen && ( +
+
+ setSearchTerm(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search or create tags..." + className="h-8" + autoFocus + /> +
+ +
+ {getFilteredTags().map((tag) => ( +
handleTagSelect(tag)} + > + + {tag} +
+ ))} + + {showCreateOption && ( +
+ + + Create "{searchTerm.trim()}" + +
+ )} + + {getFilteredTags().length === 0 && !showCreateOption && ( +
+ No tags found +
+ )} +
+
+ )} + + {selectedTags.length > 0 && ( +
+ {selectedTags.map((tag) => ( + + {tag} + + + ))} +
+ )} +
+ ); +}; diff --git a/frontend/src/components/utils/types.ts b/frontend/src/components/utils/types.ts index d7279701..d6be36ff 100644 --- a/frontend/src/components/utils/types.ts +++ b/frontend/src/components/utils/types.ts @@ -132,6 +132,15 @@ export interface AddTaskDialogProps { allTasks?: Task[]; } +export interface TagMultiSelectProps { + availableTags: string[]; + selectedTags: string[]; + onTagsChange: (tags: string[]) => void; + placeholder?: string; + disabled?: boolean; + className?: string; +} + export interface EditTaskDialogProps { index: number; task: Task; From f6c346cf0f14f629e7cc8b7e132604c5693f9e97 Mon Sep 17 00:00:00 2001 From: Rajat yadav Date: Fri, 9 Jan 2026 12:32:30 +0530 Subject: [PATCH 2/4] feat: integrate TagMultiSelect with AddTaskDialog --- .../HomeComponents/Tasks/AddTaskDialog.tsx | 55 +++---------------- .../components/HomeComponents/Tasks/Tasks.tsx | 7 +-- frontend/src/components/utils/types.ts | 3 +- 3 files changed, 11 insertions(+), 54 deletions(-) diff --git a/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx b/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx index fec867c9..108a52cd 100644 --- a/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx +++ b/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx @@ -31,6 +31,7 @@ import { format } from 'date-fns'; import { ADDTASKDIALOG_FIELDS } from './constants'; import { useAddTaskDialogKeyboard } from './UseTaskDialogKeyboard'; import { useAddTaskDialogFocusMap } from './UseTaskDialogFocusMap'; +import { TagMultiSelect } from './TagMultiSelect'; export const AddTaskdialog = ({ onOpenChange, @@ -38,12 +39,11 @@ export const AddTaskdialog = ({ setIsOpen, newTask, setNewTask, - tagInput, - setTagInput, onSubmit, isCreatingNewProject, setIsCreatingNewProject, uniqueProjects = [], + uniqueTags = [], allTasks = [], }: AddTaskDialogProps) => { const [annotationInput, setAnnotationInput] = useState(''); @@ -164,20 +164,6 @@ export const AddTaskdialog = ({ }); }; - const handleAddTag = () => { - if (tagInput && !newTask.tags.includes(tagInput, 0)) { - setNewTask({ ...newTask, tags: [...newTask.tags, tagInput] }); - setTagInput(''); - } - }; - - const handleRemoveTag = (tagToRemove: string) => { - setNewTask({ - ...newTask, - tags: newTask.tags.filter((tag) => tag !== tagToRemove), - }); - }; - return ( @@ -523,44 +509,19 @@ export const AddTaskdialog = ({
-
- {newTask.tags.length > 0 && ( -
-
-
- {newTask.tags.map((tag, index) => ( - - {tag} - - - ))} -
-
- )} -
diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index 4463de8e..10fec7dc 100644 --- a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx +++ b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx @@ -105,7 +105,6 @@ export const Tasks = ( const [isCreatingNewProject, setIsCreatingNewProject] = useState(false); const [isAddTaskOpen, setIsAddTaskOpen] = useState(false); const [_isDialogOpen, setIsDialogOpen] = useState(false); - const [tagInput, setTagInput] = useState(''); const [_selectedTask, setSelectedTask] = useState(null); const [editedTags, setEditedTags] = useState( _selectedTask?.tags || [] @@ -1124,12 +1123,11 @@ export const Tasks = ( setIsOpen={setIsAddTaskOpen} newTask={newTask} setNewTask={setNewTask} - tagInput={tagInput} - setTagInput={setTagInput} onSubmit={handleAddTask} isCreatingNewProject={isCreatingNewProject} setIsCreatingNewProject={setIsCreatingNewProject} uniqueProjects={uniqueProjects} + uniqueTags={uniqueTags} allTasks={tasks} />
@@ -1435,12 +1433,11 @@ export const Tasks = ( setIsOpen={setIsAddTaskOpen} newTask={newTask} setNewTask={setNewTask} - tagInput={tagInput} - setTagInput={setTagInput} onSubmit={handleAddTask} isCreatingNewProject={isCreatingNewProject} setIsCreatingNewProject={setIsCreatingNewProject} uniqueProjects={uniqueProjects} + uniqueTags={uniqueTags} allTasks={tasks} /> diff --git a/frontend/src/components/utils/types.ts b/frontend/src/components/utils/types.ts index d6be36ff..4eee9302 100644 --- a/frontend/src/components/utils/types.ts +++ b/frontend/src/components/utils/types.ts @@ -123,12 +123,11 @@ export interface AddTaskDialogProps { setIsOpen: (value: boolean) => void; newTask: TaskFormData; setNewTask: (task: TaskFormData) => void; - tagInput: string; - setTagInput: (value: string) => void; onSubmit: (task: TaskFormData) => void; isCreatingNewProject: boolean; setIsCreatingNewProject: (value: boolean) => void; uniqueProjects: string[]; + uniqueTags: string[]; allTasks?: Task[]; } From e35f306b8b896fa84e4c94a3df3dd357a35e3230 Mon Sep 17 00:00:00 2001 From: Rajat yadav Date: Fri, 9 Jan 2026 12:42:13 +0530 Subject: [PATCH 3/4] feat: integrate TagMultiSelect with TaskDialog for editing --- .../HomeComponents/Tasks/TaskDialog.tsx | 97 ++++--------------- .../components/HomeComponents/Tasks/Tasks.tsx | 1 + frontend/src/components/utils/types.ts | 1 + 3 files changed, 20 insertions(+), 79 deletions(-) diff --git a/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx b/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx index 8c46fe17..603b86a6 100644 --- a/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx +++ b/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx @@ -40,6 +40,7 @@ import { useEffect, useRef, useState } from 'react'; import { useTaskDialogKeyboard } from './UseTaskDialogKeyboard'; import { EDITTASKDIALOG_FIELDS } from './constants'; import { useTaskDialogFocusMap } from './UseTaskDialogFocusMap'; +import { TagMultiSelect } from './TagMultiSelect'; export const TaskDialog = ({ index, @@ -56,6 +57,7 @@ export const TaskDialog = ({ isCreatingNewProject, setIsCreatingNewProject, uniqueProjects, + uniqueTags, onSaveDescription, onSaveTags, onSavePriority, @@ -1213,57 +1215,23 @@ export const TaskDialog = ({ Tags: {editState.isEditingTags ? ( -
-
- - (inputRefs.current.tags = element) - } - type="text" - value={editState.editTagInput} - onChange={(e) => { - // For allowing only alphanumeric characters - if (e.target.value.length > 1) { - /^[a-zA-Z0-9]*$/.test(e.target.value.trim()) - ? onUpdateState({ - editTagInput: e.target.value.trim(), - }) - : ''; - } else { - /^[a-zA-Z]*$/.test(e.target.value.trim()) - ? onUpdateState({ - editTagInput: e.target.value.trim(), - }) - : ''; - } - }} - placeholder="Add a tag (press enter to add)" - className="flex-grow mr-2" - onKeyDown={(e) => { - if ( - e.key === 'Enter' && - editState.editTagInput.trim() - ) { - onUpdateState({ - editedTags: [ - ...editState.editedTags, - editState.editTagInput.trim(), - ], - editTagInput: '', - }); - } - }} - /> +
+ + onUpdateState({ editedTags: tags }) + } + placeholder="Select or create tags" + /> +
-
- {editState.editedTags != null && - editState.editedTags.length > 0 && ( -
-
- {editState.editedTags.map((tag, index) => ( - - {tag} - - - ))} -
-
- )} -
) : (
diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index 10fec7dc..306ad5fb 100644 --- a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx +++ b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx @@ -1252,6 +1252,7 @@ export const Tasks = ( onUpdateState={updateEditState} allTasks={tasks} uniqueProjects={uniqueProjects} + uniqueTags={uniqueTags} isCreatingNewProject={isCreatingNewProject} setIsCreatingNewProject={setIsCreatingNewProject} onSaveDescription={handleSaveDescription} diff --git a/frontend/src/components/utils/types.ts b/frontend/src/components/utils/types.ts index 4eee9302..8940f94c 100644 --- a/frontend/src/components/utils/types.ts +++ b/frontend/src/components/utils/types.ts @@ -153,6 +153,7 @@ export interface EditTaskDialogProps { onUpdateState: (updates: Partial) => void; allTasks: Task[]; uniqueProjects: string[]; + uniqueTags: string[]; isCreatingNewProject: boolean; setIsCreatingNewProject: (value: boolean) => void; onSaveDescription: (task: Task, description: string) => void; From 854cab3b026f06935a2a9095bd749abb0b95efb5 Mon Sep 17 00:00:00 2001 From: Rajat yadav Date: Fri, 9 Jan 2026 23:43:24 +0530 Subject: [PATCH 4/4] test: add comprehensive tests for TagMultiSelect component --- .../HomeComponents/Tasks/AddTaskDialog.tsx | 12 +- .../{TagMultiSelect.tsx => MultiSelect.tsx} | 166 +++--- .../HomeComponents/Tasks/TaskDialog.tsx | 57 +- .../components/HomeComponents/Tasks/Tasks.tsx | 118 +++- .../Tasks/__tests__/AddTaskDialog.test.tsx | 84 +-- .../Tasks/__tests__/MultiSelect.test.tsx | 506 ++++++++++++++++++ .../Tasks/__tests__/TaskDialog.test.tsx | 84 ++- .../Tasks/__tests__/Tasks.test.tsx | 55 +- .../Tasks/multi-select-utils.ts | 24 + frontend/src/components/utils/types.ts | 11 +- 10 files changed, 909 insertions(+), 208 deletions(-) rename frontend/src/components/HomeComponents/Tasks/{TagMultiSelect.tsx => MultiSelect.tsx} (50%) create mode 100644 frontend/src/components/HomeComponents/Tasks/__tests__/MultiSelect.test.tsx create mode 100644 frontend/src/components/HomeComponents/Tasks/multi-select-utils.ts diff --git a/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx b/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx index 108a52cd..77b57c56 100644 --- a/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx +++ b/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx @@ -31,7 +31,7 @@ import { format } from 'date-fns'; import { ADDTASKDIALOG_FIELDS } from './constants'; import { useAddTaskDialogKeyboard } from './UseTaskDialogKeyboard'; import { useAddTaskDialogFocusMap } from './UseTaskDialogFocusMap'; -import { TagMultiSelect } from './TagMultiSelect'; +import { MultiSelect } from './MultiSelect'; export const AddTaskdialog = ({ onOpenChange, @@ -513,10 +513,12 @@ export const AddTaskdialog = ({ Tags
- setNewTask({ ...newTask, tags })} + + setNewTask({ ...newTask, tags }) + } placeholder="Select or create tags" />
diff --git a/frontend/src/components/HomeComponents/Tasks/TagMultiSelect.tsx b/frontend/src/components/HomeComponents/Tasks/MultiSelect.tsx similarity index 50% rename from frontend/src/components/HomeComponents/Tasks/TagMultiSelect.tsx rename to frontend/src/components/HomeComponents/Tasks/MultiSelect.tsx index 78a93583..34ed629a 100644 --- a/frontend/src/components/HomeComponents/Tasks/TagMultiSelect.tsx +++ b/frontend/src/components/HomeComponents/Tasks/MultiSelect.tsx @@ -2,17 +2,21 @@ import { useState, useRef, useEffect } from 'react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { TagMultiSelectProps } from '@/components/utils/types'; -import { ChevronDown, Plus } from 'lucide-react'; +import { MultiSelectProps } from '@/components/utils/types'; +import { ChevronDown, Plus, Check, X } from 'lucide-react'; +import { getFilteredItems, shouldShowCreateOption } from './multi-select-utils'; -export const TagMultiSelect = ({ - availableTags, - selectedTags, - onTagsChange, - placeholder = 'Select or create tags', +export const MultiSelect = ({ + availableItems, + selectedItems, + onItemsChange, + placeholder = 'Select or create items', disabled = false, className = '', -}: TagMultiSelectProps) => { + showActions = false, + onSave, + onCancel, +}: MultiSelectProps) => { const [isOpen, setIsOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const dropdownRef = useRef(null); @@ -33,33 +37,31 @@ export const TagMultiSelect = ({ return () => document.removeEventListener('mousedown', handleClickOutside); }, []); - const getFilteredTags = () => { - return availableTags.filter( - (tag) => - tag.toLowerCase().includes(searchTerm.toLowerCase()) && - !selectedTags.includes(tag) - ); - }; + const filteredItems = getFilteredItems( + availableItems, + selectedItems, + searchTerm + ); - const handleTagSelect = (tag: string) => { - if (!selectedTags.includes(tag)) { - onTagsChange([...selectedTags, tag]); + const handleItemSelect = (item: string) => { + if (!selectedItems.includes(item)) { + onItemsChange([...selectedItems, item]); } setSearchTerm(''); }; - const handleTagRemove = (tagToRemove: string) => { - onTagsChange(selectedTags.filter((tag) => tag !== tagToRemove)); + const handleItemRemove = (itemToRemove: string) => { + onItemsChange(selectedItems.filter((item) => item !== itemToRemove)); }; - const handleNewTagCreate = () => { + const handleNewItemCreate = () => { const trimmedTerm = searchTerm.trim(); if ( trimmedTerm && - !selectedTags.includes(trimmedTerm) && - !availableTags.includes(trimmedTerm) + !selectedItems.includes(trimmedTerm) && + !availableItems.includes(trimmedTerm) ) { - onTagsChange([...selectedTags, trimmedTerm]); + onItemsChange([...selectedItems, trimmedTerm]); setSearchTerm(''); } }; @@ -67,11 +69,12 @@ export const TagMultiSelect = ({ const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); - const filteredTags = getFilteredTags(); - if (filteredTags.length > 0) { - handleTagSelect(filteredTags[0]); - } else if (searchTerm.trim()) { - handleNewTagCreate(); + if (searchTerm.trim()) { + if (filteredItems.length > 0) { + handleItemSelect(filteredItems[0]); + } else { + handleNewItemCreate(); + } } } else if (e.key === 'Escape') { setIsOpen(false); @@ -79,10 +82,11 @@ export const TagMultiSelect = ({ } }; - const showCreateOption = - searchTerm.trim() && - !availableTags.includes(searchTerm.trim()) && - !selectedTags.includes(searchTerm.trim()); + const showCreate = shouldShowCreateOption( + searchTerm, + availableItems, + selectedItems + ); return (
@@ -92,15 +96,59 @@ export const TagMultiSelect = ({ onClick={() => setIsOpen(!isOpen)} disabled={disabled} className="w-full justify-between text-left font-normal" + aria-label="Select items" > - {selectedTags.length > 0 - ? `${selectedTags.length} tag${selectedTags.length > 1 ? 's' : ''} selected` + {selectedItems.length > 0 + ? `${selectedItems.length} item${selectedItems.length > 1 ? 's' : ''} selected` : placeholder} + {selectedItems.length > 0 && ( +
+ {selectedItems.map((item) => ( + + {item} + + + ))} + {showActions && onSave && onCancel && ( +
+ + +
+ )} +
+ )} + {isOpen && (
@@ -109,33 +157,27 @@ export const TagMultiSelect = ({ value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} onKeyDown={handleKeyDown} - placeholder="Search or create tags..." + placeholder="Search or create..." className="h-8" autoFocus />
- {getFilteredTags().map((tag) => ( + {filteredItems.map((item) => (
handleTagSelect(tag)} + key={item} + className="px-3 py-2 cursor-pointer hover:bg-accent transition-colors" + onClick={() => handleItemSelect(item)} > - - {tag} + {item}
))} - {showCreateOption && ( + {showCreate && (
@@ -144,36 +186,14 @@ export const TagMultiSelect = ({
)} - {getFilteredTags().length === 0 && !showCreateOption && ( + {filteredItems.length === 0 && !showCreate && (
- No tags found + No items found
)}
)} - - {selectedTags.length > 0 && ( -
- {selectedTags.map((tag) => ( - - {tag} - - - ))} -
- )}
); }; diff --git a/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx b/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx index 603b86a6..522e1f49 100644 --- a/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx +++ b/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx @@ -40,7 +40,7 @@ import { useEffect, useRef, useState } from 'react'; import { useTaskDialogKeyboard } from './UseTaskDialogKeyboard'; import { EDITTASKDIALOG_FIELDS } from './constants'; import { useTaskDialogFocusMap } from './UseTaskDialogFocusMap'; -import { TagMultiSelect } from './TagMultiSelect'; +import { MultiSelect } from './MultiSelect'; export const TaskDialog = ({ index, @@ -1215,42 +1215,25 @@ export const TaskDialog = ({ Tags: {editState.isEditingTags ? ( -
- - onUpdateState({ editedTags: tags }) - } - placeholder="Select or create tags" - /> -
- - -
-
+ + onUpdateState({ editedTags: tags }) + } + placeholder="Select or create tags" + showActions={true} + onSave={() => { + onSaveTags(task, editState.editedTags); + onUpdateState({ isEditingTags: false }); + }} + onCancel={() => + onUpdateState({ + isEditingTags: false, + editedTags: task.tags || [], + }) + } + /> ) : (
{task.tags !== null && task.tags.length >= 1 ? ( diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index 306ad5fb..7291385e 100644 --- a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx +++ b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx @@ -106,9 +106,6 @@ export const Tasks = ( const [isAddTaskOpen, setIsAddTaskOpen] = useState(false); const [_isDialogOpen, setIsDialogOpen] = useState(false); const [_selectedTask, setSelectedTask] = useState(null); - const [editedTags, setEditedTags] = useState( - _selectedTask?.tags || [] - ); const [searchTerm, setSearchTerm] = useState(''); const [debouncedTerm, setDebouncedTerm] = useState(''); const [lastSyncTime, setLastSyncTime] = useState(null); @@ -199,7 +196,6 @@ export const Tasks = ( }, [props.email]); useEffect(() => { if (_selectedTask) { - setEditedTags(_selectedTask.tags || []); } }, [_selectedTask]); @@ -216,13 +212,6 @@ export const Tasks = ( setPinnedTasks(getPinnedTasks(props.email)); }, [props.email]); - useEffect(() => { - const interval = setInterval(() => { - setLastSyncTime((prevTime) => prevTime); - }, 10000); - return () => clearInterval(interval); - }, []); - useEffect(() => { const fetchTasksForEmail = async () => { try { @@ -240,12 +229,27 @@ export const Tasks = ( .sort((a, b) => (a > b ? 1 : -1)); setUniqueProjects(filteredProjects); - const tagsSet = new Set(tasksFromDB.flatMap((task) => task.tags || [])); - const filteredTags = Array.from(tagsSet) - .filter((tag) => tag !== '') - .sort((a, b) => (a > b ? 1 : -1)); + const currentTags = new Set( + tasksFromDB.flatMap((task) => task.tags || []) + ); + const currentTagsArray = Array.from(currentTags).filter( + (tag) => tag !== '' + ); + + const tagHistoryKey = hashKey('tagHistory', props.email); + const storedTagHistory = localStorage.getItem(tagHistoryKey); + const historicalTags = storedTagHistory + ? JSON.parse(storedTagHistory) + : []; + + const allTags = new Set([...historicalTags, ...currentTagsArray]); + const filteredTags = Array.from(allTags).sort((a, b) => + a > b ? 1 : -1 + ); setUniqueTags(filteredTags); + localStorage.setItem(tagHistoryKey, JSON.stringify(filteredTags)); + // Calculate completion stats setProjectStats(calculateProjectStats(tasksFromDB)); setTagStats(calculateTagStats(tasksFromDB)); @@ -294,12 +298,27 @@ export const Tasks = ( .sort((a, b) => (a > b ? 1 : -1)); setUniqueProjects(filteredProjects); - const tagsSet = new Set(sortedTasks.flatMap((task) => task.tags || [])); - const filteredTags = Array.from(tagsSet) - .filter((tag) => tag !== '') - .sort((a, b) => (a > b ? 1 : -1)); + const currentTags = new Set( + sortedTasks.flatMap((task) => task.tags || []) + ); + const currentTagsArray = Array.from(currentTags).filter( + (tag) => tag !== '' + ); + + const tagHistoryKey = hashKey('tagHistory', user_email); + const storedTagHistory = localStorage.getItem(tagHistoryKey); + const historicalTags = storedTagHistory + ? JSON.parse(storedTagHistory) + : []; + + const allTags = new Set([...historicalTags, ...currentTagsArray]); + const filteredTags = Array.from(allTags).sort((a, b) => + a > b ? 1 : -1 + ); setUniqueTags(filteredTags); + localStorage.setItem(tagHistoryKey, JSON.stringify(filteredTags)); + // Calculate completion stats setProjectStats(calculateProjectStats(sortedTasks)); setTagStats(calculateTagStats(sortedTasks)); @@ -342,6 +361,20 @@ export const Tasks = ( backendURL: url.backendURL, }); + if (task.tags && task.tags.length > 0) { + const tagHistoryKey = hashKey('tagHistory', props.email); + const storedTagHistory = localStorage.getItem(tagHistoryKey); + const historicalTags = storedTagHistory + ? JSON.parse(storedTagHistory) + : []; + const allTags = new Set([...historicalTags, ...task.tags]); + const updatedTags = Array.from(allTags).sort((a, b) => + a > b ? 1 : -1 + ); + localStorage.setItem(tagHistoryKey, JSON.stringify(updatedTags)); + setUniqueTags(updatedTags); + } + setNewTask({ description: '', priority: '', @@ -849,12 +882,45 @@ export const Tasks = ( pinnedTasks, ]); - const handleSaveTags = (task: Task, tags: string[]) => { - const currentTags = tags || []; - const removedTags = currentTags.filter((tag) => !editedTags.includes(tag)); - const updatedTags = editedTags.filter((tag) => tag.trim() !== ''); - const tagsToRemove = removedTags.map((tag) => `${tag}`); - const finalTags = [...updatedTags, ...tagsToRemove]; + const handleSaveTags = (task: Task, updatedTags: string[]) => { + const filteredUpdatedTags = updatedTags.filter((tag) => tag.trim() !== ''); + const originalTags = task.tags || []; + + // Calculate tag diff for backend (expects +tag for additions, -tag for removals) + const tagsToRemove = originalTags.filter( + (tag) => !filteredUpdatedTags.includes(tag) + ); + + const tagsToAdd = filteredUpdatedTags.filter( + (tag) => !originalTags.includes(tag) + ); + + const tagDiff = [ + ...tagsToRemove.map((tag) => `-${tag}`), + ...tagsToAdd.map((tag) => `+${tag}`), + ]; + + task.tags = filteredUpdatedTags; + + // Recalculate uniqueTags from all current tasks + history (follows same pattern as initial load) + const currentTags = new Set( + tasks.flatMap((t) => + t.uuid === task.uuid ? filteredUpdatedTags : t.tags || [] + ) + ); + const currentTagsArray = Array.from(currentTags).filter( + (tag) => tag !== '' + ); + + const tagHistoryKey = hashKey('tagHistory', props.email); + const storedTagHistory = localStorage.getItem(tagHistoryKey); + const historicalTags = storedTagHistory ? JSON.parse(storedTagHistory) : []; + + const allTags = new Set([...historicalTags, ...currentTagsArray]); + const filteredTags = Array.from(allTags).sort((a, b) => (a > b ? 1 : -1)); + setUniqueTags(filteredTags); + + localStorage.setItem(tagHistoryKey, JSON.stringify(filteredTags)); setUnsyncedTaskUuids((prev) => new Set([...prev, task.uuid])); @@ -863,7 +929,7 @@ export const Tasks = ( props.encryptionSecret, props.UUID, task.description, - finalTags, + tagDiff, task.uuid.toString(), task.project, task.start, diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/AddTaskDialog.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/AddTaskDialog.test.tsx index 4c97f6da..e5f4ad5e 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/AddTaskDialog.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/AddTaskDialog.test.tsx @@ -100,10 +100,9 @@ describe('AddTaskDialog Component', () => { depends: [], }, setNewTask: jest.fn(), - tagInput: '', - setTagInput: jest.fn(), onSubmit: jest.fn(), uniqueProjects: [], + uniqueTags: ['work', 'urgent', 'personal'], allTasks: [], isCreatingNewProject: false, setIsCreatingNewProject: jest.fn(), @@ -317,33 +316,28 @@ describe('AddTaskDialog Component', () => { }); describe('Tags', () => { - test('adds a tag when user types and presses Enter', () => { + test('displays TagMultiSelect component', () => { mockProps.isOpen = true; - mockProps.tagInput = 'urgent'; render(); - const tagsInput = screen.getByPlaceholderText(/add a tag/i); - - fireEvent.keyDown(tagsInput, { key: 'Enter', code: 'Enter' }); + expect(screen.getByText('Select or create tags')).toBeInTheDocument(); + }); - expect(mockProps.setNewTask).toHaveBeenCalledWith({ - ...mockProps.newTask, - tags: ['urgent'], - }); + test('shows selected tags count when tags are selected', () => { + mockProps.isOpen = true; + mockProps.newTask.tags = ['urgent', 'work']; + render(); - expect(mockProps.setTagInput).toHaveBeenCalledWith(''); + expect(screen.getByText('2 items selected')).toBeInTheDocument(); }); - test('does not add duplicate tags', () => { + test('displays selected tags as badges', () => { mockProps.isOpen = true; - mockProps.tagInput = 'urgent'; - mockProps.newTask.tags = ['urgent']; + mockProps.newTask.tags = ['urgent', 'work']; render(); - const tagsInput = screen.getByPlaceholderText(/add a tag/i); - fireEvent.keyDown(tagsInput, { key: 'Enter', code: 'Enter' }); - - expect(mockProps.setNewTask).not.toHaveBeenCalled(); + expect(screen.getByText('urgent')).toBeInTheDocument(); + expect(screen.getByText('work')).toBeInTheDocument(); }); test('removes a tag when user clicks the remove button', () => { @@ -352,7 +346,6 @@ describe('AddTaskDialog Component', () => { render(); const removeButtons = screen.getAllByText('✖'); - fireEvent.click(removeButtons[0]); expect(mockProps.setNewTask).toHaveBeenCalledWith({ @@ -361,34 +354,61 @@ describe('AddTaskDialog Component', () => { }); }); - test('displays tags as badges', () => { + test('opens dropdown when TagMultiSelect button is clicked', () => { mockProps.isOpen = true; - mockProps.newTask.tags = ['urgent', 'work']; render(); - expect(screen.getByText('urgent')).toBeInTheDocument(); + const tagButton = screen.getByText('Select or create tags'); + fireEvent.click(tagButton); + + expect( + screen.getByPlaceholderText('Search or create...') + ).toBeInTheDocument(); + }); + + test('shows available tags in dropdown', () => { + mockProps.isOpen = true; + render(); + + const tagButton = screen.getByText('Select or create tags'); + fireEvent.click(tagButton); + expect(screen.getByText('work')).toBeInTheDocument(); + expect(screen.getByText('urgent')).toBeInTheDocument(); + expect(screen.getByText('personal')).toBeInTheDocument(); }); - test('updates tagInput when user types in tag field', () => { + test('adds tag when selected from dropdown', () => { mockProps.isOpen = true; render(); - const tagsInput = screen.getByPlaceholderText(/add a tag/i); - fireEvent.change(tagsInput, { target: { value: 'new-tag' } }); + const tagButton = screen.getByText('Select or create tags'); + fireEvent.click(tagButton); - expect(mockProps.setTagInput).toHaveBeenCalledWith('new-tag'); + const workTag = screen.getByText('work'); + fireEvent.click(workTag); + + expect(mockProps.setNewTask).toHaveBeenCalledWith({ + ...mockProps.newTask, + tags: ['work'], + }); }); - test('does not add empty tag when tagInput is empty', () => { + test('creates new tag when typed and Enter pressed', () => { mockProps.isOpen = true; - mockProps.tagInput = ''; render(); - const tagsInput = screen.getByPlaceholderText(/add a tag/i); - fireEvent.keyDown(tagsInput, { key: 'Enter', code: 'Enter' }); + const tagButton = screen.getByText('Select or create tags'); + fireEvent.click(tagButton); - expect(mockProps.setNewTask).not.toHaveBeenCalled(); + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'newtag' } }); + fireEvent.keyDown(searchInput, { key: 'Enter' }); + + expect(mockProps.setNewTask).toHaveBeenCalledWith({ + ...mockProps.newTask, + tags: ['newtag'], + }); }); }); diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/MultiSelect.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/MultiSelect.test.tsx new file mode 100644 index 00000000..bbcee3c3 --- /dev/null +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/MultiSelect.test.tsx @@ -0,0 +1,506 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MultiSelect } from '../MultiSelect'; +import '@testing-library/jest-dom'; + +describe('MultiSelect Component', () => { + const mockProps = { + availableItems: ['work', 'urgent', 'personal', 'bug', 'feature'], + selectedItems: [], + onItemsChange: jest.fn(), + placeholder: 'Select or create items', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Rendering', () => { + test('renders with placeholder when no items selected', () => { + render(); + + expect(screen.getByText('Select or create items')).toBeInTheDocument(); + }); + + test('shows selected tag count when tags are selected', () => { + render(); + + expect(screen.getByText('2 items selected')).toBeInTheDocument(); + }); + + test('shows singular form for single tag', () => { + render(); + + expect(screen.getByText('1 item selected')).toBeInTheDocument(); + }); + + test('displays selected tags as badges', () => { + render(); + + expect(screen.getByText('work')).toBeInTheDocument(); + expect(screen.getByText('urgent')).toBeInTheDocument(); + }); + + test('applies custom className', () => { + const { container } = render( + + ); + + expect(container.firstChild).toHaveClass('custom-class'); + }); + + test('respects disabled prop', () => { + render(); + + const button = screen.getByRole('button'); + expect(button).toBeDisabled(); + }); + }); + + describe('Dropdown Behavior', () => { + test('opens dropdown on button click', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect( + screen.getByPlaceholderText('Search or create...') + ).toBeInTheDocument(); + }); + + test('closes dropdown on button click when open', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + fireEvent.click(button); + + expect( + screen.queryByPlaceholderText('Search or create...') + ).not.toBeInTheDocument(); + }); + + test('closes dropdown on outside click', async () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect( + screen.getByPlaceholderText('Search or create...') + ).toBeInTheDocument(); + + fireEvent.mouseDown(document.body); + + await waitFor(() => { + expect( + screen.queryByPlaceholderText('Search or create...') + ).not.toBeInTheDocument(); + }); + }); + + test('closes dropdown on escape key', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.keyDown(searchInput, { key: 'Escape' }); + + expect( + screen.queryByPlaceholderText('Search or create...') + ).not.toBeInTheDocument(); + }); + + test('focuses search input when dropdown opens', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + expect(searchInput).toHaveFocus(); + }); + }); + + describe('Tag Selection', () => { + test('selects existing tag from dropdown', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const workTag = screen.getByText('work'); + fireEvent.click(workTag); + + expect(mockProps.onItemsChange).toHaveBeenCalledWith(['work']); + }); + + test('does not show already selected tags in dropdown', () => { + render(); + + const dropdownButton = screen.getByText('1 item selected'); + fireEvent.click(dropdownButton); + + const dropdownContainer = screen + .getByPlaceholderText('Search or create...') + .closest('.absolute'); + expect(dropdownContainer).not.toHaveTextContent('work'); + expect(screen.getByText('urgent')).toBeInTheDocument(); + }); + + test('prevents duplicate tag selection', () => { + const onItemsChange = jest.fn(); + render( + + ); + + const dropdownButton = screen.getByText('1 item selected'); + fireEvent.click(dropdownButton); + + // Try to create 'work' again by typing it + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'work' } }); + fireEvent.keyDown(searchInput, { key: 'Enter' }); + + // Should not call onItemsChange since 'work' is already selected + expect(onItemsChange).not.toHaveBeenCalled(); + }); + + test('removes selected tag when badge X clicked', () => { + render(); + + const removeButtons = screen.getAllByText('✖'); + fireEvent.click(removeButtons[0]); + + expect(mockProps.onItemsChange).toHaveBeenCalledWith(['urgent']); + }); + + test('does not remove tags when disabled', () => { + render( + + ); + + const removeButton = screen.getByText('✖'); + expect(removeButton).toBeDisabled(); + }); + }); + + describe('Search Functionality', () => { + test('filters available tags by search term', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'ur' } }); + + expect(screen.getByText('urgent')).toBeInTheDocument(); + expect(screen.queryByText('work')).not.toBeInTheDocument(); + expect(screen.queryByText('personal')).not.toBeInTheDocument(); + }); + + test('search is case insensitive', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'WORK' } }); + + expect(screen.getByText('work')).toBeInTheDocument(); + }); + + test('shows "No items found" when no matches', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'nonexistent' } }); + + // Should show create option instead of "No items found" + expect(screen.getByText('Create "nonexistent"')).toBeInTheDocument(); + }); + + test('clears search term when tag is selected', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'work' } }); + + const workTag = screen.getByText('work'); + fireEvent.click(workTag); + + expect((searchInput as HTMLInputElement).value).toBe(''); + }); + }); + + describe('New Tag Creation', () => { + test('shows "create new" option for non-existing search', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'newtag' } }); + + expect(screen.getByText('Create "newtag"')).toBeInTheDocument(); + }); + + test('does not show "create new" for existing tags', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'work' } }); + + expect(screen.queryByText('Create "work"')).not.toBeInTheDocument(); + }); + + test('does not show "create new" for already selected tags', () => { + render(); + + const dropdownButton = screen.getByText('1 item selected'); + fireEvent.click(dropdownButton); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'work' } }); + + expect(screen.queryByText('Create "work"')).not.toBeInTheDocument(); + }); + + test('creates new tag when "create new" clicked', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'newtag' } }); + + const createOption = screen.getByText('Create "newtag"'); + fireEvent.click(createOption); + + expect(mockProps.onItemsChange).toHaveBeenCalledWith(['newtag']); + }); + + test('trims whitespace when creating new tag', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: ' newtag ' } }); + + const createOption = screen.getByText('Create "newtag"'); + fireEvent.click(createOption); + + expect(mockProps.onItemsChange).toHaveBeenCalledWith(['newtag']); + }); + + test('does not create empty tag', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: ' ' } }); + + expect(screen.queryByText(/Create/)).not.toBeInTheDocument(); + }); + }); + + describe('Keyboard Navigation', () => { + test('selects first filtered tag on Enter key', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'ur' } }); + fireEvent.keyDown(searchInput, { key: 'Enter' }); + + expect(mockProps.onItemsChange).toHaveBeenCalledWith(['urgent']); + }); + + test('creates new tag on Enter when no existing matches', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'newtag' } }); + fireEvent.keyDown(searchInput, { key: 'Enter' }); + + expect(mockProps.onItemsChange).toHaveBeenCalledWith(['newtag']); + }); + + test('does nothing on Enter when search is empty', () => { + const onItemsChange = jest.fn(); + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + // Don't type anything, just press Enter + fireEvent.keyDown(searchInput, { key: 'Enter' }); + + expect(onItemsChange).not.toHaveBeenCalled(); + }); + + test('closes dropdown and clears search on Escape', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'test' } }); + fireEvent.keyDown(searchInput, { key: 'Escape' }); + + expect( + screen.queryByPlaceholderText('Search or create...') + ).not.toBeInTheDocument(); + }); + }); + + describe('Props Validation', () => { + test('calls onItemsChange when tags change', () => { + const onItemsChange = jest.fn(); + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const workTag = screen.getByText('work'); + fireEvent.click(workTag); + + expect(onItemsChange).toHaveBeenCalledWith(['work']); + }); + + test('uses custom placeholder', () => { + render(); + + expect(screen.getByText('Custom placeholder')).toBeInTheDocument(); + }); + + test('handles empty availableItems array', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(screen.getByText('No items found')).toBeInTheDocument(); + }); + + test('handles empty selectedItems array', () => { + render(); + + expect(screen.getByText('Select or create items')).toBeInTheDocument(); + expect(screen.queryByText('✖')).not.toBeInTheDocument(); + }); + }); + + describe('Integration Scenarios', () => { + test('works with pre-selected tags and available tags', () => { + render( + + ); + + // Should show selected tag + expect(screen.getByText('work')).toBeInTheDocument(); + expect(screen.getByText('1 item selected')).toBeInTheDocument(); + + // Should not show selected tag in dropdown + const dropdownButton = screen.getByText('1 item selected'); + fireEvent.click(dropdownButton); + + expect(screen.getByText('urgent')).toBeInTheDocument(); + expect(screen.getByText('personal')).toBeInTheDocument(); + + const dropdownContainer = screen + .getByPlaceholderText('Search or create...') + .closest('.absolute'); + expect(dropdownContainer).not.toHaveTextContent('work'); + }); + + test('maintains search state during tag operations', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'ur' } }); + + // Select a tag + const urgentTag = screen.getByText('urgent'); + fireEvent.click(urgentTag); + + // Search should be cleared after selection + expect((searchInput as HTMLInputElement).value).toBe(''); + }); + + test('handles rapid tag selection and removal', () => { + const onItemsChange = jest.fn(); + render( + + ); + + // Remove existing tag + const removeButton = screen.getByText('✖'); + fireEvent.click(removeButton); + + expect(onItemsChange).toHaveBeenCalledWith([]); + + // After removing, the button text should change back to placeholder + // We need to re-render with the updated state to test the next part + onItemsChange.mockClear(); + + // Simulate the component re-rendering with empty selectedItems + render( + + ); + + const dropdownButton = screen.getByText('Select or create items'); + fireEvent.click(dropdownButton); + + const urgentTag = screen.getByText('urgent'); + fireEvent.click(urgentTag); + + expect(onItemsChange).toHaveBeenCalledWith(['urgent']); + }); + }); +}); diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx index 709c3ae4..eb4ed66d 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { TaskDialog } from '../TaskDialog'; import { Task, EditTaskState } from '../../../utils/types'; @@ -88,6 +88,7 @@ describe('TaskDialog Component', () => { onUpdateState: jest.fn(), allTasks: mockAllTasks, uniqueProjects: [], + uniqueTags: ['work', 'urgent', 'personal'], isCreatingNewProject: false, setIsCreatingNewProject: jest.fn(), onSaveDescription: jest.fn(), @@ -346,11 +347,10 @@ describe('TaskDialog Component', () => { } }); - test('should add new tag on Enter key press', () => { + test('should display TagMultiSelect when editing', () => { const editingState = { ...mockEditState, isEditingTags: true, - editTagInput: 'newtag', editedTags: ['tag1', 'tag2'], }; @@ -358,33 +358,56 @@ describe('TaskDialog Component', () => { ); - const input = screen.getByPlaceholderText( - 'Add a tag (press enter to add)' + expect(screen.getByText('2 items selected')).toBeInTheDocument(); + }); + + test('should show available tags in dropdown when editing', async () => { + const editingState = { + ...mockEditState, + isEditingTags: true, + editedTags: [], + }; + + render( + ); - fireEvent.keyDown(input, { key: 'Enter' }); - expect(defaultProps.onUpdateState).toHaveBeenCalledWith({ - editedTags: ['tag1', 'tag2', 'newtag'], - editTagInput: '', + const dropdownButton = screen.getByRole('button', { + name: /select items/i, + }); + fireEvent.click(dropdownButton); + + await waitFor(() => { + expect(screen.getByText('work')).toBeInTheDocument(); + expect(screen.getByText('urgent')).toBeInTheDocument(); + expect(screen.getByText('personal')).toBeInTheDocument(); }); }); - test('should remove tag when X button is clicked', () => { + test('should update tags when TagMultiSelect changes', async () => { const editingState = { ...mockEditState, isEditingTags: true, - editedTags: ['tag1', 'tag2'], + editedTags: [], }; render( ); - const removeButtons = screen.getAllByText('✖'); - if (removeButtons.length > 0) { - fireEvent.click(removeButtons[0]); - expect(defaultProps.onUpdateState).toHaveBeenCalled(); - } + const dropdownButton = screen.getByRole('button', { + name: /select items/i, + }); + fireEvent.click(dropdownButton); + + await waitFor(() => { + const workTag = screen.getByText('work'); + fireEvent.click(workTag); + }); + + expect(defaultProps.onUpdateState).toHaveBeenCalledWith({ + editedTags: ['work'], + }); }); test('should save tags when check icon is clicked', () => { @@ -400,7 +423,7 @@ describe('TaskDialog Component', () => { const saveButton = screen .getAllByRole('button') - .find((btn) => btn.getAttribute('aria-label') === 'Save tags'); + .find((btn) => btn.querySelector('.text-green-500')); if (saveButton) { fireEvent.click(saveButton); @@ -409,6 +432,33 @@ describe('TaskDialog Component', () => { 'tag2', 'tag3', ]); + expect(defaultProps.onUpdateState).toHaveBeenCalledWith({ + isEditingTags: false, + }); + } + }); + + test('should cancel editing when X icon is clicked', () => { + const editingState = { + ...mockEditState, + isEditingTags: true, + editedTags: ['tag1', 'tag2', 'tag3'], + }; + + render( + + ); + + const cancelButton = screen + .getAllByRole('button') + .find((btn) => btn.querySelector('.text-red-500')); + + if (cancelButton) { + fireEvent.click(cancelButton); + expect(defaultProps.onUpdateState).toHaveBeenCalledWith({ + isEditingTags: false, + editedTags: mockTask.tags || [], + }); } }); }); diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx index 83436dc0..cd5ac8b4 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx @@ -332,8 +332,13 @@ describe('Tasks Component', () => { const pencilButton = within(tagsRow).getByRole('button'); fireEvent.click(pencilButton); + const tagSelectButton = await screen.findByRole('button', { + name: /select items/i, + }); + fireEvent.click(tagSelectButton); + const editInput = await screen.findByPlaceholderText( - 'Add a tag (press enter to add)' + 'Search or create...' ); fireEvent.change(editInput, { target: { value: 'newtag' } }); @@ -359,8 +364,13 @@ describe('Tasks Component', () => { const pencilButton = within(tagsRow).getByRole('button'); fireEvent.click(pencilButton); + const tagSelectButton = await screen.findByRole('button', { + name: /select items/i, + }); + fireEvent.click(tagSelectButton); + const editInput = await screen.findByPlaceholderText( - 'Add a tag (press enter to add)' + 'Search or create...' ); fireEvent.change(editInput, { target: { value: 'addedtag' } }); @@ -369,7 +379,7 @@ describe('Tasks Component', () => { expect(await screen.findByText('addedtag')).toBeInTheDocument(); const saveButton = await screen.findByRole('button', { - name: /save tags/i, + name: /save/i, }); fireEvent.click(saveButton); @@ -382,9 +392,8 @@ describe('Tasks Component', () => { expect(hooks.editTaskOnBackend).toHaveBeenCalled(); const callArg = hooks.editTaskOnBackend.mock.calls[0][0]; - expect(callArg.tags).toEqual( - expect.arrayContaining(['tag1', 'addedtag']) - ); + // Tags should be sent as a diff with + prefix for additions + expect(callArg.tags).toEqual(['+addedtag']); }); test('removes a tag while editing and saves updated tags to backend', async () => { @@ -402,8 +411,13 @@ describe('Tasks Component', () => { const pencilButton = within(tagsRow).getByRole('button'); fireEvent.click(pencilButton); + const tagSelectButton = await screen.findByRole('button', { + name: /select items/i, + }); + fireEvent.click(tagSelectButton); + const editInput = await screen.findByPlaceholderText( - 'Add a tag (press enter to add)' + 'Search or create...' ); fireEvent.change(editInput, { target: { value: 'newtag' } }); @@ -418,10 +432,17 @@ describe('Tasks Component', () => { const removeButton = within(badgeContainer).getByText('✖'); fireEvent.click(removeButton); - expect(screen.queryByText('tag2')).not.toBeInTheDocument(); + await waitFor(() => { + const selectedTagsArea = screen + .getByText('newtag') + .closest('div')?.parentElement; + expect( + within(selectedTagsArea as HTMLElement).queryByText('tag1') + ).not.toBeInTheDocument(); + }); const saveButton = await screen.findByRole('button', { - name: /save tags/i, + name: /save/i, }); fireEvent.click(saveButton); @@ -435,7 +456,10 @@ describe('Tasks Component', () => { const callArg = hooks.editTaskOnBackend.mock.calls[0][0]; - expect(callArg.tags).toEqual(expect.arrayContaining(['newtag', 'tag1'])); + // Tags should be sent as a diff with - prefix for removals and + prefix for additions + expect(callArg.tags).toEqual( + expect.arrayContaining(['-tag1', '+newtag']) + ); }); it('clicking checkbox does not open task detail dialog', async () => { @@ -1242,14 +1266,17 @@ describe('Tasks Component', () => { const editButton = within(tagsRow).getByLabelText('edit'); fireEvent.click(editButton); - const editInput = await screen.findByPlaceholderText( - 'Add a tag (press enter to add)' - ); + const tagSelectButton = await screen.findByRole('button', { + name: /select items/i, + }); + fireEvent.click(tagSelectButton); + + const editInput = await screen.findByPlaceholderText('Search or create...'); fireEvent.change(editInput, { target: { value: 'unsyncedtag' } }); fireEvent.keyDown(editInput, { key: 'Enter', code: 'Enter' }); - const saveButton = screen.getByLabelText('Save tags'); + const saveButton = screen.getByLabelText('Save items'); fireEvent.click(saveButton); await waitFor(() => { diff --git a/frontend/src/components/HomeComponents/Tasks/multi-select-utils.ts b/frontend/src/components/HomeComponents/Tasks/multi-select-utils.ts new file mode 100644 index 00000000..04ff843a --- /dev/null +++ b/frontend/src/components/HomeComponents/Tasks/multi-select-utils.ts @@ -0,0 +1,24 @@ +export const getFilteredItems = ( + availableItems: string[], + selectedItems: string[], + searchTerm: string +): string[] => { + return availableItems.filter( + (item) => + item.toLowerCase().includes(searchTerm.toLowerCase()) && + !selectedItems.includes(item) + ); +}; + +export const shouldShowCreateOption = ( + searchTerm: string, + availableItems: string[], + selectedItems: string[] +): boolean => { + const trimmed = searchTerm.trim(); + return ( + !!trimmed && + !availableItems.includes(trimmed) && + !selectedItems.includes(trimmed) + ); +}; diff --git a/frontend/src/components/utils/types.ts b/frontend/src/components/utils/types.ts index 8940f94c..b51c7bef 100644 --- a/frontend/src/components/utils/types.ts +++ b/frontend/src/components/utils/types.ts @@ -131,13 +131,16 @@ export interface AddTaskDialogProps { allTasks?: Task[]; } -export interface TagMultiSelectProps { - availableTags: string[]; - selectedTags: string[]; - onTagsChange: (tags: string[]) => void; +export interface MultiSelectProps { + availableItems: string[]; + selectedItems: string[]; + onItemsChange: (items: string[]) => void; placeholder?: string; disabled?: boolean; className?: string; + showActions?: boolean; + onSave?: () => void; + onCancel?: () => void; } export interface EditTaskDialogProps {