Skip to content

Commit 4219262

Browse files
committed
test: add comprehensive tests for TagMultiSelect component
1 parent e35f306 commit 4219262

File tree

10 files changed

+908
-208
lines changed

10 files changed

+908
-208
lines changed

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ import { format } from 'date-fns';
3131
import { ADDTASKDIALOG_FIELDS } from './constants';
3232
import { useAddTaskDialogKeyboard } from './UseTaskDialogKeyboard';
3333
import { useAddTaskDialogFocusMap } from './UseTaskDialogFocusMap';
34-
import { TagMultiSelect } from './TagMultiSelect';
34+
import { MultiSelect } from './MultiSelect';
35+
3536

3637
export const AddTaskdialog = ({
3738
onOpenChange,
@@ -513,10 +514,10 @@ export const AddTaskdialog = ({
513514
Tags
514515
</Label>
515516
<div className="col-span-3">
516-
<TagMultiSelect
517-
availableTags={uniqueTags}
518-
selectedTags={newTask.tags}
519-
onTagsChange={(tags: string[]) => setNewTask({ ...newTask, tags })}
517+
<MultiSelect
518+
availableItems={uniqueTags}
519+
selectedItems={newTask.tags}
520+
onItemsChange={(tags: string[]) => setNewTask({ ...newTask, tags })}
520521
placeholder="Select or create tags"
521522
/>
522523
</div>

frontend/src/components/HomeComponents/Tasks/TagMultiSelect.tsx renamed to frontend/src/components/HomeComponents/Tasks/MultiSelect.tsx

Lines changed: 93 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,21 @@ import { useState, useRef, useEffect } from 'react';
22
import { Badge } from '@/components/ui/badge';
33
import { Button } from '@/components/ui/button';
44
import { Input } from '@/components/ui/input';
5-
import { TagMultiSelectProps } from '@/components/utils/types';
6-
import { ChevronDown, Plus } from 'lucide-react';
5+
import { MultiSelectProps } from '@/components/utils/types';
6+
import { ChevronDown, Plus, Check, X } from 'lucide-react';
7+
import { getFilteredItems, shouldShowCreateOption } from './multi-select-utils';
78

8-
export const TagMultiSelect = ({
9-
availableTags,
10-
selectedTags,
11-
onTagsChange,
12-
placeholder = 'Select or create tags',
9+
export const MultiSelect = ({
10+
availableItems,
11+
selectedItems,
12+
onItemsChange,
13+
placeholder = 'Select or create items',
1314
disabled = false,
1415
className = '',
15-
}: TagMultiSelectProps) => {
16+
showActions = false,
17+
onSave,
18+
onCancel,
19+
}: MultiSelectProps) => {
1620
const [isOpen, setIsOpen] = useState(false);
1721
const [searchTerm, setSearchTerm] = useState('');
1822
const dropdownRef = useRef<HTMLDivElement>(null);
@@ -33,56 +37,56 @@ export const TagMultiSelect = ({
3337
return () => document.removeEventListener('mousedown', handleClickOutside);
3438
}, []);
3539

36-
const getFilteredTags = () => {
37-
return availableTags.filter(
38-
(tag) =>
39-
tag.toLowerCase().includes(searchTerm.toLowerCase()) &&
40-
!selectedTags.includes(tag)
41-
);
42-
};
40+
const filteredItems = getFilteredItems(
41+
availableItems,
42+
selectedItems,
43+
searchTerm
44+
);
4345

44-
const handleTagSelect = (tag: string) => {
45-
if (!selectedTags.includes(tag)) {
46-
onTagsChange([...selectedTags, tag]);
46+
const handleItemSelect = (item: string) => {
47+
if (!selectedItems.includes(item)) {
48+
onItemsChange([...selectedItems, item]);
4749
}
4850
setSearchTerm('');
4951
};
5052

51-
const handleTagRemove = (tagToRemove: string) => {
52-
onTagsChange(selectedTags.filter((tag) => tag !== tagToRemove));
53+
const handleItemRemove = (itemToRemove: string) => {
54+
onItemsChange(selectedItems.filter((item) => item !== itemToRemove));
5355
};
5456

55-
const handleNewTagCreate = () => {
57+
const handleNewItemCreate = () => {
5658
const trimmedTerm = searchTerm.trim();
5759
if (
5860
trimmedTerm &&
59-
!selectedTags.includes(trimmedTerm) &&
60-
!availableTags.includes(trimmedTerm)
61+
!selectedItems.includes(trimmedTerm) &&
62+
!availableItems.includes(trimmedTerm)
6163
) {
62-
onTagsChange([...selectedTags, trimmedTerm]);
64+
onItemsChange([...selectedItems, trimmedTerm]);
6365
setSearchTerm('');
6466
}
6567
};
6668

6769
const handleKeyDown = (e: React.KeyboardEvent) => {
6870
if (e.key === 'Enter') {
6971
e.preventDefault();
70-
const filteredTags = getFilteredTags();
71-
if (filteredTags.length > 0) {
72-
handleTagSelect(filteredTags[0]);
73-
} else if (searchTerm.trim()) {
74-
handleNewTagCreate();
72+
if (searchTerm.trim()) {
73+
if (filteredItems.length > 0) {
74+
handleItemSelect(filteredItems[0]);
75+
} else {
76+
handleNewItemCreate();
77+
}
7578
}
7679
} else if (e.key === 'Escape') {
7780
setIsOpen(false);
7881
setSearchTerm('');
7982
}
8083
};
8184

82-
const showCreateOption =
83-
searchTerm.trim() &&
84-
!availableTags.includes(searchTerm.trim()) &&
85-
!selectedTags.includes(searchTerm.trim());
85+
const showCreate = shouldShowCreateOption(
86+
searchTerm,
87+
availableItems,
88+
selectedItems
89+
);
8690

8791
return (
8892
<div className={`relative ${className}`} ref={dropdownRef}>
@@ -92,15 +96,59 @@ export const TagMultiSelect = ({
9296
onClick={() => setIsOpen(!isOpen)}
9397
disabled={disabled}
9498
className="w-full justify-between text-left font-normal"
99+
aria-label="Select items"
95100
>
96101
<span className="truncate">
97-
{selectedTags.length > 0
98-
? `${selectedTags.length} tag${selectedTags.length > 1 ? 's' : ''} selected`
102+
{selectedItems.length > 0
103+
? `${selectedItems.length} item${selectedItems.length > 1 ? 's' : ''} selected`
99104
: placeholder}
100105
</span>
101106
<ChevronDown className="h-4 w-4 opacity-50" />
102107
</Button>
103108

109+
{selectedItems.length > 0 && (
110+
<div className="flex flex-wrap items-center gap-2 mt-2">
111+
{selectedItems.map((item) => (
112+
<Badge key={item} className="flex items-center gap-1">
113+
<span>{item}</span>
114+
<button
115+
type="button"
116+
onClick={() => handleItemRemove(item)}
117+
className="ml-1 text-red-500 hover:text-red-700 text-xs"
118+
disabled={disabled}
119+
aria-label={`Remove ${item}`}
120+
>
121+
122+
</button>
123+
</Badge>
124+
))}
125+
{showActions && onSave && onCancel && (
126+
<div className="flex items-center gap-1 whitespace-nowrap">
127+
<Button
128+
type="button"
129+
variant="ghost"
130+
size="icon"
131+
onClick={onSave}
132+
className="h-8 w-8"
133+
aria-label="Save items"
134+
>
135+
<Check className="h-4 w-4 text-green-500" />
136+
</Button>
137+
<Button
138+
type="button"
139+
variant="ghost"
140+
size="icon"
141+
onClick={onCancel}
142+
className="h-8 w-8"
143+
aria-label="Cancel"
144+
>
145+
<X className="h-4 w-4 text-red-500" />
146+
</Button>
147+
</div>
148+
)}
149+
</div>
150+
)}
151+
104152
{isOpen && (
105153
<div className="absolute z-50 w-full mt-1 bg-background border border-border rounded-md shadow-lg max-h-60 overflow-hidden">
106154
<div className="p-2 border-b">
@@ -109,33 +157,27 @@ export const TagMultiSelect = ({
109157
value={searchTerm}
110158
onChange={(e) => setSearchTerm(e.target.value)}
111159
onKeyDown={handleKeyDown}
112-
placeholder="Search or create tags..."
160+
placeholder="Search or create..."
113161
className="h-8"
114162
autoFocus
115163
/>
116164
</div>
117165

118166
<div className="max-h-40 overflow-y-auto">
119-
{getFilteredTags().map((tag) => (
167+
{filteredItems.map((item) => (
120168
<div
121-
key={tag}
122-
className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-accent transition-colors"
123-
onClick={() => handleTagSelect(tag)}
169+
key={item}
170+
className="px-3 py-2 cursor-pointer hover:bg-accent transition-colors"
171+
onClick={() => handleItemSelect(item)}
124172
>
125-
<input
126-
type="checkbox"
127-
checked={false}
128-
readOnly
129-
className="h-4 w-4"
130-
/>
131-
<span className="flex-1 text-sm">{tag}</span>
173+
<span className="text-sm">{item}</span>
132174
</div>
133175
))}
134176

135-
{showCreateOption && (
177+
{showCreate && (
136178
<div
137179
className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-accent transition-colors border-t"
138-
onClick={handleNewTagCreate}
180+
onClick={handleNewItemCreate}
139181
>
140182
<Plus className="h-4 w-4 text-muted-foreground" />
141183
<span className="flex-1 text-sm">
@@ -144,36 +186,14 @@ export const TagMultiSelect = ({
144186
</div>
145187
)}
146188

147-
{getFilteredTags().length === 0 && !showCreateOption && (
189+
{filteredItems.length === 0 && !showCreate && (
148190
<div className="px-3 py-2 text-sm text-muted-foreground text-center">
149-
No tags found
191+
No items found
150192
</div>
151193
)}
152194
</div>
153195
</div>
154196
)}
155-
156-
{selectedTags.length > 0 && (
157-
<div className="flex flex-wrap gap-2 mt-2">
158-
{selectedTags.map((tag) => (
159-
<Badge
160-
key={tag}
161-
variant="secondary"
162-
className="flex items-center gap-1"
163-
>
164-
<span>{tag}</span>
165-
<button
166-
type="button"
167-
onClick={() => handleTagRemove(tag)}
168-
className="ml-1 text-red-500 hover:text-red-700 text-xs"
169-
disabled={disabled}
170-
>
171-
172-
</button>
173-
</Badge>
174-
))}
175-
</div>
176-
)}
177197
</div>
178198
);
179199
};

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

Lines changed: 20 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import { useEffect, useRef, useState } from 'react';
4040
import { useTaskDialogKeyboard } from './UseTaskDialogKeyboard';
4141
import { EDITTASKDIALOG_FIELDS } from './constants';
4242
import { useTaskDialogFocusMap } from './UseTaskDialogFocusMap';
43-
import { TagMultiSelect } from './TagMultiSelect';
43+
import { MultiSelect } from './MultiSelect';
4444

4545
export const TaskDialog = ({
4646
index,
@@ -1215,42 +1215,25 @@ export const TaskDialog = ({
12151215
<TableCell>Tags:</TableCell>
12161216
<TableCell>
12171217
{editState.isEditingTags ? (
1218-
<div className="space-y-2">
1219-
<TagMultiSelect
1220-
availableTags={uniqueTags}
1221-
selectedTags={editState.editedTags}
1222-
onTagsChange={(tags) =>
1223-
onUpdateState({ editedTags: tags })
1224-
}
1225-
placeholder="Select or create tags"
1226-
/>
1227-
<div className="flex items-center gap-2 whitespace-nowrap">
1228-
<Button
1229-
variant="ghost"
1230-
size="icon"
1231-
aria-label="save"
1232-
onClick={() => {
1233-
onSaveTags(task, editState.editedTags);
1234-
onUpdateState({ isEditingTags: false });
1235-
}}
1236-
>
1237-
<CheckIcon className="h-4 w-4 text-green-500" />
1238-
</Button>
1239-
<Button
1240-
variant="ghost"
1241-
size="icon"
1242-
aria-label="cancel"
1243-
onClick={() =>
1244-
onUpdateState({
1245-
isEditingTags: false,
1246-
editedTags: task.tags || [],
1247-
})
1248-
}
1249-
>
1250-
<XIcon className="h-4 w-4 text-red-500" />
1251-
</Button>
1252-
</div>
1253-
</div>
1218+
<MultiSelect
1219+
availableItems={uniqueTags}
1220+
selectedItems={editState.editedTags}
1221+
onItemsChange={(tags) =>
1222+
onUpdateState({ editedTags: tags })
1223+
}
1224+
placeholder="Select or create tags"
1225+
showActions={true}
1226+
onSave={() => {
1227+
onSaveTags(task, editState.editedTags);
1228+
onUpdateState({ isEditingTags: false });
1229+
}}
1230+
onCancel={() =>
1231+
onUpdateState({
1232+
isEditingTags: false,
1233+
editedTags: task.tags || [],
1234+
})
1235+
}
1236+
/>
12541237
) : (
12551238
<div className="flex items-center flex-wrap">
12561239
{task.tags !== null && task.tags.length >= 1 ? (

0 commit comments

Comments
 (0)