From 1ba860d24cbd291ce14960bc662fa7b2376edc6c Mon Sep 17 00:00:00 2001 From: Shiva Gupta Date: Thu, 20 Nov 2025 17:02:15 +0530 Subject: [PATCH 1/2] feat(tags): add tag selector dropdown with multi-select & autocomplete Implemented new TagSelector component with searchable dropdown and create-tag support. Replaced manual tag input in Add Task and Edit Task dialogs. Integrated existing user-defined tags (uniqueTags) into the selector. Removed Popover.Portal to fix cursor and interaction issues inside dialogs. Updated tests to mock TagSelector and verify new tag state updates. Improves tagging UX and prevents inconsistent or typo-prone tags. Fixes: #210 --- .../components/HomeComponents/Tasks/Tasks.tsx | 154 ++++------------ .../Tasks/__tests__/Tasks.test.tsx | 170 ++++++------------ frontend/src/components/ui/popover.tsx | 22 ++- frontend/src/components/ui/tagSelector.tsx | 138 ++++++++++++++ 4 files changed, 236 insertions(+), 248 deletions(-) create mode 100644 frontend/src/components/ui/tagSelector.tsx diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index d0b0f31f..e2fe326c 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,22 +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({ @@ -633,11 +616,6 @@ export const Tasks = ( }); }; - // 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 +701,7 @@ export const Tasks = ( task.depends || [] ); - setIsEditingTags(false); - setEditTagInput(''); + setIsEditingTags(false); // Exit editing mode }; const handleCancelTags = () => { @@ -1062,18 +1039,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" - /> +
+ + setNewTask({ ...newTask, tags: updated }) + } + placeholder="Select or Create Tags" + /> +
@@ -1924,86 +1899,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..25ccf36c 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx @@ -3,8 +3,6 @@ import { screen, fireEvent, act, - within, - waitFor, } from '@testing-library/react'; import { Tasks } from '../Tasks'; @@ -45,6 +43,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 +185,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 +203,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 +226,46 @@ describe('Tasks Component', () => { jest.useRealTimers(); }); + + test('renders mocked TagSelector in Add Task dialog', async () => { + render(); + + const addButton = screen.getAllByText('Add Task')[0]; + 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..77afff62 --- /dev/null +++ b/frontend/src/components/ui/tagSelector.tsx @@ -0,0 +1,138 @@ +import * as React from 'react'; +import { Check, ChevronsUpDown, Plus } 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]); + } + }; + 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} + + ); + })} + + + + + + + ); +} From 46b78b381dfec01862bc6ae190cf986699f67329 Mon Sep 17 00:00:00 2001 From: Shiva Gupta Date: Fri, 21 Nov 2025 23:58:59 +0530 Subject: [PATCH 2/2] fix: update Tasks, tests, and tagSelector --- .../components/HomeComponents/Tasks/Tasks.tsx | 30 ------------------- .../Tasks/__tests__/Tasks.test.tsx | 10 ++----- frontend/src/components/ui/tagSelector.tsx | 18 +++++++++-- 3 files changed, 18 insertions(+), 40 deletions(-) diff --git a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx index e2fe326c..52626f3a 100644 --- a/frontend/src/components/HomeComponents/Tasks/Tasks.tsx +++ b/frontend/src/components/HomeComponents/Tasks/Tasks.tsx @@ -608,14 +608,6 @@ export const Tasks = ( } }; - // Handle removing a tag - const handleRemoveTag = (tagToRemove: string) => { - setNewTask({ - ...newTask, - tags: newTask.tags.filter((tag) => tag !== tagToRemove), - }); - }; - const sortWithOverdueOnTop = (tasks: Task[]) => { return [...tasks].sort((a, b) => { const aOverdue = a.status === 'pending' && isOverdue(a.due); @@ -1050,28 +1042,6 @@ export const Tasks = ( />
- -
- {newTask.tags.length > 0 && ( -
-
-
- {newTask.tags.map((tag, index) => ( - - {tag} - - - ))} -
-
- )} -