From 1465795ddf359e3589fa9787579ad17942deac56 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 54d4482b..846843cc 100644 --- a/frontend/src/components/utils/types.ts +++ b/frontend/src/components/utils/types.ts @@ -128,6 +128,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 eea25ec1caf35e9d4aad2f55146ed96000d11e64 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 | 54 +++---------------- .../components/HomeComponents/Tasks/Tasks.tsx | 7 +-- frontend/src/components/utils/types.ts | 3 +- 3 files changed, 10 insertions(+), 54 deletions(-) diff --git a/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx b/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx index 18ed7f7e..1cbd4ad5 100644 --- a/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx +++ b/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx @@ -24,18 +24,18 @@ import { } from '@/components/ui/select'; import { AddTaskDialogProps } from '@/components/utils/types'; import { format } from 'date-fns'; +import { TagMultiSelect } from './TagMultiSelect'; export const AddTaskdialog = ({ isOpen, setIsOpen, newTask, setNewTask, - tagInput, - setTagInput, onSubmit, isCreatingNewProject, setIsCreatingNewProject, uniqueProjects = [], + uniqueTags = [], allTasks = [], }: AddTaskDialogProps) => { const [annotationInput, setAnnotationInput] = useState(''); @@ -102,20 +102,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 ( @@ -417,40 +403,14 @@ export const AddTaskdialog = ({ Tags
- setTagInput(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleAddTag()} - required - className="col-span-6" + setNewTask({ ...newTask, tags })} + placeholder="Select or create tags" />
- -
- {newTask.tags.length > 0 && ( -
-
-
- {newTask.tags.map((tag, index) => ( - - {tag} - - - ))} -
-
- )} -
@@ -1433,12 +1431,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 846843cc..424386df 100644 --- a/frontend/src/components/utils/types.ts +++ b/frontend/src/components/utils/types.ts @@ -119,12 +119,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 49a0ea1138d2a1b2cafa052324794ae947a92578 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 2c2be1e6..3d28d89d 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 { 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 e43369d9..8d69e629 100644 --- a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx +++ b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx @@ -1251,6 +1251,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 424386df..96114cae 100644 --- a/frontend/src/components/utils/types.ts +++ b/frontend/src/components/utils/types.ts @@ -149,6 +149,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 3affb2ad3fb7f96b4614deda30a1bd06a88387a1 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 --- backend/utils/tw/edit_task.go | 30 +- .../HomeComponents/Tasks/AddTaskDialog.tsx | 10 +- .../{TagMultiSelect.tsx => MultiSelect.tsx} | 166 +++--- .../HomeComponents/Tasks/TaskDialog.tsx | 57 +- .../components/HomeComponents/Tasks/Tasks.tsx | 20 +- .../Tasks/__tests__/AddTaskDialog.test.tsx | 84 +-- .../Tasks/__tests__/MultiSelect.test.tsx | 506 ++++++++++++++++++ .../Tasks/__tests__/TaskDialog.test.tsx | 84 ++- .../Tasks/__tests__/Tasks.test.tsx | 47 +- .../Tasks/multi-select-utils.ts | 24 + frontend/src/components/utils/types.ts | 11 +- 11 files changed, 829 insertions(+), 210 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/backend/utils/tw/edit_task.go b/backend/utils/tw/edit_task.go index cf185ff8..b7056670 100644 --- a/backend/utils/tw/edit_task.go +++ b/backend/utils/tw/edit_task.go @@ -51,22 +51,24 @@ func EditTaskInTaskwarrior(uuid, description, email, encryptionSecret, taskID st } // Handle tags + + output, err := utils.ExecCommandForOutputInDir(tempDir, "task", taskID, "export") + if err == nil { + var tasks []map[string]interface{} + if err := json.Unmarshal(output, &tasks); err == nil && len(tasks) > 0 { + if existingTags, ok := tasks[0]["tags"].([]interface{}); ok { + for _, tag := range existingTags { + if tagStr, ok := tag.(string); ok { + utils.ExecCommand("task", taskID, "modify", "-"+tagStr) + } + } + } + } + } + if len(tags) > 0 { for _, tag := range tags { - if strings.HasPrefix(tag, "+") { - // Add tag - tagValue := strings.TrimPrefix(tag, "+") - if err := utils.ExecCommand("task", taskID, "modify", "+"+tagValue); err != nil { - return fmt.Errorf("failed to add tag %s: %v", tagValue, err) - } - } else if strings.HasPrefix(tag, "-") { - // Remove tag - tagValue := strings.TrimPrefix(tag, "-") - if err := utils.ExecCommand("task", taskID, "modify", "-"+tagValue); err != nil { - return fmt.Errorf("failed to remove tag %s: %v", tagValue, err) - } - } else { - // Add tag without prefix + if tag != "" { if err := utils.ExecCommand("task", taskID, "modify", "+"+tag); err != nil { return fmt.Errorf("failed to add tag %s: %v", tag, err) } diff --git a/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx b/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx index 1cbd4ad5..1fe5fe1c 100644 --- a/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx +++ b/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx @@ -23,8 +23,8 @@ import { SelectValue, } from '@/components/ui/select'; import { AddTaskDialogProps } from '@/components/utils/types'; +import { MultiSelect } from './MultiSelect'; import { format } from 'date-fns'; -import { TagMultiSelect } from './TagMultiSelect'; export const AddTaskdialog = ({ isOpen, @@ -403,10 +403,10 @@ 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 3d28d89d..2d81219d 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 { 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 8d69e629..3e763299 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 { @@ -850,11 +839,8 @@ export const Tasks = ( ]); 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 updatedTags = tags.filter((tag) => tag.trim() !== ''); + task.tags = updatedTags; setUnsyncedTaskUuids((prev) => new Set([...prev, task.uuid])); @@ -863,7 +849,7 @@ export const Tasks = ( props.encryptionSecret, props.UUID, task.description, - finalTags, + updatedTags, 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 51d11ab6..b6435827 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 36886824..e48b380d 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx @@ -336,8 +336,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' } }); @@ -363,8 +368,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' } }); @@ -373,7 +383,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); @@ -406,8 +416,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' } }); @@ -422,10 +437,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); @@ -439,7 +461,7 @@ describe('Tasks Component', () => { const callArg = hooks.editTaskOnBackend.mock.calls[0][0]; - expect(callArg.tags).toEqual(expect.arrayContaining(['newtag', 'tag1'])); + expect(callArg.tags).toEqual(expect.arrayContaining(['newtag'])); }); it('clicking checkbox does not open task detail dialog', async () => { @@ -1246,14 +1268,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 96114cae..1acc74b1 100644 --- a/frontend/src/components/utils/types.ts +++ b/frontend/src/components/utils/types.ts @@ -127,13 +127,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 {