diff --git a/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx b/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx index fec867c9..77b57c56 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 { MultiSelect } from './MultiSelect'; 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,21 @@ export const AddTaskdialog = ({
-
- {newTask.tags.length > 0 && ( -
-
-
- {newTask.tags.map((tag, index) => ( - - {tag} - - - ))} -
-
- )} -
diff --git a/frontend/src/components/HomeComponents/Tasks/MultiSelect.tsx b/frontend/src/components/HomeComponents/Tasks/MultiSelect.tsx new file mode 100644 index 00000000..34ed629a --- /dev/null +++ b/frontend/src/components/HomeComponents/Tasks/MultiSelect.tsx @@ -0,0 +1,199 @@ +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 { MultiSelectProps } from '@/components/utils/types'; +import { ChevronDown, Plus, Check, X } from 'lucide-react'; +import { getFilteredItems, shouldShowCreateOption } from './multi-select-utils'; + +export const MultiSelect = ({ + availableItems, + selectedItems, + onItemsChange, + placeholder = 'Select or create items', + disabled = false, + className = '', + showActions = false, + onSave, + onCancel, +}: MultiSelectProps) => { + 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 filteredItems = getFilteredItems( + availableItems, + selectedItems, + searchTerm + ); + + const handleItemSelect = (item: string) => { + if (!selectedItems.includes(item)) { + onItemsChange([...selectedItems, item]); + } + setSearchTerm(''); + }; + + const handleItemRemove = (itemToRemove: string) => { + onItemsChange(selectedItems.filter((item) => item !== itemToRemove)); + }; + + const handleNewItemCreate = () => { + const trimmedTerm = searchTerm.trim(); + if ( + trimmedTerm && + !selectedItems.includes(trimmedTerm) && + !availableItems.includes(trimmedTerm) + ) { + onItemsChange([...selectedItems, trimmedTerm]); + setSearchTerm(''); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (searchTerm.trim()) { + if (filteredItems.length > 0) { + handleItemSelect(filteredItems[0]); + } else { + handleNewItemCreate(); + } + } + } else if (e.key === 'Escape') { + setIsOpen(false); + setSearchTerm(''); + } + }; + + const showCreate = shouldShowCreateOption( + searchTerm, + availableItems, + selectedItems + ); + + return ( +
+ + + {selectedItems.length > 0 && ( +
+ {selectedItems.map((item) => ( + + {item} + + + ))} + {showActions && onSave && onCancel && ( +
+ + +
+ )} +
+ )} + + {isOpen && ( +
+
+ setSearchTerm(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search or create..." + className="h-8" + autoFocus + /> +
+ +
+ {filteredItems.map((item) => ( +
handleItemSelect(item)} + > + {item} +
+ ))} + + {showCreate && ( +
+ + + Create "{searchTerm.trim()}" + +
+ )} + + {filteredItems.length === 0 && !showCreate && ( +
+ No items found +
+ )} +
+
+ )} +
+ ); +}; diff --git a/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx b/frontend/src/components/HomeComponents/Tasks/TaskDialog.tsx index 8c46fe17..522e1f49 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 { MultiSelect } from './MultiSelect'; export const TaskDialog = ({ index, @@ -56,6 +57,7 @@ export const TaskDialog = ({ isCreatingNewProject, setIsCreatingNewProject, uniqueProjects, + uniqueTags, onSaveDescription, onSaveTags, onSavePriority, @@ -1213,105 +1215,25 @@ 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: '', - }); - } - }} - /> - - -
-
- {editState.editedTags != null && - editState.editedTags.length > 0 && ( -
-
- {editState.editedTags.map((tag, index) => ( - - {tag} - - - ))} -
-
- )} -
-
+ + 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 4463de8e..7291385e 100644 --- a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx +++ b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx @@ -105,11 +105,7 @@ 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 || [] - ); const [searchTerm, setSearchTerm] = useState(''); const [debouncedTerm, setDebouncedTerm] = useState(''); const [lastSyncTime, setLastSyncTime] = useState(null); @@ -200,7 +196,6 @@ export const Tasks = ( }, [props.email]); useEffect(() => { if (_selectedTask) { - setEditedTags(_selectedTask.tags || []); } }, [_selectedTask]); @@ -217,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 { @@ -241,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)); @@ -295,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)); @@ -343,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: '', @@ -850,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])); @@ -864,7 +929,7 @@ export const Tasks = ( props.encryptionSecret, props.UUID, task.description, - finalTags, + tagDiff, task.uuid.toString(), task.project, task.start, @@ -1124,12 +1189,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} />
@@ -1254,6 +1318,7 @@ export const Tasks = ( onUpdateState={updateEditState} allTasks={tasks} uniqueProjects={uniqueProjects} + uniqueTags={uniqueTags} isCreatingNewProject={isCreatingNewProject} setIsCreatingNewProject={setIsCreatingNewProject} onSaveDescription={handleSaveDescription} @@ -1435,12 +1500,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/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 d7279701..b51c7bef 100644 --- a/frontend/src/components/utils/types.ts +++ b/frontend/src/components/utils/types.ts @@ -123,15 +123,26 @@ 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[]; } +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 { index: number; task: Task; @@ -145,6 +156,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;