diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index d0b0f31f..52626f3a 100644 --- a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx +++ b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx @@ -61,6 +61,7 @@ import { import Pagination from './Pagination'; import { url } from '@/components/utils/URLs'; import { MultiSelectFilter } from '@/components/ui/multi-select'; +import { TagSelector } from '@/components/ui/tagSelector'; import BottomBar from '../BottomBar/BottomBar'; import { addTaskToBackend, @@ -106,7 +107,6 @@ export const Tasks = ( }); const [isAddTaskOpen, setIsAddTaskOpen] = useState(false); const [_isDialogOpen, setIsDialogOpen] = useState(false); - const [tagInput, setTagInput] = useState(''); const [isEditing, setIsEditing] = useState(false); const [editedDescription, setEditedDescription] = useState(''); @@ -114,7 +114,6 @@ export const Tasks = ( const [editedTags, setEditedTags] = useState( _selectedTask?.tags || [] ); - const [editTagInput, setEditTagInput] = useState(''); const [isEditingTags, setIsEditingTags] = useState(false); const [isEditingPriority, setIsEditingPriority] = useState(false); const [editedPriority, setEditedPriority] = useState('NONE'); @@ -609,35 +608,6 @@ export const Tasks = ( } }; - // Handle adding a tag - const handleAddTag = () => { - if (tagInput && !newTask.tags.includes(tagInput, 0)) { - setNewTask({ ...newTask, tags: [...newTask.tags, tagInput] }); - setTagInput(''); // Clear the input field - } - }; - - // Handle adding a tag while editing - const handleAddEditTag = () => { - if (editTagInput && !editedTags.includes(editTagInput, 0)) { - setEditedTags([...editedTags, editTagInput]); - setEditTagInput(''); - } - }; - - // Handle removing a tag - const handleRemoveTag = (tagToRemove: string) => { - setNewTask({ - ...newTask, - tags: newTask.tags.filter((tag) => tag !== tagToRemove), - }); - }; - - // Handle removing a tag while editing task - const handleRemoveEditTag = (tagToRemove: string) => { - setEditedTags(editedTags.filter((tag) => tag !== tagToRemove)); - }; - const sortWithOverdueOnTop = (tasks: Task[]) => { return [...tasks].sort((a, b) => { const aOverdue = a.status === 'pending' && isOverdue(a.due); @@ -723,8 +693,7 @@ export const Tasks = ( task.depends || [] ); - setIsEditingTags(false); - setEditTagInput(''); + setIsEditingTags(false); // Exit editing mode }; const handleCancelTags = () => { @@ -1062,40 +1031,16 @@ export const Tasks = ( > Tags - setTagInput(e.target.value)} - onKeyDown={(e) => - e.key === 'Enter' && handleAddTag() - } // Allow adding tag on pressing Enter - required - className="col-span-3" - /> - - -
- {newTask.tags.length > 0 && ( -
-
-
- {newTask.tags.map((tag, index) => ( - - {tag} - - - ))} -
-
- )} +
+ + setNewTask({ ...newTask, tags: updated }) + } + placeholder="Select or Create Tags" + /> +
@@ -1924,86 +1869,31 @@ export const Tasks = ( Tags: {isEditingTags ? ( -
-
- { - // For allowing only alphanumeric characters - if ( - e.target.value.length > 1 - ) { - /^[a-zA-Z0-9]*$/.test( - e.target.value.trim() - ) - ? setEditTagInput( - e.target.value.trim() - ) - : ''; - } else { - /^[a-zA-Z]*$/.test( - e.target.value.trim() - ) - ? setEditTagInput( - e.target.value.trim() - ) - : ''; - } - }} - placeholder="Add a tag (press enter to add)" - className="flex-grow mr-2" - onKeyDown={(e) => - e.key === 'Enter' && - handleAddEditTag() - } - /> - - -
-
- {editedTags != null && - editedTags.length > 0 && ( -
-
- {editedTags.map( - (tag, index) => ( - - {tag} - - - ) - )} -
-
- )} -
+
+ + setEditedTags(updated) + } + placeholder="Select or Create Tags" + /> + +
) : (
diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx index b5371f55..e58bd4be 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx @@ -1,11 +1,4 @@ -import { - render, - screen, - fireEvent, - act, - within, - waitFor, -} from '@testing-library/react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; import { Tasks } from '../Tasks'; // Mock props for the Tasks component @@ -45,6 +38,14 @@ jest.mock('@/components/ui/multi-select', () => ({ )), })); +jest.mock('@/components/ui/tagSelector', () => ({ + TagSelector: jest.fn(({ selected }) => ( +
+ Mocked TagSelector - Selected: {selected?.join(', ') || 'none'} +
+ )), +})); + jest.mock('../../BottomBar/BottomBar', () => { return jest.fn(() =>
Mocked BottomBar
); }); @@ -179,123 +180,6 @@ describe('Tasks Component', () => { expect(screen.getByTestId('current-page')).toHaveTextContent('1'); }); - test('shows tags as badges in task dialog and allows editing (add on Enter)', async () => { - render(); - - expect(await screen.findByText('Task 1')).toBeInTheDocument(); - - const taskRow = screen.getByText('Task 1'); - fireEvent.click(taskRow); - - expect(await screen.findByText('Tags:')).toBeInTheDocument(); - - expect(screen.getByText('tag1')).toBeInTheDocument(); - - const tagsLabel = screen.getByText('Tags:'); - const tagsRow = tagsLabel.closest('tr') as HTMLElement; - const pencilButton = within(tagsRow).getByRole('button'); - fireEvent.click(pencilButton); - - const editInput = await screen.findByPlaceholderText( - 'Add a tag (press enter to add)' - ); - - fireEvent.change(editInput, { target: { value: 'newtag' } }); - fireEvent.keyDown(editInput, { key: 'Enter', code: 'Enter' }); - - expect(await screen.findByText('newtag')).toBeInTheDocument(); - - expect((editInput as HTMLInputElement).value).toBe(''); - }); - - test('adds a tag while editing and saves updated tags to backend', async () => { - render(); - - expect(await screen.findByText('Task 1')).toBeInTheDocument(); - - const taskRow = screen.getByText('Task 1'); - fireEvent.click(taskRow); - - expect(await screen.findByText('Tags:')).toBeInTheDocument(); - - const tagsLabel = screen.getByText('Tags:'); - const tagsRow = tagsLabel.closest('tr') as HTMLElement; - const pencilButton = within(tagsRow).getByRole('button'); - fireEvent.click(pencilButton); - - const editInput = await screen.findByPlaceholderText( - 'Add a tag (press enter to add)' - ); - - fireEvent.change(editInput, { target: { value: 'addedtag' } }); - fireEvent.keyDown(editInput, { key: 'Enter', code: 'Enter' }); - - expect(await screen.findByText('addedtag')).toBeInTheDocument(); - - const saveButton = await screen.findByRole('button', { - name: /save tags/i, - }); - fireEvent.click(saveButton); - - await waitFor(() => { - const hooks = require('../hooks'); - expect(hooks.editTaskOnBackend).toHaveBeenCalled(); - }); - - const hooks = require('../hooks'); - const callArg = hooks.editTaskOnBackend.mock.calls[0][0]; - expect(callArg.tags).toEqual(expect.arrayContaining(['tag1', 'addedtag'])); - }); - - test('removes a tag while editing and saves updated tags to backend', async () => { - render(); - - expect(await screen.findByText('Task 1')).toBeInTheDocument(); - - const taskRow = screen.getByText('Task 1'); - fireEvent.click(taskRow); - - expect(await screen.findByText('Tags:')).toBeInTheDocument(); - - const tagsLabel = screen.getByText('Tags:'); - const tagsRow = tagsLabel.closest('tr') as HTMLElement; - const pencilButton = within(tagsRow).getByRole('button'); - fireEvent.click(pencilButton); - - const editInput = await screen.findByPlaceholderText( - 'Add a tag (press enter to add)' - ); - - fireEvent.change(editInput, { target: { value: 'newtag' } }); - fireEvent.keyDown(editInput, { key: 'Enter', code: 'Enter' }); - - expect(await screen.findByText('newtag')).toBeInTheDocument(); - - const tagBadge = screen.getByText('tag1'); - const badgeContainer = (tagBadge.closest('div') || - tagBadge.parentElement) as HTMLElement; - - const removeButton = within(badgeContainer).getByText('✖'); - fireEvent.click(removeButton); - - expect(screen.queryByText('tag1')).not.toBeInTheDocument(); - - const saveButton = await screen.findByRole('button', { - name: /save tags/i, - }); - fireEvent.click(saveButton); - - await waitFor(() => { - const hooks = require('../hooks'); - expect(hooks.editTaskOnBackend).toHaveBeenCalled(); - }); - - const hooks = require('../hooks'); - const callArg = hooks.editTaskOnBackend.mock.calls[0][0]; - - expect(callArg.tags).toEqual(expect.arrayContaining(['newtag', '-tag1'])); - }); - test('shows red background on task ID and Overdue badge for overdue tasks', async () => { render(); @@ -314,6 +198,7 @@ describe('Tasks Component', () => { const overdueBadge = await screen.findByText('Overdue'); expect(overdueBadge).toBeInTheDocument(); }); + test('filters tasks with fuzzy search (handles typos)', async () => { jest.useFakeTimers(); @@ -336,4 +221,47 @@ describe('Tasks Component', () => { jest.useRealTimers(); }); + + test('renders mocked TagSelector in Add Task dialog', async () => { + render(); + + const addButton = screen.getByRole('button', { name: /add task/i }); + + fireEvent.click(addButton); + + expect(await screen.findByTestId('mock-tag-selector')).toBeInTheDocument(); + }); + + test('TagSelector receives correct options and selected values', async () => { + render(); + + fireEvent.click(screen.getAllByText('Add Task')[0]); + + const tagSelector = await screen.findByTestId('mock-tag-selector'); + + expect(tagSelector).toHaveTextContent('Selected: none'); + }); + + test('Selecting tags updates newTask state', async () => { + ( + require('@/components/ui/tagSelector').TagSelector as jest.Mock + ).mockImplementation(({ selected, onChange }) => ( +
+ +
+ {selected?.join(',') || 'none'} +
+
+ )); + + render(); + + fireEvent.click(screen.getAllByText('Add Task')[0]); + + fireEvent.click(await screen.findByTestId('add-tag')); + + expect(screen.getByTestId('mock-tag-selector')).toHaveTextContent('tag1'); + }); }); diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx index 24e02d1a..c31de5c5 100644 --- a/frontend/src/components/ui/popover.tsx +++ b/frontend/src/components/ui/popover.tsx @@ -11,18 +11,16 @@ const PopoverContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( - - - + )); PopoverContent.displayName = PopoverPrimitive.Content.displayName; diff --git a/frontend/src/components/ui/tagSelector.tsx b/frontend/src/components/ui/tagSelector.tsx new file mode 100644 index 00000000..0000af49 --- /dev/null +++ b/frontend/src/components/ui/tagSelector.tsx @@ -0,0 +1,150 @@ +import * as React from 'react'; +import { Check, ChevronsUpDown, Plus, X } from 'lucide-react'; + +import { cn } from '@/components/utils/utils'; +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; + +interface TagSelectorProps { + options: string[]; // all existing tags (uniqueTags) + selected: string[]; // currently selected tags + onChange: (tags: string[]) => void; // update parent state + placeholder?: string; // optional placeholder text +} + +export function TagSelector({ + options, + selected, + onChange, + placeholder = 'Select or create tags…', +}: TagSelectorProps) { + const [open, setOpen] = React.useState(false); + const [search, setSearch] = React.useState(''); + + const handleCreateTag = () => { + const newTag = search.trim(); + + if (!newTag) return; + if (selected.includes(newTag)) return; + + onChange([...selected, newTag]); + setSearch(''); + }; + + // Toggle existing tag selection + const handleSelectTag = (tag: string) => { + const alreadySelected = selected.includes(tag); + + if (alreadySelected) { + onChange(selected.filter((t) => t !== tag)); + } else { + onChange([...selected, tag]); + } + }; + + // Remove a tag when X inside the chip is clicked + const removeTagInsideChip = (tag: string, e: React.MouseEvent) => { + e.stopPropagation(); + onChange(selected.filter((t) => t !== tag)); + }; + + return ( + <> + + + + + + + + setSearch(e.target.value)} + /> + + No results found. + + + {/* CREATE NEW TAG OPTION */} + {search.trim() !== '' && !options.includes(search.trim()) && ( + handleCreateTag()} + className="flex items-center cursor-pointer text-green-500" + > + + Create "{search}" + + )} + + {/* EXISTING TAGS */} + {options.map((tag) => { + const isSelected = selected.includes(tag); + + return ( + handleSelectTag(tag)} + > + + {tag} + + ); + })} + + + + + + + ); +}