Skip to content

Commit bc01a43

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 1cee133 commit bc01a43

File tree

4 files changed

+235
-126
lines changed

4 files changed

+235
-126
lines changed

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

Lines changed: 36 additions & 114 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');
@@ -575,22 +574,6 @@ export const Tasks = (
575574
}
576575
};
577576

578-
// Handle adding a tag
579-
const handleAddTag = () => {
580-
if (tagInput && !newTask.tags.includes(tagInput, 0)) {
581-
setNewTask({ ...newTask, tags: [...newTask.tags, tagInput] });
582-
setTagInput(''); // Clear the input field
583-
}
584-
};
585-
586-
// Handle adding a tag while editing
587-
const handleAddEditTag = () => {
588-
if (editTagInput && !editedTags.includes(editTagInput, 0)) {
589-
setEditedTags([...editedTags, editTagInput]);
590-
setEditTagInput('');
591-
}
592-
};
593-
594577
// Handle removing a tag
595578
const handleRemoveTag = (tagToRemove: string) => {
596579
setNewTask({
@@ -599,11 +582,6 @@ export const Tasks = (
599582
});
600583
};
601584

602-
// Handle removing a tag while editing task
603-
const handleRemoveEditTag = (tagToRemove: string) => {
604-
setEditedTags(editedTags.filter((tag) => tag !== tagToRemove));
605-
};
606-
607585
const sortWithOverdueOnTop = (tasks: Task[]) => {
608586
return [...tasks].sort((a, b) => {
609587
const aOverdue = a.status === 'pending' && isOverdue(a.due);
@@ -693,7 +671,6 @@ export const Tasks = (
693671
);
694672

695673
setIsEditingTags(false); // Exit editing mode
696-
setEditTagInput(''); // Reset edit tag input
697674
};
698675

699676
const handleCancelTags = () => {
@@ -958,18 +935,16 @@ export const Tasks = (
958935
>
959936
Tags
960937
</Label>
961-
<Input
962-
id="tags"
963-
name="tags"
964-
placeholder="Add a tag"
965-
value={tagInput}
966-
onChange={(e) => setTagInput(e.target.value)}
967-
onKeyDown={(e) =>
968-
e.key === 'Enter' && handleAddTag()
969-
} // Allow adding tag on pressing Enter
970-
required
971-
className="col-span-3"
972-
/>
938+
<div className="col-span-3">
939+
<TagSelector
940+
options={uniqueTags}
941+
selected={newTask.tags}
942+
onChange={(updated) =>
943+
setNewTask({ ...newTask, tags: updated })
944+
}
945+
placeholder="Select or Create Tags"
946+
/>
947+
</div>
973948
</div>
974949

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