Skip to content

Commit 38ebafe

Browse files
committed
feat: implement KanbanBoard and KanbanColumn components with optimistic drag-and-drop updates
1 parent d97809f commit 38ebafe

2 files changed

Lines changed: 30 additions & 73 deletions

File tree

src/components/Kanban/KanbanBoard.tsx

Lines changed: 28 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -80,65 +80,17 @@ export function KanbanBoard({
8080
/* ── Search state ── */
8181
const [searchTerm, setSearchTerm] = useState("");
8282
const debouncedSearchTerm = useDebounce(searchTerm, 500);
83-
const [searchMatchedTaskIds, setSearchMatchedTaskIds] = useState<Set<number> | null>(null);
8483
const [isSearching, setIsSearching] = useState(false);
8584

85+
// We keep isSearching for UI feedback if needed,
86+
// though client-side search is nearly instantaneous.
8687
useEffect(() => {
87-
let active = true;
88-
89-
if (!debouncedSearchTerm.trim()) {
90-
setSearchMatchedTaskIds(null);
88+
if (searchTerm !== debouncedSearchTerm) {
89+
setIsSearching(true);
90+
} else {
9191
setIsSearching(false);
92-
return;
9392
}
94-
95-
const fetchSearchResults = async () => {
96-
setIsSearching(true);
97-
try {
98-
const supabase = createClient();
99-
const colIds = columns.map(c => c.id);
100-
if (colIds.length === 0) {
101-
if (active) {
102-
setSearchMatchedTaskIds(new Set());
103-
setIsSearching(false);
104-
}
105-
return;
106-
}
107-
108-
// 1. Escape characters used as wildcards in ilike: \ , % , _
109-
// 2. Escape double quotes for PostgREST: " -> ""
110-
const safeTerm = debouncedSearchTerm
111-
.replace(/\\/g, "\\\\") // Escape backslash first
112-
.replace(/%/g, "\\%")
113-
.replace(/_/g, "\\_")
114-
.replace(/"/g, '""');
115-
116-
// 3. Wrap in double quotes to handle reserved characters in .or() like , ( )
117-
const filterVal = `"%${safeTerm}%"`;
118-
119-
const { data, error } = await supabase
120-
.from("tasks")
121-
.select("id")
122-
.in("column_id", colIds)
123-
.or(`title.ilike.${filterVal},description.ilike.${filterVal}`);
124-
125-
if (!active) return;
126-
127-
if (!error && data) {
128-
setSearchMatchedTaskIds(new Set(data.map(t => t.id)));
129-
}
130-
} catch (e) {
131-
if (active) console.error(e);
132-
} finally {
133-
if (active) setIsSearching(false);
134-
}
135-
};
136-
137-
fetchSearchResults();
138-
return () => {
139-
active = false;
140-
};
141-
}, [debouncedSearchTerm, columns]);
93+
}, [searchTerm, debouncedSearchTerm]);
14294

14395
const lastSyncedColumnsRef = useRef(columns);
14496
const lastSyncedTasksRef = useRef(tasks);
@@ -530,16 +482,27 @@ export function KanbanBoard({
530482
return map;
531483
}, [boardLabels]);
532484

533-
// Filter tasks by assignee and labels (client-side) using useMemo for performance
485+
// Filter tasks by assignee, labels, and search term (client-side) using useMemo for performance
534486
const { effectiveLabelIds, filteredTasks } = React.useMemo(() => {
535487
let tasks = localTasks;
536488

489+
// 1. Filter by Search Term
490+
if (debouncedSearchTerm.trim()) {
491+
const term = debouncedSearchTerm.toLowerCase();
492+
tasks = tasks.filter((t) =>
493+
t.title.toLowerCase().includes(term) ||
494+
(t.description && t.description.toLowerCase().includes(term))
495+
);
496+
}
497+
498+
// 2. Filter by Assignee
537499
if (filterUserId) {
538500
tasks = tasks.filter((t) =>
539501
t.assignees?.some((a) => a.user_id === filterUserId),
540502
);
541503
}
542504

505+
// 3. Filter by Labels
543506
let effectiveIds = filterLabelIds || [];
544507
if (filterLabelIds && filterLabelIds.length > 0) {
545508
const selectedColors = new Set(
@@ -563,7 +526,7 @@ export function KanbanBoard({
563526
}
564527

565528
return { effectiveLabelIds: effectiveIds, filteredTasks: tasks };
566-
}, [localTasks, filterUserId, filterLabelIds, boardLabels]);
529+
}, [localTasks, filterUserId, filterLabelIds, boardLabels, debouncedSearchTerm]);
567530

568531
// Don't render on server to avoid hydration mismatch
569532
if (!isMounted) {
@@ -591,7 +554,7 @@ export function KanbanBoard({
591554
}
592555

593556
const isFiltering =
594-
!!filterUserId || effectiveLabelIds.length > 0 || searchMatchedTaskIds !== null;
557+
!!filterUserId || effectiveLabelIds.length > 0 || !!debouncedSearchTerm.trim();
595558

596559
return (
597560
<div className="flex flex-col h-full">
@@ -608,11 +571,10 @@ export function KanbanBoard({
608571
filterUserId === currentUserId ? null : currentUserId,
609572
)
610573
}
611-
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-bold border-2 transition-all ${
612-
filterUserId === currentUserId
574+
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-bold border-2 transition-all ${filterUserId === currentUserId
613575
? "bg-[#28B8FA] border-[#28B8FA] text-white shadow-md shadow-cyan-200"
614576
: "bg-white border-slate-200 text-slate-500 hover:border-[#28B8FA] hover:text-[#28B8FA]"
615-
}`}
577+
}`}
616578
title="Chỉ hiện nhiệm vụ của tôi"
617579
>
618580
<svg
@@ -644,11 +606,10 @@ export function KanbanBoard({
644606
onFilterChange?.(isActive ? null : member.user_id)
645607
}
646608
title={`Lọc theo: ${member.display_name}`}
647-
className={`relative rounded-full transition-all focus:outline-none ${
648-
isActive
609+
className={`relative rounded-full transition-all focus:outline-none ${isActive
649610
? "ring-3 ring-[#28B8FA] ring-offset-2 scale-110"
650611
: "ring-2 ring-transparent hover:ring-[#28B8FA]/40 hover:ring-offset-1 hover:scale-105"
651-
}`}
612+
}`}
652613
>
653614
<UserAvatar
654615
avatarUrl={member.avatar_url}
@@ -692,11 +653,10 @@ export function KanbanBoard({
692653
<div className="relative" ref={labelFilterPopoverRef}>
693654
<button
694655
onClick={() => setShowLabelFilterPopover((v) => !v)}
695-
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-bold border-2 transition-all ${
696-
filterLabelIds.length > 0 || showLabelFilterPopover
656+
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-bold border-2 transition-all ${filterLabelIds.length > 0 || showLabelFilterPopover
697657
? "bg-[#28B8FA] border-[#28B8FA] text-white shadow-md shadow-cyan-200"
698658
: "bg-white border-slate-200 text-slate-500 hover:border-[#28B8FA] hover:text-[#28B8FA]"
699-
}`}
659+
}`}
700660
title="Lọc theo nhãn"
701661
>
702662
<svg
@@ -735,8 +695,8 @@ export function KanbanBoard({
735695
// Find all IDs with this same color
736696
const relatedIds = label.color_hex
737697
? colorToLabelIdsMap.get(label.color_hex) || [
738-
label.id,
739-
]
698+
label.id,
699+
]
740700
: [label.id];
741701

742702
if (isActive) {
@@ -898,7 +858,6 @@ export function KanbanBoard({
898858
column={col}
899859
colIndex={index}
900860
tasks={columnTasks}
901-
searchMatchedTaskIds={searchMatchedTaskIds}
902861
onTaskClick={onTaskClick}
903862
onAddTask={onAddTask}
904863
onUpdateColumn={onUpdateColumn}

src/components/Kanban/KanbanColumn.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -226,9 +226,7 @@ export function KanbanColumn({
226226
droppableProvided: DroppableProvided,
227227
droppableSnapshot: DroppableStateSnapshot,
228228
) => {
229-
const visibleTasks = searchMatchedTaskIds
230-
? tasks.filter(task => searchMatchedTaskIds.has(task.id))
231-
: tasks;
229+
const visibleTasks = tasks;
232230

233231
return (
234232
<div
@@ -241,7 +239,7 @@ export function KanbanColumn({
241239
>
242240
{visibleTasks.length === 0 && !droppableSnapshot.isDraggingOver && (
243241
<div className="bg-white p-5 rounded-2xl border border-dashed border-slate-200 text-sm text-slate-400">
244-
{searchMatchedTaskIds ? "Không có kết quả" : "Chưa có nhiệm vụ"}
242+
Chưa có nhiệm vụ
245243
</div>
246244
)}
247245
{visibleTasks.map((task, taskIndex) => (

0 commit comments

Comments
 (0)