Skip to content

Commit 34a414f

Browse files
committed
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
1 parent 28e2825 commit 34a414f

File tree

4 files changed

+236
-127
lines changed

4 files changed

+236
-127
lines changed

frontend/src/components/HomeComponents/Tasks/Tasks.tsx

Lines changed: 37 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import {
6060
import Pagination from './Pagination';
6161
import { url } from '@/components/utils/URLs';
6262
import { MultiSelectFilter } from '@/components/ui/multiSelect';
63+
import { TagSelector } from '@/components/ui/tagSelector';
6364
import BottomBar from '../BottomBar/BottomBar';
6465
import {
6566
addTaskToBackend,
@@ -104,15 +105,13 @@ export const Tasks = (
104105
});
105106
const [isAddTaskOpen, setIsAddTaskOpen] = useState(false);
106107
const [_isDialogOpen, setIsDialogOpen] = useState(false);
107-
const [tagInput, setTagInput] = useState('');
108108

109109
const [isEditing, setIsEditing] = useState(false);
110110
const [editedDescription, setEditedDescription] = useState('');
111111
const [_selectedTask, setSelectedTask] = useState<Task | null>(null);
112112
const [editedTags, setEditedTags] = useState<string[]>(
113113
_selectedTask?.tags || []
114114
);
115-
const [editTagInput, setEditTagInput] = useState<string>('');
116115
const [isEditingTags, setIsEditingTags] = useState(false);
117116
const [isEditingPriority, setIsEditingPriority] = useState(false);
118117
const [editedPriority, setEditedPriority] = useState('NONE');
@@ -568,22 +567,6 @@ export const Tasks = (
568567
}
569568
};
570569

571-
// Handle adding a tag
572-
const handleAddTag = () => {
573-
if (tagInput && !newTask.tags.includes(tagInput, 0)) {
574-
setNewTask({ ...newTask, tags: [...newTask.tags, tagInput] });
575-
setTagInput(''); // Clear the input field
576-
}
577-
};
578-
579-
// Handle adding a tag while editing
580-
const handleAddEditTag = () => {
581-
if (editTagInput && !editedTags.includes(editTagInput, 0)) {
582-
setEditedTags([...editedTags, editTagInput]);
583-
setEditTagInput('');
584-
}
585-
};
586-
587570
// Handle removing a tag
588571
const handleRemoveTag = (tagToRemove: string) => {
589572
setNewTask({
@@ -592,11 +575,6 @@ export const Tasks = (
592575
});
593576
};
594577

595-
// Handle removing a tag while editing task
596-
const handleRemoveEditTag = (tagToRemove: string) => {
597-
setEditedTags(editedTags.filter((tag) => tag !== tagToRemove));
598-
};
599-
600578
const sortWithOverdueOnTop = (tasks: Task[]) => {
601579
return [...tasks].sort((a, b) => {
602580
const aOverdue = a.status === 'pending' && isOverdue(a.due);
@@ -682,8 +660,7 @@ export const Tasks = (
682660
task.depends || []
683661
);
684662

685-
setIsEditingTags(false);
686-
setEditTagInput('');
663+
setIsEditingTags(false); // Exit editing mode
687664
};
688665

689666
const handleCancelTags = () => {
@@ -946,18 +923,16 @@ export const Tasks = (
946923
>
947924
Tags
948925
</Label>
949-
<Input
950-
id="tags"
951-
name="tags"
952-
placeholder="Add a tag"
953-
value={tagInput}
954-
onChange={(e) => setTagInput(e.target.value)}
955-
onKeyDown={(e) =>
956-
e.key === 'Enter' && handleAddTag()
957-
} // Allow adding tag on pressing Enter
958-
required
959-
className="col-span-3"
960-
/>
926+
<div className="col-span-3">
927+
<TagSelector
928+
options={uniqueTags}
929+
selected={newTask.tags}
930+
onChange={(updated) =>
931+
setNewTask({ ...newTask, tags: updated })
932+
}
933+
placeholder="Select or Create Tags"
934+
/>
935+
</div>
961936
</div>
962937

963938
<div className="mt-2">
@@ -1802,84 +1777,31 @@ export const Tasks = (
18021777
<TableCell>Tags:</TableCell>
18031778
<TableCell>
18041779
{isEditingTags ? (
1805-
<div>
1806-
<div className="flex items-center w-full">
1807-
<Input
1808-
type="text"
1809-
value={editTagInput}
1810-
onChange={(e) => {
1811-
// For allowing only alphanumeric characters
1812-
if (
1813-
e.target.value.length > 1
1814-
) {
1815-
/^[a-zA-Z0-9]*$/.test(
1816-
e.target.value.trim()
1817-
)
1818-
? setEditTagInput(
1819-
e.target.value.trim()
1820-
)
1821-
: '';
1822-
} else {
1823-
/^[a-zA-Z]*$/.test(
1824-
e.target.value.trim()
1825-
)
1826-
? setEditTagInput(
1827-
e.target.value.trim()
1828-
)
1829-
: '';
1830-
}
1831-
}}
1832-
placeholder="Add a tag (press enter to add)"
1833-
className="flex-grow mr-2"
1834-
onKeyDown={(e) =>
1835-
e.key === 'Enter' &&
1836-
handleAddEditTag()
1837-
}
1838-
/>
1839-
<Button
1840-
variant="ghost"
1841-
size="icon"
1842-
onClick={() =>
1843-
handleSaveTags(task)
1844-
}
1845-
>
1846-
<CheckIcon className="h-4 w-4 text-green-500" />
1847-
</Button>
1848-
<Button
1849-
variant="ghost"
1850-
size="icon"
1851-
onClick={handleCancelTags}
1852-
>
1853-
<XIcon className="h-4 w-4 text-red-500" />
1854-
</Button>
1855-
</div>
1856-
<div className="mt-2">
1857-
{editedTags != null &&
1858-
editedTags.length > 0 && (
1859-
<div>
1860-
<div className="flex flex-wrap gap-2 col-span-3">
1861-
{editedTags.map(
1862-
(tag, index) => (
1863-
<Badge key={index}>
1864-
<span>{tag}</span>
1865-
<button
1866-
type="button"
1867-
className="ml-2 text-red-500"
1868-
onClick={() =>
1869-
handleRemoveEditTag(
1870-
tag
1871-
)
1872-
}
1873-
>
1874-
1875-
</button>
1876-
</Badge>
1877-
)
1878-
)}
1879-
</div>
1880-
</div>
1881-
)}
1882-
</div>
1780+
<div className="flex items-center">
1781+
<TagSelector
1782+
options={uniqueTags}
1783+
selected={editedTags}
1784+
onChange={(updated) =>
1785+
setEditedTags(updated)
1786+
}
1787+
placeholder="Select or Create Tags"
1788+
/>
1789+
<Button
1790+
variant="ghost"
1791+
size="icon"
1792+
onClick={() =>
1793+
handleSaveTags(task)
1794+
}
1795+
>
1796+
<CheckIcon className="h-4 w-4 text-green-500" />
1797+
</Button>
1798+
<Button
1799+
variant="ghost"
1800+
size="icon"
1801+
onClick={handleCancelTags}
1802+
>
1803+
<XIcon className="h-4 w-4 text-red-500" />
1804+
</Button>
18831805
</div>
18841806
) : (
18851807
<div className="flex items-center flex-wrap">

frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ jest.mock('@/components/ui/multiSelect', () => ({
3838
)),
3939
}));
4040

41+
jest.mock('@/components/ui/tagSelector', () => ({
42+
TagSelector: jest.fn(({ selected }) => (
43+
<div data-testid="mock-tag-selector">
44+
Mocked TagSelector - Selected: {selected?.join(', ') || 'none'}
45+
</div>
46+
)),
47+
}));
48+
4149
jest.mock('../../BottomBar/BottomBar', () => {
4250
return jest.fn(() => <div>Mocked BottomBar</div>);
4351
});
@@ -301,6 +309,7 @@ describe('Tasks Component', () => {
301309
const overdueBadge = await screen.findByText('Overdue');
302310
expect(overdueBadge).toBeInTheDocument();
303311
});
312+
304313
test('filters tasks with fuzzy search (handles typos)', async () => {
305314
jest.useFakeTimers();
306315

@@ -323,4 +332,46 @@ describe('Tasks Component', () => {
323332

324333
jest.useRealTimers();
325334
});
335+
336+
test('renders mocked TagSelector in Add Task dialog', async () => {
337+
render(<Tasks {...mockProps} />);
338+
339+
const addButton = screen.getAllByText('Add Task')[0];
340+
fireEvent.click(addButton);
341+
342+
expect(await screen.findByTestId('mock-tag-selector')).toBeInTheDocument();
343+
});
344+
345+
test('TagSelector receives correct options and selected values', async () => {
346+
render(<Tasks {...mockProps} />);
347+
348+
fireEvent.click(screen.getAllByText('Add Task')[0]);
349+
350+
const tagSelector = await screen.findByTestId('mock-tag-selector');
351+
352+
expect(tagSelector).toHaveTextContent('Selected: none');
353+
});
354+
355+
test('Selecting tags updates newTask state', async () => {
356+
(
357+
require('@/components/ui/tagSelector').TagSelector as jest.Mock
358+
).mockImplementation(({ selected, onChange }) => (
359+
<div>
360+
<button data-testid="add-tag" onClick={() => onChange(['tag1'])}>
361+
Add Tag1
362+
</button>
363+
<div data-testid="mock-tag-selector">
364+
{selected?.join(',') || 'none'}
365+
</div>
366+
</div>
367+
));
368+
369+
render(<Tasks {...mockProps} />);
370+
371+
fireEvent.click(screen.getAllByText('Add Task')[0]);
372+
373+
fireEvent.click(await screen.findByTestId('add-tag'));
374+
375+
expect(screen.getByTestId('mock-tag-selector')).toHaveTextContent('tag1');
376+
});
326377
});

frontend/src/components/ui/popover.tsx

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,16 @@ const PopoverContent = React.forwardRef<
1111
React.ElementRef<typeof PopoverPrimitive.Content>,
1212
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
1313
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
14-
<PopoverPrimitive.Portal>
15-
<PopoverPrimitive.Content
16-
ref={ref}
17-
align={align}
18-
sideOffset={sideOffset}
19-
className={cn(
20-
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]',
21-
className
22-
)}
23-
{...props}
24-
/>
25-
</PopoverPrimitive.Portal>
14+
<PopoverPrimitive.Content
15+
ref={ref}
16+
align={align}
17+
sideOffset={sideOffset}
18+
className={cn(
19+
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]',
20+
className
21+
)}
22+
{...props}
23+
/>
2624
));
2725
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
2826

0 commit comments

Comments
 (0)