Skip to content

Commit f96c5db

Browse files
committed
feat(tags): implement multi-select tag component for Add/Edit Task dialogs
- Create AddMultiSelect component with search, select, and create functionality - Display selected tags as removable chips with X buttons - Add search filtering for existing tags - Add inline "Create new tag" option for non-existing tags - Integrate AddMultiSelect into TaskDialog and AddTaskDialog Bug fix: - Fix tag removal not persisting: now sends tags with "-" prefix to backend for removals - Updated handleSaveTags to calculate tag additions and removals correctly Tests: - Add comprehensive tests for AddMultiSelect component - Update AddTaskDialog tests for new tag selection flow - Update TaskDialog tests for tag editing with AddMultiSelect - Mock AddMultiSelect in tests Fixes: #210
1 parent bf7b859 commit f96c5db

File tree

12 files changed

+796
-360
lines changed

12 files changed

+796
-360
lines changed
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import * as React from 'react';
2+
import { Check, ChevronDown, X } from 'lucide-react';
3+
import { cn } from '@/components/utils/utils';
4+
import { Input } from '@/components/ui/input';
5+
import {
6+
Popover,
7+
PopoverContent,
8+
PopoverTrigger,
9+
} from '@/components/ui/popover';
10+
import { Button } from '@/components/ui/button';
11+
12+
interface AddMultiSelectProps {
13+
options: string[];
14+
selected: string[];
15+
onChange: (items: string[]) => void;
16+
placeholder?: string;
17+
portalContainer?: HTMLElement | null;
18+
}
19+
20+
export function AddMultiSelect({
21+
options,
22+
selected,
23+
onChange,
24+
placeholder = 'Search or create..',
25+
portalContainer,
26+
}: AddMultiSelectProps) {
27+
const [open, setOpen] = React.useState(false);
28+
const [searchValue, setSearchValue] = React.useState('');
29+
30+
const filteredOptions = options.filter((option) =>
31+
option.toLowerCase().includes(searchValue.toLowerCase())
32+
);
33+
34+
const isNewItem =
35+
searchValue.trim() !== '' &&
36+
!options.some(
37+
(opt) => opt.toLowerCase() === searchValue.trim().toLowerCase()
38+
);
39+
40+
const handleSelect = (item: string) => {
41+
if (selected.includes(item)) {
42+
onChange(selected.filter((s) => s !== item));
43+
} else {
44+
onChange([...selected, item]);
45+
}
46+
};
47+
48+
const handleCreateItem = () => {
49+
const newItem = searchValue.trim();
50+
if (newItem && !selected.includes(newItem)) {
51+
onChange([...selected, newItem]);
52+
setSearchValue('');
53+
}
54+
};
55+
56+
const handleRemoveItem = (item: string, e: React.MouseEvent) => {
57+
e.stopPropagation();
58+
onChange(selected.filter((s) => s !== item));
59+
};
60+
61+
const handleKeyDown = (e: React.KeyboardEvent) => {
62+
if (e.key === 'Enter' && isNewItem) {
63+
e.preventDefault();
64+
handleCreateItem();
65+
}
66+
};
67+
68+
return (
69+
<div className={cn('w-full')}>
70+
<Popover open={open} onOpenChange={setOpen}>
71+
<PopoverTrigger asChild>
72+
<Button
73+
variant="outline"
74+
role="combobox"
75+
aria-expanded={open}
76+
className="w-full justify-between h-auto min-h-[40px] hover:bg-transparent"
77+
>
78+
<div className="flex flex-wrap gap-1 items-center flex-1">
79+
{selected.length === 0 ? (
80+
<span className="text-muted-foreground">{placeholder}</span>
81+
) : (
82+
selected.map((item) => (
83+
<span
84+
key={item}
85+
className="px-2 py-0.5 rounded-md bg-muted text-sm flex items-center gap-1"
86+
>
87+
{item}
88+
<X
89+
className="w-3 h-3 cursor-pointer hover:text-red-500"
90+
onClick={(e) => handleRemoveItem(item, e)}
91+
/>
92+
</span>
93+
))
94+
)}
95+
</div>
96+
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
97+
</Button>
98+
</PopoverTrigger>
99+
100+
<PopoverContent
101+
className="w-full p-0"
102+
align="start"
103+
container={portalContainer}
104+
>
105+
<div className="p-2">
106+
<Input
107+
placeholder={placeholder}
108+
value={searchValue}
109+
onChange={(e) => setSearchValue(e.target.value)}
110+
onKeyDown={handleKeyDown}
111+
className="h-9"
112+
autoFocus
113+
/>
114+
</div>
115+
116+
<div className="max-h-60 overflow-y-auto">
117+
{isNewItem && (
118+
<div
119+
className="flex items-center px-3 py-2 cursor-pointer hover:bg-accent text-green-500"
120+
onClick={handleCreateItem}
121+
>
122+
<span className="mr-2">+</span>
123+
Create "{searchValue.trim()}"
124+
</div>
125+
)}
126+
127+
{filteredOptions.length === 0 && !isNewItem ? (
128+
<div className="px-3 py-2 text-muted-foreground text-sm">
129+
No results found.
130+
</div>
131+
) : (
132+
filteredOptions.map((option) => {
133+
const isSelected = selected.includes(option);
134+
return (
135+
<div
136+
key={option}
137+
className={cn(
138+
'flex items-center px-3 py-2 cursor-pointer hover:bg-accent',
139+
isSelected && 'bg-accent/50'
140+
)}
141+
onClick={() => handleSelect(option)}
142+
>
143+
<Check
144+
className={cn(
145+
'mr-2 h-4 w-4',
146+
isSelected ? 'opacity-100' : 'opacity-0'
147+
)}
148+
/>
149+
{option}
150+
</div>
151+
);
152+
})
153+
)}
154+
</div>
155+
</PopoverContent>
156+
</Popover>
157+
</div>
158+
);
159+
}

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

Lines changed: 16 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import React from 'react';
12
import { useState, useEffect } from 'react';
23
import { Badge } from '@/components/ui/badge';
34
import { Button } from '@/components/ui/button';
@@ -24,23 +25,24 @@ import {
2425
} from '@/components/ui/select';
2526
import { AddTaskDialogProps } from '@/components/utils/types';
2627
import { format } from 'date-fns';
28+
import { AddMultiSelect } from './AddMultiSelect';
2729

2830
export const AddTaskdialog = ({
2931
isOpen,
3032
setIsOpen,
3133
newTask,
3234
setNewTask,
33-
tagInput,
34-
setTagInput,
3535
onSubmit,
3636
isCreatingNewProject,
3737
setIsCreatingNewProject,
3838
uniqueProjects = [],
39+
uniqueTags = [],
3940
allTasks = [],
4041
}: AddTaskDialogProps) => {
4142
const [annotationInput, setAnnotationInput] = useState('');
4243
const [dependencySearch, setDependencySearch] = useState('');
4344
const [showDependencyResults, setShowDependencyResults] = useState(false);
45+
const dialogContainerRef = React.useRef<HTMLDivElement>(null);
4446

4547
const getFilteredTasks = () => {
4648
const availableTasks = allTasks.filter(
@@ -102,20 +104,6 @@ export const AddTaskdialog = ({
102104
});
103105
};
104106

105-
const handleAddTag = () => {
106-
if (tagInput && !newTask.tags.includes(tagInput, 0)) {
107-
setNewTask({ ...newTask, tags: [...newTask.tags, tagInput] });
108-
setTagInput('');
109-
}
110-
};
111-
112-
const handleRemoveTag = (tagToRemove: string) => {
113-
setNewTask({
114-
...newTask,
115-
tags: newTask.tags.filter((tag) => tag !== tagToRemove),
116-
});
117-
};
118-
119107
return (
120108
<Dialog open={isOpen} onOpenChange={setIsOpen}>
121109
<DialogTrigger asChild>
@@ -129,6 +117,7 @@ export const AddTaskdialog = ({
129117
</Button>
130118
</DialogTrigger>
131119
<DialogContent>
120+
<div ref={dialogContainerRef} />
132121
<DialogHeader>
133122
<DialogTitle>
134123
<span className="ml-0 mb-0 mr-0 text-2xl mt-0 md:text-2xl font-bold">
@@ -194,6 +183,7 @@ export const AddTaskdialog = ({
194183
</Label>
195184
<div className="col-span-3 space-y-2">
196185
<Select
186+
data-testid="project-select"
197187
value={
198188
isCreatingNewProject ? '__CREATE_NEW__' : newTask.project
199189
}
@@ -210,7 +200,7 @@ export const AddTaskdialog = ({
210200
}
211201
}}
212202
>
213-
<SelectTrigger id="project" data-testid="project-select">
203+
<SelectTrigger id="project">
214204
<SelectValue
215205
placeholder={
216206
uniqueProjects.length
@@ -376,45 +366,20 @@ export const AddTaskdialog = ({
376366
</select>
377367
</div>
378368
</div>
379-
<div className="grid grid-cols-8 items-center gap-4">
380-
<Label htmlFor="tags" className="text-right col-span-2">
369+
<div className="grid grid-cols-4 items-center gap-4">
370+
<Label htmlFor="tags" className="text-right">
381371
Tags
382372
</Label>
383-
<div className="col-span-6">
384-
<Input
385-
id="tags"
386-
name="tags"
387-
placeholder="Add a tag"
388-
value={tagInput}
389-
onChange={(e) => setTagInput(e.target.value)}
390-
onKeyDown={(e) => e.key === 'Enter' && handleAddTag()}
391-
required
392-
className="col-span-6"
373+
<div className="col-span-3 space-y-2">
374+
<AddMultiSelect
375+
options={uniqueTags}
376+
selected={newTask.tags}
377+
onChange={(tags) => setNewTask({ ...newTask, tags })}
378+
placeholder="Search or create tag.."
379+
portalContainer={dialogContainerRef.current}
393380
/>
394381
</div>
395382
</div>
396-
397-
<div className="mt-2">
398-
{newTask.tags.length > 0 && (
399-
<div className="grid grid-cols-4 items-center">
400-
<div> </div>
401-
<div className="flex flex-wrap gap-2 col-span-3">
402-
{newTask.tags.map((tag, index) => (
403-
<Badge key={index}>
404-
<span>{tag}</span>
405-
<button
406-
type="button"
407-
className="ml-2 text-red-500"
408-
onClick={() => handleRemoveTag(tag)}
409-
>
410-
411-
</button>
412-
</Badge>
413-
))}
414-
</div>
415-
</div>
416-
)}
417-
</div>
418383
<div className="grid grid-cols-8 items-center gap-4">
419384
<Label htmlFor="annotations" className="text-right col-span-2">
420385
Annotation

0 commit comments

Comments
 (0)