perf: memoize KanbanBoard, KanbanGridLayout, MemberCard, TaskRow, SidebarTaskItem

Wrap five hot-path components in React.memo to prevent unnecessary
re-renders when parent state changes don't affect their props.
This commit is contained in:
Mike 2026-05-02 21:20:25 +05:00
parent 2bda324e1a
commit 8b30930c04
5 changed files with 1263 additions and 1238 deletions

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
@ -69,218 +69,220 @@ interface SidebarTaskItemProps {
getDisplaySubject?: (task: GlobalTask) => string | undefined; getDisplaySubject?: (task: GlobalTask) => string | undefined;
} }
export const SidebarTaskItem = ({ export const SidebarTaskItem = memo(
task, ({
hideTeamName, task,
showTeamName, hideTeamName,
renamingKey, showTeamName,
onRenameComplete, renamingKey,
onRenameCancel, onRenameComplete,
getDisplaySubject, onRenameCancel,
}: SidebarTaskItemProps): React.JSX.Element => { getDisplaySubject,
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail); }: SidebarTaskItemProps): React.JSX.Element => {
const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members)); const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments); const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members));
const { isLight } = useTheme(); const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
const { isLight } = useTheme();
const isRenaming = renamingKey === `${task.teamName}:${task.id}`; const isRenaming = renamingKey === `${task.teamName}:${task.id}`;
const displaySubject = getDisplaySubject?.(task) ?? task.subject; const displaySubject = getDisplaySubject?.(task) ?? task.subject;
const [editValue, setEditValue] = useState(displaySubject); const [editValue, setEditValue] = useState(displaySubject);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// Focus input when rename starts // Focus input when rename starts
useEffect(() => { useEffect(() => {
if (!isRenaming) return; if (!isRenaming) return;
const raf = requestAnimationFrame(() => { const raf = requestAnimationFrame(() => {
inputRef.current?.focus(); inputRef.current?.focus();
inputRef.current?.select(); inputRef.current?.select();
}); });
return () => cancelAnimationFrame(raf); return () => cancelAnimationFrame(raf);
}, [isRenaming]); }, [isRenaming]);
// Reset edit value when renaming starts // Reset edit value when renaming starts
useEffect(() => { useEffect(() => {
if (isRenaming) { if (isRenaming) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
setEditValue(displaySubject); setEditValue(displaySubject);
} }
}, [isRenaming, displaySubject]); }, [isRenaming, displaySubject]);
const reviewColumn = getTaskKanbanColumn(task); const reviewColumn = getTaskKanbanColumn(task);
const cfg = const cfg =
reviewColumn === 'approved' reviewColumn === 'approved'
? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const) ? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const)
: reviewColumn === 'review' : reviewColumn === 'review'
? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const) ? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const)
: (statusConfig[task.status] ?? statusConfig.pending); : (statusConfig[task.status] ?? statusConfig.pending);
const StatusIcon = cfg.icon; const StatusIcon = cfg.icon;
const updatedLabel = formatUpdatedLabel(task); const updatedLabel = formatUpdatedLabel(task);
const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt); const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt);
const ownerColorSet = useMemo(() => { const ownerColorSet = useMemo(() => {
if (!teamMembers || !task.owner) return null; if (!teamMembers || !task.owner) return null;
const colorMap = buildMemberColorMap(teamMembers); const colorMap = buildMemberColorMap(teamMembers);
const colorName = colorMap.get(task.owner); const colorName = colorMap.get(task.owner);
return colorName ? getTeamColorSet(colorName) : null; return colorName ? getTeamColorSet(colorName) : null;
}, [teamMembers, task.owner]); }, [teamMembers, task.owner]);
const ownerTextColor = useMemo(() => { const ownerTextColor = useMemo(() => {
if (!ownerColorSet) return undefined; if (!ownerColorSet) return undefined;
return isLight && ownerColorSet.textLight ? ownerColorSet.textLight : ownerColorSet.text; return isLight && ownerColorSet.textLight ? ownerColorSet.textLight : ownerColorSet.text;
}, [ownerColorSet, isLight]); }, [ownerColorSet, isLight]);
const projectLabel = useMemo(() => { const projectLabel = useMemo(() => {
if (!task.projectPath?.trim()) return null; if (!task.projectPath?.trim()) return null;
return projectLabelFromPath(task.projectPath); return projectLabelFromPath(task.projectPath);
}, [task.projectPath]); }, [task.projectPath]);
const projectColorSet = useMemo( const projectColorSet = useMemo(
() => (projectLabel ? projectColor(projectLabel, isLight) : null), () => (projectLabel ? projectColor(projectLabel, isLight) : null),
[projectLabel, isLight] [projectLabel, isLight]
); );
const teamColor = useMemo( const teamColor = useMemo(
() => (showTeamName ? nameColorSet(task.teamDisplayName, isLight) : null), () => (showTeamName ? nameColorSet(task.teamDisplayName, isLight) : null),
[showTeamName, task.teamDisplayName, isLight] [showTeamName, task.teamDisplayName, isLight]
); );
const showTeamRow = showTeamName && !hideTeamName; const showTeamRow = showTeamName && !hideTeamName;
const unreadBackgroundClass = const unreadBackgroundClass =
unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : ''; unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : '';
return ( return (
<button <button
type="button" type="button"
className={`flex w-full cursor-pointer flex-col justify-center border-b px-2 py-1.5 text-left transition-colors hover:bg-surface-raised ${unreadBackgroundClass} ${task.teamDeleted ? 'opacity-50' : ''}`} className={`flex w-full cursor-pointer flex-col justify-center border-b px-2 py-1.5 text-left transition-colors hover:bg-surface-raised ${unreadBackgroundClass} ${task.teamDeleted ? 'opacity-50' : ''}`}
style={{ borderColor: 'var(--color-border)' }} style={{ borderColor: 'var(--color-border)' }}
onClick={() => { onClick={() => {
if (!isRenaming) { if (!isRenaming) {
openGlobalTaskDetail(task.teamName, task.id); openGlobalTaskDetail(task.teamName, task.id);
} }
}} }}
> >
{/* Row 1: status + subject */} {/* Row 1: status + subject */}
<div className="w-full overflow-hidden"> <div className="w-full overflow-hidden">
{isRenaming ? ( {isRenaming ? (
<div className="flex items-start gap-1.5"> <div className="flex items-start gap-1.5">
<StatusIcon className={`mt-0.5 size-3 shrink-0 ${cfg.color}`} /> <StatusIcon className={`mt-0.5 size-3 shrink-0 ${cfg.color}`} />
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
value={editValue} value={editValue}
onChange={(e) => setEditValue(e.target.value)} onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
const trimmed = editValue.trim();
if (trimmed && trimmed !== task.subject) {
onRenameComplete?.(task.teamName, task.id, trimmed);
} else {
onRenameCancel?.();
}
} else if (e.key === 'Escape') {
e.preventDefault();
onRenameCancel?.();
}
}}
onBlur={() => {
const trimmed = editValue.trim(); const trimmed = editValue.trim();
if (trimmed && trimmed !== task.subject) { if (trimmed && trimmed !== task.subject) {
onRenameComplete?.(task.teamName, task.id, trimmed); onRenameComplete?.(task.teamName, task.id, trimmed);
} else { } else {
onRenameCancel?.(); onRenameCancel?.();
} }
} else if (e.key === 'Escape') { }}
e.preventDefault(); className="min-w-0 flex-1 border-none bg-transparent p-0 text-[13px] font-medium leading-tight focus:outline-none"
onRenameCancel?.();
}
}}
onBlur={() => {
const trimmed = editValue.trim();
if (trimmed && trimmed !== task.subject) {
onRenameComplete?.(task.teamName, task.id, trimmed);
} else {
onRenameCancel?.();
}
}}
className="min-w-0 flex-1 border-none bg-transparent p-0 text-[13px] font-medium leading-tight focus:outline-none"
style={{ color: 'var(--color-text-muted)' }}
onClick={(e) => e.stopPropagation()}
/>
</div>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span
className="line-clamp-2 text-[13px] font-medium leading-tight"
style={{ color: 'var(--color-text-muted)' }} style={{ color: 'var(--color-text-muted)' }}
> onClick={(e) => e.stopPropagation()}
<StatusIcon className={`mr-1.5 inline-block size-3 align-[-1px] ${cfg.color}`} /> />
{unreadCount > 0 && </div>
(unreadCount === 1 ? ( ) : (
<span className="mr-1 inline-block size-1.5 rounded-full bg-blue-400 align-middle" /> <Tooltip>
) : ( <TooltipTrigger asChild>
<span className="mr-1 inline-flex size-3.5 items-center justify-center rounded-full bg-blue-500 align-middle text-[8px] font-bold leading-none text-white"> <span
{unreadCount > 9 ? '9+' : unreadCount} className="line-clamp-2 text-[13px] font-medium leading-tight"
style={{ color: 'var(--color-text-muted)' }}
>
<StatusIcon className={`mr-1.5 inline-block size-3 align-[-1px] ${cfg.color}`} />
{unreadCount > 0 &&
(unreadCount === 1 ? (
<span className="mr-1 inline-block size-1.5 rounded-full bg-blue-400 align-middle" />
) : (
<span className="mr-1 inline-flex size-3.5 items-center justify-center rounded-full bg-blue-500 align-middle text-[8px] font-bold leading-none text-white">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
))}
{displaySubject}
{task.reviewState === 'needsFix' && (
<span
className={`ml-1.5 inline-block rounded-full px-1.5 py-0.5 align-middle text-[10px] font-medium leading-none ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
>
{REVIEW_STATE_DISPLAY.needsFix.label}
</span> </span>
))} )}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{displaySubject} {displaySubject}
{task.reviewState === 'needsFix' && ( </TooltipContent>
<span </Tooltip>
className={`ml-1.5 inline-block rounded-full px-1.5 py-0.5 align-middle text-[10px] font-medium leading-none ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`} )}
> </div>
{REVIEW_STATE_DISPLAY.needsFix.label}
</span>
)}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{displaySubject}
</TooltipContent>
</Tooltip>
)}
</div>
{/* Row 2: project + owner (when no team row) + date */} {/* Row 2: project + owner (when no team row) + date */}
<div <div
className="mt-0.5 flex w-full items-center gap-1.5 text-[10px] leading-tight" className="mt-0.5 flex w-full items-center gap-1.5 text-[10px] leading-tight"
style={{ color: 'var(--color-text-muted)' }} style={{ color: 'var(--color-text-muted)' }}
> >
{task.teamDeleted && <Trash2 className="size-2.5 shrink-0 text-zinc-500" />} {task.teamDeleted && <Trash2 className="size-2.5 shrink-0 text-zinc-500" />}
{projectLabel && ( {projectLabel && (
<span <span
className="shrink-0" className="shrink-0"
style={projectColorSet ? { color: projectColorSet.text } : undefined} style={projectColorSet ? { color: projectColorSet.text } : undefined}
>
{projectLabel}
</span>
)}
{!showTeamRow && (
<>
{projectLabel && <span className="opacity-100 dark:opacity-40">·</span>}
<span
className="shrink-0 opacity-100 dark:opacity-60"
style={ownerTextColor ? { color: ownerTextColor } : undefined}
>
{task.owner ?? 'unassigned'}
</span>
</>
)}
{dateLabel && (
<span
className={`ml-auto shrink-0 ${updatedLabel ? 'italic opacity-100 dark:opacity-70' : ''}`}
>
{dateLabel}
</span>
)}
</div>
{/* Row 3: Team: name · owner */}
{showTeamRow && (
<div
className="mt-0.5 flex w-full items-center gap-1.5 text-[10px] leading-tight"
style={{ color: 'var(--color-text-muted)' }}
> >
{projectLabel} <span className="shrink-0 opacity-100 dark:opacity-50">Team:</span>
</span> <span className="shrink-0" style={teamColor ? { color: teamColor.text } : undefined}>
)} {task.teamDisplayName}
{!showTeamRow && ( </span>
<> <span className="opacity-100 dark:opacity-40">·</span>
{projectLabel && <span className="opacity-100 dark:opacity-40">·</span>}
<span <span
className="shrink-0 opacity-100 dark:opacity-60" className="shrink-0 opacity-100 dark:opacity-60"
style={ownerTextColor ? { color: ownerTextColor } : undefined} style={ownerTextColor ? { color: ownerTextColor } : undefined}
> >
{task.owner ?? 'unassigned'} {task.owner ?? 'unassigned'}
</span> </span>
</> </div>
)} )}
{dateLabel && ( </button>
<span );
className={`ml-auto shrink-0 ${updatedLabel ? 'italic opacity-100 dark:opacity-70' : ''}`} }
> );
{dateLabel}
</span>
)}
</div>
{/* Row 3: Team: name · owner */}
{showTeamRow && (
<div
className="mt-0.5 flex w-full items-center gap-1.5 text-[10px] leading-tight"
style={{ color: 'var(--color-text-muted)' }}
>
<span className="shrink-0 opacity-100 dark:opacity-50">Team:</span>
<span className="shrink-0" style={teamColor ? { color: teamColor.text } : undefined}>
{task.teamDisplayName}
</span>
<span className="opacity-100 dark:opacity-40">·</span>
<span
className="shrink-0 opacity-100 dark:opacity-60"
style={ownerTextColor ? { color: ownerTextColor } : undefined}
>
{task.owner ?? 'unassigned'}
</span>
</div>
)}
</button>
);
};

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove } from '@dnd-kit/sortable'; import { arrayMove } from '@dnd-kit/sortable';
@ -311,445 +311,454 @@ const SortableKanbanTaskCard = ({
); );
}; };
export const KanbanBoard = ({ export const KanbanBoard = memo(
tasks, ({
teamName, tasks,
kanbanState, teamName,
filter, kanbanState,
sort, filter,
sessions, sort,
leadSessionId, sessions,
members, leadSessionId,
onFilterChange, members,
onSortChange, onFilterChange,
onRequestReview, onSortChange,
onApprove, onRequestReview,
onRequestChanges, onApprove,
onMoveBackToDone, onRequestChanges,
onStartTask, onMoveBackToDone,
onCompleteTask, onStartTask,
onCancelTask, onCompleteTask,
onScrollToTask, onCancelTask,
onTaskClick, onScrollToTask,
onViewChanges, onTaskClick,
onColumnOrderChange, onViewChanges,
toolbarLeft, onColumnOrderChange,
onAddTask, toolbarLeft,
onDeleteTask, onAddTask,
deletedTaskCount, onDeleteTask,
onOpenTrash, deletedTaskCount,
}: KanbanBoardProps): React.JSX.Element => { onOpenTrash,
const boardRef = useRef<HTMLDivElement>(null); }: KanbanBoardProps): React.JSX.Element => {
const scrollRestoreTimeoutsRef = useRef<number[]>([]); const boardRef = useRef<HTMLDivElement>(null);
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid'); const scrollRestoreTimeoutsRef = useRef<number[]>([]);
const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState<number | null>(null); const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS); const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState<number | null>(null);
const hasReviewers = kanbanState.reviewers.length > 0; const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS);
const enableTaskSorting = const hasReviewers = kanbanState.reviewers.length > 0;
viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual'; const enableTaskSorting =
viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual';
const stableTaskMapRef = useRef<{ const stableTaskMapRef = useRef<{
signatures: string[]; signatures: string[];
map: Map<string, TeamTask>; map: Map<string, TeamTask>;
} | null>(null); } | null>(null);
const taskMap = useMemo(() => { const taskMap = useMemo(() => {
const signatures = tasks.map( const signatures = tasks.map(
(task) => `${task.id}\0${task.displayId ?? ''}\0${task.subject}\0${task.status}` (task) => `${task.id}\0${task.displayId ?? ''}\0${task.subject}\0${task.status}`
);
const previous = stableTaskMapRef.current;
if (
previous?.signatures.length === signatures.length &&
previous.signatures.every((signature, index) => signature === signatures[index])
) {
return previous.map;
}
const next = new Map(tasks.map((task) => [task.id, task]));
stableTaskMapRef.current = { signatures, map: next };
return next;
}, [tasks]);
const memberColorMap = useMemo(() => buildMemberColorMap(members), [members]);
const grouped = useMemo(() => {
const result = new Map<KanbanColumnId, TeamTask[]>(
COLUMNS.map(({ id }) => [id, [] as TeamTask[]])
);
for (const task of tasks) {
const column = getTaskColumn(task, kanbanState);
if (!column) {
continue;
}
result.get(column)?.push(task);
}
return result;
}, [tasks, kanbanState]);
const groupedOrdered = useMemo(() => {
const result = new Map<KanbanColumnId, TeamTask[]>();
for (const column of COLUMNS) {
const columnTasks = grouped.get(column.id) ?? [];
const order = kanbanState.columnOrder?.[column.id];
result.set(column.id, sortColumnTasksByField(columnTasks, sort.field, order));
}
return result;
}, [grouped, kanbanState.columnOrder, sort.field]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
})
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!onColumnOrderChange || !over || active.id === over.id) {
return;
}
const activeData = active.data.current;
if (activeData?.type !== 'kanban-task') {
return;
}
const columnId = activeData.columnId as KanbanColumnId;
const orderedIds = groupedOrdered.get(columnId)?.map((t) => t.id) ?? [];
const oldIndex = orderedIds.indexOf(active.id as string);
const newIndex = orderedIds.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) {
return;
}
const newOrder = arrayMove(orderedIds, oldIndex, newIndex);
onColumnOrderChange(columnId, newOrder);
},
[onColumnOrderChange, groupedOrdered]
);
const renderCards = (
columnId: KanbanColumnId,
columnTasks: TeamTask[],
compact?: boolean
): React.JSX.Element => {
const addHandler =
onAddTask && columnId === 'todo'
? () => onAddTask(false)
: onAddTask && columnId === 'in_progress'
? () => onAddTask(true)
: undefined;
const addButton = addHandler ? (
<button
type="button"
onClick={addHandler}
className="flex w-full items-center justify-center gap-1.5 rounded-md border border-dashed border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)] transition-colors hover:border-[var(--color-border-emphasis)] hover:text-[var(--color-text-secondary)]"
>
<Plus size={13} />
Add task
</button>
) : null;
if (columnTasks.length === 0) {
return (
addButton ?? (
<div className="rounded-md border border-dashed border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]">
No tasks
</div>
)
); );
} const previous = stableTaskMapRef.current;
if (enableTaskSorting) { if (
const itemIds = columnTasks.map((t) => t.id); previous?.signatures.length === signatures.length &&
previous.signatures.every((signature, index) => signature === signatures[index])
) {
return previous.map;
}
const next = new Map(tasks.map((task) => [task.id, task]));
stableTaskMapRef.current = { signatures, map: next };
return next;
}, [tasks]);
const memberColorMap = useMemo(() => buildMemberColorMap(members), [members]);
const grouped = useMemo(() => {
const result = new Map<KanbanColumnId, TeamTask[]>(
COLUMNS.map(({ id }) => [id, [] as TeamTask[]])
);
for (const task of tasks) {
const column = getTaskColumn(task, kanbanState);
if (!column) {
continue;
}
result.get(column)?.push(task);
}
return result;
}, [tasks, kanbanState]);
const groupedOrdered = useMemo(() => {
const result = new Map<KanbanColumnId, TeamTask[]>();
for (const column of COLUMNS) {
const columnTasks = grouped.get(column.id) ?? [];
const order = kanbanState.columnOrder?.[column.id];
result.set(column.id, sortColumnTasksByField(columnTasks, sort.field, order));
}
return result;
}, [grouped, kanbanState.columnOrder, sort.field]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
})
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!onColumnOrderChange || !over || active.id === over.id) {
return;
}
const activeData = active.data.current;
if (activeData?.type !== 'kanban-task') {
return;
}
const columnId = activeData.columnId as KanbanColumnId;
const orderedIds = groupedOrdered.get(columnId)?.map((t) => t.id) ?? [];
const oldIndex = orderedIds.indexOf(active.id as string);
const newIndex = orderedIds.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) {
return;
}
const newOrder = arrayMove(orderedIds, oldIndex, newIndex);
onColumnOrderChange(columnId, newOrder);
},
[onColumnOrderChange, groupedOrdered]
);
const renderCards = (
columnId: KanbanColumnId,
columnTasks: TeamTask[],
compact?: boolean
): React.JSX.Element => {
const addHandler =
onAddTask && columnId === 'todo'
? () => onAddTask(false)
: onAddTask && columnId === 'in_progress'
? () => onAddTask(true)
: undefined;
const addButton = addHandler ? (
<button
type="button"
onClick={addHandler}
className="flex w-full items-center justify-center gap-1.5 rounded-md border border-dashed border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)] transition-colors hover:border-[var(--color-border-emphasis)] hover:text-[var(--color-text-secondary)]"
>
<Plus size={13} />
Add task
</button>
) : null;
if (columnTasks.length === 0) {
return (
addButton ?? (
<div className="rounded-md border border-dashed border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]">
No tasks
</div>
)
);
}
if (enableTaskSorting) {
const itemIds = columnTasks.map((t) => t.id);
return (
<>
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
{columnTasks.map((task) => (
<SortableKanbanTaskCard
key={task.id}
task={task}
columnId={columnId}
teamName={teamName}
kanbanState={kanbanState}
compact={compact}
taskMap={taskMap}
memberColorMap={memberColorMap}
onRequestReview={onRequestReview}
onApprove={onApprove}
onRequestChanges={onRequestChanges}
onMoveBackToDone={onMoveBackToDone}
onStartTask={onStartTask}
onCompleteTask={onCompleteTask}
onCancelTask={onCancelTask}
onScrollToTask={onScrollToTask}
onTaskClick={onTaskClick}
onViewChanges={onViewChanges}
onDeleteTask={onDeleteTask}
/>
))}
</SortableContext>
{addButton}
</>
);
}
return ( return (
<> <>
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}> {columnTasks.map((task) => (
{columnTasks.map((task) => ( <KanbanTaskCard
<SortableKanbanTaskCard key={task.id}
key={task.id} task={task}
task={task} teamName={teamName}
columnId={columnId} columnId={columnId}
teamName={teamName} kanbanTaskState={kanbanState.tasks[task.id]}
kanbanState={kanbanState} hasReviewers={hasReviewers}
compact={compact} compact={compact}
taskMap={taskMap} taskMap={taskMap}
memberColorMap={memberColorMap} memberColorMap={memberColorMap}
onRequestReview={onRequestReview} onRequestReview={onRequestReview}
onApprove={onApprove} onApprove={onApprove}
onRequestChanges={onRequestChanges} onRequestChanges={onRequestChanges}
onMoveBackToDone={onMoveBackToDone} onMoveBackToDone={onMoveBackToDone}
onStartTask={onStartTask} onStartTask={onStartTask}
onCompleteTask={onCompleteTask} onCompleteTask={onCompleteTask}
onCancelTask={onCancelTask} onCancelTask={onCancelTask}
onScrollToTask={onScrollToTask} onScrollToTask={onScrollToTask}
onTaskClick={onTaskClick} onTaskClick={onTaskClick}
onViewChanges={onViewChanges} onViewChanges={onViewChanges}
onDeleteTask={onDeleteTask} onDeleteTask={onDeleteTask}
/> />
))} ))}
</SortableContext>
{addButton} {addButton}
</> </>
); );
} };
return (
<> const visibleColumns = useMemo(
{columnTasks.map((task) => ( () => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS),
<KanbanTaskCard [filter.columns]
key={task.id}
task={task}
teamName={teamName}
columnId={columnId}
kanbanTaskState={kanbanState.tasks[task.id]}
hasReviewers={hasReviewers}
compact={compact}
taskMap={taskMap}
memberColorMap={memberColorMap}
onRequestReview={onRequestReview}
onApprove={onApprove}
onRequestChanges={onRequestChanges}
onMoveBackToDone={onMoveBackToDone}
onStartTask={onStartTask}
onCompleteTask={onCompleteTask}
onCancelTask={onCancelTask}
onScrollToTask={onScrollToTask}
onTaskClick={onTaskClick}
onViewChanges={onViewChanges}
onDeleteTask={onDeleteTask}
/>
))}
{addButton}
</>
); );
}; const primaryVisibleColumnId = visibleColumns[0]?.id ?? null;
const visibleColumns = useMemo( const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]);
() => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS), const { widths: columnWidths, getHandleProps } = useResizableColumns({
[filter.columns] storageKey: teamName,
); columnIds: resizableColumnIds,
const primaryVisibleColumnId = visibleColumns[0]?.id ?? null; });
const columnModeSearchWidth =
primaryVisibleColumnId != null ? (columnWidths.get(primaryVisibleColumnId) ?? 256) : 256;
const toolbarLeftWidth =
viewMode === 'grid'
? (gridPrimaryColumnWidth ?? columnModeSearchWidth)
: columnModeSearchWidth;
const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]); const clearScheduledScrollRestore = useCallback(() => {
const { widths: columnWidths, getHandleProps } = useResizableColumns({ for (const timeoutId of scrollRestoreTimeoutsRef.current) {
storageKey: teamName, window.clearTimeout(timeoutId);
columnIds: resizableColumnIds,
});
const columnModeSearchWidth =
primaryVisibleColumnId != null ? (columnWidths.get(primaryVisibleColumnId) ?? 256) : 256;
const toolbarLeftWidth =
viewMode === 'grid' ? (gridPrimaryColumnWidth ?? columnModeSearchWidth) : columnModeSearchWidth;
const clearScheduledScrollRestore = useCallback(() => {
for (const timeoutId of scrollRestoreTimeoutsRef.current) {
window.clearTimeout(timeoutId);
}
scrollRestoreTimeoutsRef.current = [];
}, []);
useEffect(() => clearScheduledScrollRestore, [clearScheduledScrollRestore]);
const findScrollContainer = useCallback((startNode: HTMLElement | null): HTMLElement | null => {
let current = startNode?.parentElement ?? null;
while (current) {
const { overflowY } = window.getComputedStyle(current);
if (SCROLLABLE_OVERFLOW_VALUES.has(overflowY)) {
return current;
} }
current = current.parentElement; scrollRestoreTimeoutsRef.current = [];
} }, []);
return null;
}, []);
const scheduleScrollRestore = useCallback( useEffect(() => clearScheduledScrollRestore, [clearScheduledScrollRestore]);
(nextViewMode: KanbanViewMode, skeletonDelayMs: number) => {
const container = findScrollContainer(boardRef.current); const findScrollContainer = useCallback((startNode: HTMLElement | null): HTMLElement | null => {
if (!container) { let current = startNode?.parentElement ?? null;
return; while (current) {
const { overflowY } = window.getComputedStyle(current);
if (SCROLLABLE_OVERFLOW_VALUES.has(overflowY)) {
return current;
}
current = current.parentElement;
} }
return null;
}, []);
const savedScrollTop = container.scrollTop; const scheduleScrollRestore = useCallback(
clearScheduledScrollRestore(); (nextViewMode: KanbanViewMode, skeletonDelayMs: number) => {
const container = findScrollContainer(boardRef.current);
if (!container) {
return;
}
const restore = (): void => { const savedScrollTop = container.scrollTop;
container.scrollTop = savedScrollTop; clearScheduledScrollRestore();
};
const delays = const restore = (): void => {
nextViewMode === 'grid' ? [skeletonDelayMs + 40, skeletonDelayMs + 220] : [120]; container.scrollTop = savedScrollTop;
};
scrollRestoreTimeoutsRef.current = delays.map((delay) => window.setTimeout(restore, delay)); const delays =
}, nextViewMode === 'grid' ? [skeletonDelayMs + 40, skeletonDelayMs + 220] : [120];
[clearScheduledScrollRestore, findScrollContainer]
);
const switchViewMode = useCallback( scrollRestoreTimeoutsRef.current = delays.map((delay) => window.setTimeout(restore, delay));
(nextViewMode: KanbanViewMode) => { },
const nextSkeletonDelayMs = [clearScheduledScrollRestore, findScrollContainer]
nextViewMode === 'grid' && viewMode === 'columns' );
? SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH
: SKELETON_HIDE_DELAY_MS;
setGridSkeletonDelayMs(nextSkeletonDelayMs); const switchViewMode = useCallback(
scheduleScrollRestore(nextViewMode, nextSkeletonDelayMs); (nextViewMode: KanbanViewMode) => {
setViewMode(nextViewMode); const nextSkeletonDelayMs =
}, nextViewMode === 'grid' && viewMode === 'columns'
[scheduleScrollRestore, viewMode] ? SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH
); : SKELETON_HIDE_DELAY_MS;
const boardContent = ( setGridSkeletonDelayMs(nextSkeletonDelayMs);
<div ref={boardRef} className="min-w-0 max-w-full overflow-x-hidden"> scheduleScrollRestore(nextViewMode, nextSkeletonDelayMs);
<div setViewMode(nextViewMode);
className={cn( },
'flex min-w-0 max-w-full items-center gap-2', [scheduleScrollRestore, viewMode]
viewMode === 'columns' ? 'mb-0' : 'mb-2', );
toolbarLeft == null && 'justify-end'
)} const boardContent = (
> <div ref={boardRef} className="min-w-0 max-w-full overflow-x-hidden">
{toolbarLeft != null && ( <div
<div className="min-w-0 max-w-full" style={{ width: toolbarLeftWidth }}> className={cn(
{toolbarLeft} 'flex min-w-0 max-w-full items-center gap-2',
</div> viewMode === 'columns' ? 'mb-0' : 'mb-2',
)} toolbarLeft == null && 'justify-end'
<div className="ml-auto flex shrink-0 items-center gap-2"> )}
<div className="inline-flex items-center rounded-md border border-[var(--color-border)]"> >
<KanbanFilterPopover {toolbarLeft != null && (
filter={filter} <div className="min-w-0 max-w-full" style={{ width: toolbarLeftWidth }}>
sessions={sessions} {toolbarLeft}
leadSessionId={leadSessionId} </div>
members={members} )}
onFilterChange={onFilterChange} <div className="ml-auto flex shrink-0 items-center gap-2">
/> <div className="inline-flex items-center rounded-md border border-[var(--color-border)]">
<div className="h-4 w-px bg-[var(--color-border)]" /> <KanbanFilterPopover
<KanbanSortPopover sort={sort} onSortChange={onSortChange} /> filter={filter}
</div> sessions={sessions}
{deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? ( leadSessionId={leadSessionId}
<Tooltip> members={members}
<TooltipTrigger asChild> onFilterChange={onFilterChange}
<Button />
variant="ghost" <div className="h-4 w-px bg-[var(--color-border)]" />
size="sm" <KanbanSortPopover sort={sort} onSortChange={onSortChange} />
className="h-7 px-2 text-[var(--color-text-muted)]" </div>
onClick={onOpenTrash} {deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? (
> <Tooltip>
<Trash2 size={14} /> <TooltipTrigger asChild>
<span className="ml-1 text-xs">{deletedTaskCount}</span> <Button
</Button> variant="ghost"
</TooltipTrigger> size="sm"
<TooltipContent side="bottom">Trash</TooltipContent> className="h-7 px-2 text-[var(--color-text-muted)]"
</Tooltip> onClick={onOpenTrash}
) : null} >
<div className="inline-flex rounded-md border border-[var(--color-border)]"> <Trash2 size={14} />
<Tooltip> <span className="ml-1 text-xs">{deletedTaskCount}</span>
<TooltipTrigger asChild> </Button>
<Button </TooltipTrigger>
variant="ghost" <TooltipContent side="bottom">Trash</TooltipContent>
size="sm" </Tooltip>
className={cn( ) : null}
'h-7 rounded-r-none px-2', <div className="inline-flex rounded-md border border-[var(--color-border)]">
viewMode === 'grid' <Tooltip>
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]' <TooltipTrigger asChild>
: 'text-[var(--color-text-muted)]' <Button
)} variant="ghost"
onClick={() => switchViewMode('grid')} size="sm"
aria-label="Grid view" className={cn(
> 'h-7 rounded-r-none px-2',
<LayoutGrid size={14} /> viewMode === 'grid'
</Button> ? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
</TooltipTrigger> : 'text-[var(--color-text-muted)]'
<TooltipContent side="bottom">Grid view</TooltipContent> )}
</Tooltip> onClick={() => switchViewMode('grid')}
<Tooltip> aria-label="Grid view"
<TooltipTrigger asChild> >
<Button <LayoutGrid size={14} />
variant="ghost" </Button>
size="sm" </TooltipTrigger>
className={cn( <TooltipContent side="bottom">Grid view</TooltipContent>
'h-7 rounded-l-none border-l border-[var(--color-border)] px-2', </Tooltip>
viewMode === 'columns' <Tooltip>
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]' <TooltipTrigger asChild>
: 'text-[var(--color-text-muted)]' <Button
)} variant="ghost"
onClick={() => switchViewMode('columns')} size="sm"
aria-label="Columns view" className={cn(
> 'h-7 rounded-l-none border-l border-[var(--color-border)] px-2',
<Columns3 size={14} /> viewMode === 'columns'
</Button> ? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
</TooltipTrigger> : 'text-[var(--color-text-muted)]'
<TooltipContent side="bottom">Columns view</TooltipContent> )}
</Tooltip> onClick={() => switchViewMode('columns')}
aria-label="Columns view"
>
<Columns3 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Columns view</TooltipContent>
</Tooltip>
</div>
</div> </div>
</div> </div>
</div>
{viewMode === 'grid' ? ( {viewMode === 'grid' ? (
<KanbanGridLayout <KanbanGridLayout
allColumnIds={COLUMNS.map((column) => column.id)} allColumnIds={COLUMNS.map((column) => column.id)}
primaryColumnId={primaryVisibleColumnId} primaryColumnId={primaryVisibleColumnId}
onPrimaryColumnWidthChange={setGridPrimaryColumnWidth} onPrimaryColumnWidthChange={setGridPrimaryColumnWidth}
skeletonDelayMs={gridSkeletonDelayMs} skeletonDelayMs={gridSkeletonDelayMs}
columns={visibleColumns.map((column) => { columns={visibleColumns.map((column) => {
const columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[column.id];
return {
id: column.id,
title: column.title,
count: columnTasks.length,
icon: accent.icon,
headerBg: accent.headerBg,
bodyBg: accent.bodyBg,
content: renderCards(column.id, columnTasks),
showAddButton: columnSupportsAddButton(column.id, onAddTask),
skeletonCards: columnTasks.map((task) => ({
key: task.id,
height: estimateGridSkeletonCardHeight(task, column.id, kanbanState, hasReviewers),
})),
};
})}
/>
) : (
<div className="w-full min-w-0 max-w-full overflow-x-auto overflow-y-hidden px-1 pb-6 pr-4 pt-2">
<div className="flex min-w-max items-start pr-1">
{visibleColumns.map((column, index) => {
const columnTasks = groupedOrdered.get(column.id) ?? []; const columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[column.id]; const accent = COLUMN_ACCENTS[column.id];
const width = columnWidths.get(column.id) ?? 256;
const handleProps = getHandleProps(column.id); return {
return ( id: column.id,
<div key={column.id} className="flex shrink-0"> title: column.title,
<div style={{ width }}> count: columnTasks.length,
<KanbanColumn icon: accent.icon,
title={column.title} headerBg: accent.headerBg,
count={columnTasks.length} bodyBg: accent.bodyBg,
icon={accent.icon} content: renderCards(column.id, columnTasks),
headerBg={accent.headerBg} showAddButton: columnSupportsAddButton(column.id, onAddTask),
bodyBg={accent.bodyBg} skeletonCards: columnTasks.map((task) => ({
bodyClassName="max-h-none overflow-visible" key: task.id,
> height: estimateGridSkeletonCardHeight(
{renderCards(column.id, columnTasks, true)} task,
</KanbanColumn> column.id,
</div> kanbanState,
{index < visibleColumns.length - 1 ? ( hasReviewers
<div ),
className="group relative mx-0.5 flex items-center justify-center" })),
onPointerDown={handleProps.onPointerDown} };
style={handleProps.style}
aria-label={handleProps['aria-label']}
>
<div className="h-full w-px bg-[var(--color-border)] transition-colors group-hover:bg-blue-500/50 group-active:bg-blue-500" />
</div>
) : null}
</div>
);
})} })}
/>
) : (
<div className="w-full min-w-0 max-w-full overflow-x-auto overflow-y-hidden px-1 pb-6 pr-4 pt-2">
<div className="flex min-w-max items-start pr-1">
{visibleColumns.map((column, index) => {
const columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[column.id];
const width = columnWidths.get(column.id) ?? 256;
const handleProps = getHandleProps(column.id);
return (
<div key={column.id} className="flex shrink-0">
<div style={{ width }}>
<KanbanColumn
title={column.title}
count={columnTasks.length}
icon={accent.icon}
headerBg={accent.headerBg}
bodyBg={accent.bodyBg}
bodyClassName="max-h-none overflow-visible"
>
{renderCards(column.id, columnTasks, true)}
</KanbanColumn>
</div>
{index < visibleColumns.length - 1 ? (
<div
className="group relative mx-0.5 flex items-center justify-center"
onPointerDown={handleProps.onPointerDown}
style={handleProps.style}
aria-label={handleProps['aria-label']}
>
<div className="h-full w-px bg-[var(--color-border)] transition-colors group-hover:bg-blue-500/50 group-active:bg-blue-500" />
</div>
) : null}
</div>
);
})}
</div>
</div> </div>
</div> )}
)} </div>
</div>
);
if (enableTaskSorting) {
return (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
{boardContent}
</DndContext>
); );
}
return boardContent; if (enableTaskSorting) {
}; return (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
{boardContent}
</DndContext>
);
}
return boardContent;
}
);

View file

@ -1,5 +1,5 @@
/* eslint-disable tailwindcss/no-custom-classname -- this adapter needs stable non-Tailwind class hooks for react-grid-layout handles. */ /* eslint-disable tailwindcss/no-custom-classname -- this adapter needs stable non-Tailwind class hooks for react-grid-layout handles. */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ReactGridLayout, { WidthProvider } from 'react-grid-layout/legacy'; import ReactGridLayout, { WidthProvider } from 'react-grid-layout/legacy';
import { usePersistedGridLayout } from '@renderer/hooks/usePersistedGridLayout'; import { usePersistedGridLayout } from '@renderer/hooks/usePersistedGridLayout';
@ -387,74 +387,76 @@ const LoadedKanbanGridLayout = ({
); );
}; };
export const KanbanGridLayout = ({ export const KanbanGridLayout = memo(
columns, ({
allColumnIds, columns,
primaryColumnId, allColumnIds,
onPrimaryColumnWidthChange, primaryColumnId,
skeletonDelayMs = SKELETON_HIDE_DELAY_MS, onPrimaryColumnWidthChange,
}: KanbanGridLayoutProps): React.JSX.Element => { skeletonDelayMs = SKELETON_HIDE_DELAY_MS,
const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]); }: KanbanGridLayoutProps): React.JSX.Element => {
const { visibleItems, applyVisibleItems, isLoaded } = usePersistedGridLayout({ const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]);
scopeKey: GRID_SCOPE_KEY, const { visibleItems, applyVisibleItems, isLoaded } = usePersistedGridLayout({
allItemIds: allColumnIds, scopeKey: GRID_SCOPE_KEY,
visibleItemIds: visibleColumnIds, allItemIds: allColumnIds,
cols: GRID_COLS, visibleItemIds: visibleColumnIds,
repository: browserGridLayoutRepository, cols: GRID_COLS,
buildDefaultItems, repository: browserGridLayoutRepository,
}); buildDefaultItems,
const [showResolvedLayout, setShowResolvedLayout] = useState(false); });
const [showResolvedLayout, setShowResolvedLayout] = useState(false);
useEffect(() => { useEffect(() => {
if (showResolvedLayout) return; if (showResolvedLayout) return;
const timeoutId = window.setTimeout(() => { const timeoutId = window.setTimeout(() => {
setShowResolvedLayout(true); setShowResolvedLayout(true);
}, skeletonDelayMs); }, skeletonDelayMs);
return () => { return () => {
window.clearTimeout(timeoutId); window.clearTimeout(timeoutId);
}; };
}, [showResolvedLayout, skeletonDelayMs]); }, [showResolvedLayout, skeletonDelayMs]);
const applyReactGridLayout = useCallback( const applyReactGridLayout = useCallback(
(layout: Layout, options?: { persist?: boolean }) => { (layout: Layout, options?: { persist?: boolean }) => {
if (options?.persist) { if (options?.persist) {
applyVisibleItems(fromReactGridLayout(layout), options); applyVisibleItems(fromReactGridLayout(layout), options);
} }
}, },
[applyVisibleItems] [applyVisibleItems]
); );
const showSkeletonOverlay = !showResolvedLayout || !isLoaded; const showSkeletonOverlay = !showResolvedLayout || !isLoaded;
const gridKey = visibleItems.map((item) => item.id).join('|'); const gridKey = visibleItems.map((item) => item.id).join('|');
return ( return (
<div className="relative min-w-0 max-w-full"> <div className="relative min-w-0 max-w-full">
<LoadedKanbanGridLayout <LoadedKanbanGridLayout
key={gridKey} key={gridKey}
columns={columns}
visibleItems={visibleItems}
onPersistLayout={applyReactGridLayout}
primaryColumnId={primaryColumnId}
onPrimaryColumnWidthChange={onPrimaryColumnWidthChange}
className={cn(
'transition-opacity duration-150',
showSkeletonOverlay ? 'pointer-events-none opacity-0' : 'opacity-100'
)}
/>
{showSkeletonOverlay ? (
<LoadingKanbanGridLayout
columns={columns} columns={columns}
visibleItems={visibleItems} visibleItems={visibleItems}
onPersistLayout={applyReactGridLayout}
primaryColumnId={primaryColumnId} primaryColumnId={primaryColumnId}
onPrimaryColumnWidthChange={onPrimaryColumnWidthChange} onPrimaryColumnWidthChange={onPrimaryColumnWidthChange}
className="pointer-events-none absolute inset-0 z-10" className={cn(
'transition-opacity duration-150',
showSkeletonOverlay ? 'pointer-events-none opacity-0' : 'opacity-100'
)}
/> />
) : null} {showSkeletonOverlay ? (
</div> <LoadingKanbanGridLayout
); columns={columns}
}; visibleItems={visibleItems}
primaryColumnId={primaryColumnId}
onPrimaryColumnWidthChange={onPrimaryColumnWidthChange}
className="pointer-events-none absolute inset-0 z-10"
/>
) : null}
</div>
);
}
);
export { SKELETON_HIDE_DELAY_MS, SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH }; export { SKELETON_HIDE_DELAY_MS, SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH };
/* eslint-enable tailwindcss/no-custom-classname -- stable class hooks remain scoped to this file. */ /* eslint-enable tailwindcss/no-custom-classname -- stable class hooks remain scoped to this file. */

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,5 @@
import { memo } from 'react';
import { import {
KANBAN_COLUMN_DISPLAY, KANBAN_COLUMN_DISPLAY,
REVIEW_STATE_DISPLAY, REVIEW_STATE_DISPLAY,
@ -12,7 +14,7 @@ interface TaskRowProps {
task: TeamTaskWithKanban; task: TeamTaskWithKanban;
} }
export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => { export const TaskRow = memo(({ task }: TaskRowProps): React.JSX.Element => {
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? []; const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? []; const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
const kanbanColumn = getTaskKanbanColumn(task); const kanbanColumn = getTaskKanbanColumn(task);
@ -62,4 +64,4 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
</td> </td>
</tr> </tr>
); );
}; });