Skip to content

Commit 46cd01d

Browse files
committed
feat(tags): implement multi-select tag component for Add/Edit Task dialogs
- Create SearchAndAddSelector component with search, select, and create functionality - Use local Popover (no Portal) to fix click-through issue in Dialog without modifying global popover.tsx - 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 SearchAndAddSelector 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 SearchAndAddSelector component - Update AddTaskDialog tests for new tag selection flow - Update TaskDialog tests for tag editing with SearchAndAddSelector - Mock SearchAndAddSelector in tests Fixes: #210
1 parent bf7b859 commit 46cd01d

11 files changed

Lines changed: 775 additions & 345 deletions

File tree

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

Lines changed: 12 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,18 @@ import {
2424
} from '@/components/ui/select';
2525
import { AddTaskDialogProps } from '@/components/utils/types';
2626
import { format } from 'date-fns';
27+
import { SearchAndAddSelector } from './SearchAndAddSelector';
2728

2829
export const AddTaskdialog = ({
2930
isOpen,
3031
setIsOpen,
3132
newTask,
3233
setNewTask,
33-
tagInput,
34-
setTagInput,
3534
onSubmit,
3635
isCreatingNewProject,
3736
setIsCreatingNewProject,
3837
uniqueProjects = [],
38+
uniqueTags = [],
3939
allTasks = [],
4040
}: AddTaskDialogProps) => {
4141
const [annotationInput, setAnnotationInput] = useState('');
@@ -102,20 +102,6 @@ export const AddTaskdialog = ({
102102
});
103103
};
104104

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-
119105
return (
120106
<Dialog open={isOpen} onOpenChange={setIsOpen}>
121107
<DialogTrigger asChild>
@@ -194,6 +180,7 @@ export const AddTaskdialog = ({
194180
</Label>
195181
<div className="col-span-3 space-y-2">
196182
<Select
183+
data-testid="project-select"
197184
value={
198185
isCreatingNewProject ? '__CREATE_NEW__' : newTask.project
199186
}
@@ -210,7 +197,7 @@ export const AddTaskdialog = ({
210197
}
211198
}}
212199
>
213-
<SelectTrigger id="project" data-testid="project-select">
200+
<SelectTrigger id="project">
214201
<SelectValue
215202
placeholder={
216203
uniqueProjects.length
@@ -376,45 +363,19 @@ export const AddTaskdialog = ({
376363
</select>
377364
</div>
378365
</div>
379-
<div className="grid grid-cols-8 items-center gap-4">
380-
<Label htmlFor="tags" className="text-right col-span-2">
366+
<div className="grid grid-cols-4 items-center gap-4">
367+
<Label htmlFor="tags" className="text-right">
381368
Tags
382369
</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"
370+
<div className="col-span-3 space-y-2">
371+
<SearchAndAddSelector
372+
options={uniqueTags}
373+
selected={newTask.tags}
374+
onChange={(tags) => setNewTask({ ...newTask, tags })}
375+
placeholder="Search or create tag.."
393376
/>
394377
</div>
395378
</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>
418379
<div className="grid grid-cols-8 items-center gap-4">
419380
<Label htmlFor="annotations" className="text-right col-span-2">
420381
Annotation
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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 * as PopoverPrimitive from '@radix-ui/react-popover';
6+
import { Button } from '@/components/ui/button';
7+
8+
const LocalPopoverContent = React.forwardRef<
9+
React.ElementRef<typeof PopoverPrimitive.Content>,
10+
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
11+
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
12+
<PopoverPrimitive.Content
13+
ref={ref}
14+
align={align}
15+
sideOffset={sideOffset}
16+
className={cn(
17+
'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]',
18+
className
19+
)}
20+
{...props}
21+
/>
22+
));
23+
LocalPopoverContent.displayName = 'LocalPopoverContent';
24+
25+
interface SearchAndAddSelectorProps {
26+
options: string[];
27+
selected: string[];
28+
onChange: (items: string[]) => void;
29+
placeholder?: string;
30+
}
31+
32+
export function SearchAndAddSelector({
33+
options,
34+
selected,
35+
onChange,
36+
placeholder = 'Search or create..',
37+
}: SearchAndAddSelectorProps) {
38+
const [open, setOpen] = React.useState(false);
39+
const [searchValue, setSearchValue] = React.useState('');
40+
41+
const filteredOptions = options.filter((option) =>
42+
option.toLowerCase().includes(searchValue.toLowerCase())
43+
);
44+
45+
const isNewItem =
46+
searchValue.trim() !== '' &&
47+
!options.some(
48+
(opt) => opt.toLowerCase() === searchValue.trim().toLowerCase()
49+
);
50+
51+
const handleSelect = (item: string) => {
52+
if (selected.includes(item)) {
53+
onChange(selected.filter((s) => s !== item));
54+
} else {
55+
onChange([...selected, item]);
56+
}
57+
};
58+
59+
const handleCreateItem = () => {
60+
const newItem = searchValue.trim();
61+
if (newItem && !selected.includes(newItem)) {
62+
onChange([...selected, newItem]);
63+
setSearchValue('');
64+
}
65+
};
66+
67+
const handleRemoveItem = (item: string, e: React.MouseEvent) => {
68+
e.stopPropagation();
69+
onChange(selected.filter((s) => s !== item));
70+
};
71+
72+
const handleKeyDown = (e: React.KeyboardEvent) => {
73+
if (e.key === 'Enter' && isNewItem) {
74+
e.preventDefault();
75+
handleCreateItem();
76+
}
77+
};
78+
79+
return (
80+
<div className={cn('w-full')}>
81+
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
82+
<PopoverPrimitive.Trigger asChild>
83+
<Button
84+
variant="outline"
85+
role="combobox"
86+
aria-expanded={open}
87+
className="w-full justify-between h-auto min-h-[40px] hover:bg-transparent"
88+
>
89+
<div className="flex flex-wrap gap-1 items-center flex-1">
90+
{selected.length === 0 ? (
91+
<span className="text-muted-foreground">{placeholder}</span>
92+
) : (
93+
selected.map((item) => (
94+
<span
95+
key={item}
96+
className="px-2 py-0.5 rounded-md bg-muted text-sm flex items-center gap-1"
97+
>
98+
{item}
99+
<X
100+
className="w-3 h-3 cursor-pointer hover:text-red-500"
101+
onClick={(e) => handleRemoveItem(item, e)}
102+
/>
103+
</span>
104+
))
105+
)}
106+
</div>
107+
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
108+
</Button>
109+
</PopoverPrimitive.Trigger>
110+
111+
<LocalPopoverContent className="w-full p-0" align="start">
112+
<div className="p-2">
113+
<Input
114+
placeholder={placeholder}
115+
value={searchValue}
116+
onChange={(e) => setSearchValue(e.target.value)}
117+
onKeyDown={handleKeyDown}
118+
className="h-9"
119+
autoFocus
120+
/>
121+
</div>
122+
123+
<div className="max-h-60 overflow-y-auto">
124+
{isNewItem && (
125+
<div
126+
className="flex items-center px-3 py-2 cursor-pointer hover:bg-accent text-green-500"
127+
onClick={handleCreateItem}
128+
>
129+
<span className="mr-2">+</span>
130+
Create "{searchValue.trim()}"
131+
</div>
132+
)}
133+
134+
{filteredOptions.length === 0 && !isNewItem ? (
135+
<div className="px-3 py-2 text-muted-foreground text-sm">
136+
No results found.
137+
</div>
138+
) : (
139+
filteredOptions.map((option) => {
140+
const isSelected = selected.includes(option);
141+
return (
142+
<div
143+
key={option}
144+
className={cn(
145+
'flex items-center px-3 py-2 cursor-pointer hover:bg-accent',
146+
isSelected && 'bg-accent/50'
147+
)}
148+
onClick={() => handleSelect(option)}
149+
>
150+
<Check
151+
className={cn(
152+
'mr-2 h-4 w-4',
153+
isSelected ? 'opacity-100' : 'opacity-0'
154+
)}
155+
/>
156+
{option}
157+
</div>
158+
);
159+
})
160+
)}
161+
</div>
162+
</LocalPopoverContent>
163+
</PopoverPrimitive.Root>
164+
</div>
165+
);
166+
}

0 commit comments

Comments
 (0)