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:
parent
2bda324e1a
commit
8b30930c04
5 changed files with 1263 additions and 1238 deletions
|
|
@ -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 { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
|
|
@ -69,218 +69,220 @@ interface SidebarTaskItemProps {
|
|||
getDisplaySubject?: (task: GlobalTask) => string | undefined;
|
||||
}
|
||||
|
||||
export const SidebarTaskItem = ({
|
||||
task,
|
||||
hideTeamName,
|
||||
showTeamName,
|
||||
renamingKey,
|
||||
onRenameComplete,
|
||||
onRenameCancel,
|
||||
getDisplaySubject,
|
||||
}: SidebarTaskItemProps): React.JSX.Element => {
|
||||
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
|
||||
const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members));
|
||||
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
|
||||
const { isLight } = useTheme();
|
||||
export const SidebarTaskItem = memo(
|
||||
({
|
||||
task,
|
||||
hideTeamName,
|
||||
showTeamName,
|
||||
renamingKey,
|
||||
onRenameComplete,
|
||||
onRenameCancel,
|
||||
getDisplaySubject,
|
||||
}: SidebarTaskItemProps): React.JSX.Element => {
|
||||
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
|
||||
const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members));
|
||||
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
|
||||
const { isLight } = useTheme();
|
||||
|
||||
const isRenaming = renamingKey === `${task.teamName}:${task.id}`;
|
||||
const displaySubject = getDisplaySubject?.(task) ?? task.subject;
|
||||
const [editValue, setEditValue] = useState(displaySubject);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// Focus input when rename starts
|
||||
useEffect(() => {
|
||||
if (!isRenaming) return;
|
||||
const raf = requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [isRenaming]);
|
||||
const isRenaming = renamingKey === `${task.teamName}:${task.id}`;
|
||||
const displaySubject = getDisplaySubject?.(task) ?? task.subject;
|
||||
const [editValue, setEditValue] = useState(displaySubject);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// Focus input when rename starts
|
||||
useEffect(() => {
|
||||
if (!isRenaming) return;
|
||||
const raf = requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [isRenaming]);
|
||||
|
||||
// Reset edit value when renaming starts
|
||||
useEffect(() => {
|
||||
if (isRenaming) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
||||
setEditValue(displaySubject);
|
||||
}
|
||||
}, [isRenaming, displaySubject]);
|
||||
// Reset edit value when renaming starts
|
||||
useEffect(() => {
|
||||
if (isRenaming) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
||||
setEditValue(displaySubject);
|
||||
}
|
||||
}, [isRenaming, displaySubject]);
|
||||
|
||||
const reviewColumn = getTaskKanbanColumn(task);
|
||||
const cfg =
|
||||
reviewColumn === 'approved'
|
||||
? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const)
|
||||
: reviewColumn === 'review'
|
||||
? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const)
|
||||
: (statusConfig[task.status] ?? statusConfig.pending);
|
||||
const StatusIcon = cfg.icon;
|
||||
const updatedLabel = formatUpdatedLabel(task);
|
||||
const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt);
|
||||
const reviewColumn = getTaskKanbanColumn(task);
|
||||
const cfg =
|
||||
reviewColumn === 'approved'
|
||||
? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const)
|
||||
: reviewColumn === 'review'
|
||||
? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const)
|
||||
: (statusConfig[task.status] ?? statusConfig.pending);
|
||||
const StatusIcon = cfg.icon;
|
||||
const updatedLabel = formatUpdatedLabel(task);
|
||||
const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt);
|
||||
|
||||
const ownerColorSet = useMemo(() => {
|
||||
if (!teamMembers || !task.owner) return null;
|
||||
const colorMap = buildMemberColorMap(teamMembers);
|
||||
const colorName = colorMap.get(task.owner);
|
||||
return colorName ? getTeamColorSet(colorName) : null;
|
||||
}, [teamMembers, task.owner]);
|
||||
const ownerColorSet = useMemo(() => {
|
||||
if (!teamMembers || !task.owner) return null;
|
||||
const colorMap = buildMemberColorMap(teamMembers);
|
||||
const colorName = colorMap.get(task.owner);
|
||||
return colorName ? getTeamColorSet(colorName) : null;
|
||||
}, [teamMembers, task.owner]);
|
||||
|
||||
const ownerTextColor = useMemo(() => {
|
||||
if (!ownerColorSet) return undefined;
|
||||
return isLight && ownerColorSet.textLight ? ownerColorSet.textLight : ownerColorSet.text;
|
||||
}, [ownerColorSet, isLight]);
|
||||
const ownerTextColor = useMemo(() => {
|
||||
if (!ownerColorSet) return undefined;
|
||||
return isLight && ownerColorSet.textLight ? ownerColorSet.textLight : ownerColorSet.text;
|
||||
}, [ownerColorSet, isLight]);
|
||||
|
||||
const projectLabel = useMemo(() => {
|
||||
if (!task.projectPath?.trim()) return null;
|
||||
return projectLabelFromPath(task.projectPath);
|
||||
}, [task.projectPath]);
|
||||
const projectLabel = useMemo(() => {
|
||||
if (!task.projectPath?.trim()) return null;
|
||||
return projectLabelFromPath(task.projectPath);
|
||||
}, [task.projectPath]);
|
||||
|
||||
const projectColorSet = useMemo(
|
||||
() => (projectLabel ? projectColor(projectLabel, isLight) : null),
|
||||
[projectLabel, isLight]
|
||||
);
|
||||
const projectColorSet = useMemo(
|
||||
() => (projectLabel ? projectColor(projectLabel, isLight) : null),
|
||||
[projectLabel, isLight]
|
||||
);
|
||||
|
||||
const teamColor = useMemo(
|
||||
() => (showTeamName ? nameColorSet(task.teamDisplayName, isLight) : null),
|
||||
[showTeamName, task.teamDisplayName, isLight]
|
||||
);
|
||||
const teamColor = useMemo(
|
||||
() => (showTeamName ? nameColorSet(task.teamDisplayName, isLight) : null),
|
||||
[showTeamName, task.teamDisplayName, isLight]
|
||||
);
|
||||
|
||||
const showTeamRow = showTeamName && !hideTeamName;
|
||||
const unreadBackgroundClass =
|
||||
unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : '';
|
||||
const showTeamRow = showTeamName && !hideTeamName;
|
||||
const unreadBackgroundClass =
|
||||
unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : '';
|
||||
|
||||
return (
|
||||
<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' : ''}`}
|
||||
style={{ borderColor: 'var(--color-border)' }}
|
||||
onClick={() => {
|
||||
if (!isRenaming) {
|
||||
openGlobalTaskDetail(task.teamName, task.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Row 1: status + subject */}
|
||||
<div className="w-full overflow-hidden">
|
||||
{isRenaming ? (
|
||||
<div className="flex items-start gap-1.5">
|
||||
<StatusIcon className={`mt-0.5 size-3 shrink-0 ${cfg.color}`} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
return (
|
||||
<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' : ''}`}
|
||||
style={{ borderColor: 'var(--color-border)' }}
|
||||
onClick={() => {
|
||||
if (!isRenaming) {
|
||||
openGlobalTaskDetail(task.teamName, task.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Row 1: status + subject */}
|
||||
<div className="w-full overflow-hidden">
|
||||
{isRenaming ? (
|
||||
<div className="flex items-start gap-1.5">
|
||||
<StatusIcon className={`mt-0.5 size-3 shrink-0 ${cfg.color}`} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
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();
|
||||
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();
|
||||
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"
|
||||
}}
|
||||
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)' }}
|
||||
>
|
||||
<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}
|
||||
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)' }}
|
||||
>
|
||||
<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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={6}>
|
||||
{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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={6}>
|
||||
{displaySubject}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: project + owner (when no team row) + date */}
|
||||
<div
|
||||
className="mt-0.5 flex w-full items-center gap-1.5 text-[10px] leading-tight"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{task.teamDeleted && <Trash2 className="size-2.5 shrink-0 text-zinc-500" />}
|
||||
{projectLabel && (
|
||||
<span
|
||||
className="shrink-0"
|
||||
style={projectColorSet ? { color: projectColorSet.text } : undefined}
|
||||
{/* Row 2: project + owner (when no team row) + date */}
|
||||
<div
|
||||
className="mt-0.5 flex w-full items-center gap-1.5 text-[10px] leading-tight"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{task.teamDeleted && <Trash2 className="size-2.5 shrink-0 text-zinc-500" />}
|
||||
{projectLabel && (
|
||||
<span
|
||||
className="shrink-0"
|
||||
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>
|
||||
)}
|
||||
{!showTeamRow && (
|
||||
<>
|
||||
{projectLabel && <span className="opacity-100 dark:opacity-40">·</span>}
|
||||
<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>
|
||||
)}
|
||||
{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)' }}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 { arrayMove } from '@dnd-kit/sortable';
|
||||
|
|
@ -311,445 +311,454 @@ const SortableKanbanTaskCard = ({
|
|||
);
|
||||
};
|
||||
|
||||
export const KanbanBoard = ({
|
||||
tasks,
|
||||
teamName,
|
||||
kanbanState,
|
||||
filter,
|
||||
sort,
|
||||
sessions,
|
||||
leadSessionId,
|
||||
members,
|
||||
onFilterChange,
|
||||
onSortChange,
|
||||
onRequestReview,
|
||||
onApprove,
|
||||
onRequestChanges,
|
||||
onMoveBackToDone,
|
||||
onStartTask,
|
||||
onCompleteTask,
|
||||
onCancelTask,
|
||||
onScrollToTask,
|
||||
onTaskClick,
|
||||
onViewChanges,
|
||||
onColumnOrderChange,
|
||||
toolbarLeft,
|
||||
onAddTask,
|
||||
onDeleteTask,
|
||||
deletedTaskCount,
|
||||
onOpenTrash,
|
||||
}: KanbanBoardProps): React.JSX.Element => {
|
||||
const boardRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRestoreTimeoutsRef = useRef<number[]>([]);
|
||||
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
|
||||
const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState<number | null>(null);
|
||||
const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS);
|
||||
const hasReviewers = kanbanState.reviewers.length > 0;
|
||||
const enableTaskSorting =
|
||||
viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual';
|
||||
export const KanbanBoard = memo(
|
||||
({
|
||||
tasks,
|
||||
teamName,
|
||||
kanbanState,
|
||||
filter,
|
||||
sort,
|
||||
sessions,
|
||||
leadSessionId,
|
||||
members,
|
||||
onFilterChange,
|
||||
onSortChange,
|
||||
onRequestReview,
|
||||
onApprove,
|
||||
onRequestChanges,
|
||||
onMoveBackToDone,
|
||||
onStartTask,
|
||||
onCompleteTask,
|
||||
onCancelTask,
|
||||
onScrollToTask,
|
||||
onTaskClick,
|
||||
onViewChanges,
|
||||
onColumnOrderChange,
|
||||
toolbarLeft,
|
||||
onAddTask,
|
||||
onDeleteTask,
|
||||
deletedTaskCount,
|
||||
onOpenTrash,
|
||||
}: KanbanBoardProps): React.JSX.Element => {
|
||||
const boardRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRestoreTimeoutsRef = useRef<number[]>([]);
|
||||
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
|
||||
const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState<number | null>(null);
|
||||
const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS);
|
||||
const hasReviewers = kanbanState.reviewers.length > 0;
|
||||
const enableTaskSorting =
|
||||
viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual';
|
||||
|
||||
const stableTaskMapRef = useRef<{
|
||||
signatures: string[];
|
||||
map: Map<string, TeamTask>;
|
||||
} | null>(null);
|
||||
const taskMap = useMemo(() => {
|
||||
const signatures = tasks.map(
|
||||
(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 stableTaskMapRef = useRef<{
|
||||
signatures: string[];
|
||||
map: Map<string, TeamTask>;
|
||||
} | null>(null);
|
||||
const taskMap = useMemo(() => {
|
||||
const signatures = tasks.map(
|
||||
(task) => `${task.id}\0${task.displayId ?? ''}\0${task.subject}\0${task.status}`
|
||||
);
|
||||
}
|
||||
if (enableTaskSorting) {
|
||||
const itemIds = columnTasks.map((t) => t.id);
|
||||
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>
|
||||
)
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<>
|
||||
<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>
|
||||
{columnTasks.map((task) => (
|
||||
<KanbanTaskCard
|
||||
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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{columnTasks.map((task) => (
|
||||
<KanbanTaskCard
|
||||
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 visibleColumns = useMemo(
|
||||
() => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS),
|
||||
[filter.columns]
|
||||
);
|
||||
};
|
||||
const primaryVisibleColumnId = visibleColumns[0]?.id ?? null;
|
||||
|
||||
const visibleColumns = useMemo(
|
||||
() => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS),
|
||||
[filter.columns]
|
||||
);
|
||||
const primaryVisibleColumnId = visibleColumns[0]?.id ?? null;
|
||||
const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]);
|
||||
const { widths: columnWidths, getHandleProps } = useResizableColumns({
|
||||
storageKey: teamName,
|
||||
columnIds: resizableColumnIds,
|
||||
});
|
||||
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 { widths: columnWidths, getHandleProps } = useResizableColumns({
|
||||
storageKey: teamName,
|
||||
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;
|
||||
const clearScheduledScrollRestore = useCallback(() => {
|
||||
for (const timeoutId of scrollRestoreTimeoutsRef.current) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
scrollRestoreTimeoutsRef.current = [];
|
||||
}, []);
|
||||
|
||||
const scheduleScrollRestore = useCallback(
|
||||
(nextViewMode: KanbanViewMode, skeletonDelayMs: number) => {
|
||||
const container = findScrollContainer(boardRef.current);
|
||||
if (!container) {
|
||||
return;
|
||||
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;
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const savedScrollTop = container.scrollTop;
|
||||
clearScheduledScrollRestore();
|
||||
const scheduleScrollRestore = useCallback(
|
||||
(nextViewMode: KanbanViewMode, skeletonDelayMs: number) => {
|
||||
const container = findScrollContainer(boardRef.current);
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const restore = (): void => {
|
||||
container.scrollTop = savedScrollTop;
|
||||
};
|
||||
const savedScrollTop = container.scrollTop;
|
||||
clearScheduledScrollRestore();
|
||||
|
||||
const delays =
|
||||
nextViewMode === 'grid' ? [skeletonDelayMs + 40, skeletonDelayMs + 220] : [120];
|
||||
const restore = (): void => {
|
||||
container.scrollTop = savedScrollTop;
|
||||
};
|
||||
|
||||
scrollRestoreTimeoutsRef.current = delays.map((delay) => window.setTimeout(restore, delay));
|
||||
},
|
||||
[clearScheduledScrollRestore, findScrollContainer]
|
||||
);
|
||||
const delays =
|
||||
nextViewMode === 'grid' ? [skeletonDelayMs + 40, skeletonDelayMs + 220] : [120];
|
||||
|
||||
const switchViewMode = useCallback(
|
||||
(nextViewMode: KanbanViewMode) => {
|
||||
const nextSkeletonDelayMs =
|
||||
nextViewMode === 'grid' && viewMode === 'columns'
|
||||
? SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH
|
||||
: SKELETON_HIDE_DELAY_MS;
|
||||
scrollRestoreTimeoutsRef.current = delays.map((delay) => window.setTimeout(restore, delay));
|
||||
},
|
||||
[clearScheduledScrollRestore, findScrollContainer]
|
||||
);
|
||||
|
||||
setGridSkeletonDelayMs(nextSkeletonDelayMs);
|
||||
scheduleScrollRestore(nextViewMode, nextSkeletonDelayMs);
|
||||
setViewMode(nextViewMode);
|
||||
},
|
||||
[scheduleScrollRestore, viewMode]
|
||||
);
|
||||
const switchViewMode = useCallback(
|
||||
(nextViewMode: KanbanViewMode) => {
|
||||
const nextSkeletonDelayMs =
|
||||
nextViewMode === 'grid' && viewMode === 'columns'
|
||||
? SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH
|
||||
: SKELETON_HIDE_DELAY_MS;
|
||||
|
||||
const boardContent = (
|
||||
<div ref={boardRef} className="min-w-0 max-w-full overflow-x-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 max-w-full items-center gap-2',
|
||||
viewMode === 'columns' ? 'mb-0' : 'mb-2',
|
||||
toolbarLeft == null && 'justify-end'
|
||||
)}
|
||||
>
|
||||
{toolbarLeft != null && (
|
||||
<div className="min-w-0 max-w-full" style={{ width: toolbarLeftWidth }}>
|
||||
{toolbarLeft}
|
||||
</div>
|
||||
)}
|
||||
<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
|
||||
filter={filter}
|
||||
sessions={sessions}
|
||||
leadSessionId={leadSessionId}
|
||||
members={members}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
<div className="h-4 w-px bg-[var(--color-border)]" />
|
||||
<KanbanSortPopover sort={sort} onSortChange={onSortChange} />
|
||||
</div>
|
||||
{deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-[var(--color-text-muted)]"
|
||||
onClick={onOpenTrash}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span className="ml-1 text-xs">{deletedTaskCount}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Trash</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<div className="inline-flex rounded-md border border-[var(--color-border)]">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 rounded-r-none px-2',
|
||||
viewMode === 'grid'
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
|
||||
: 'text-[var(--color-text-muted)]'
|
||||
)}
|
||||
onClick={() => switchViewMode('grid')}
|
||||
aria-label="Grid view"
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Grid view</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 rounded-l-none border-l border-[var(--color-border)] px-2',
|
||||
viewMode === 'columns'
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
|
||||
: 'text-[var(--color-text-muted)]'
|
||||
)}
|
||||
onClick={() => switchViewMode('columns')}
|
||||
aria-label="Columns view"
|
||||
>
|
||||
<Columns3 size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Columns view</TooltipContent>
|
||||
</Tooltip>
|
||||
setGridSkeletonDelayMs(nextSkeletonDelayMs);
|
||||
scheduleScrollRestore(nextViewMode, nextSkeletonDelayMs);
|
||||
setViewMode(nextViewMode);
|
||||
},
|
||||
[scheduleScrollRestore, viewMode]
|
||||
);
|
||||
|
||||
const boardContent = (
|
||||
<div ref={boardRef} className="min-w-0 max-w-full overflow-x-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 max-w-full items-center gap-2',
|
||||
viewMode === 'columns' ? 'mb-0' : 'mb-2',
|
||||
toolbarLeft == null && 'justify-end'
|
||||
)}
|
||||
>
|
||||
{toolbarLeft != null && (
|
||||
<div className="min-w-0 max-w-full" style={{ width: toolbarLeftWidth }}>
|
||||
{toolbarLeft}
|
||||
</div>
|
||||
)}
|
||||
<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
|
||||
filter={filter}
|
||||
sessions={sessions}
|
||||
leadSessionId={leadSessionId}
|
||||
members={members}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
<div className="h-4 w-px bg-[var(--color-border)]" />
|
||||
<KanbanSortPopover sort={sort} onSortChange={onSortChange} />
|
||||
</div>
|
||||
{deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-[var(--color-text-muted)]"
|
||||
onClick={onOpenTrash}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span className="ml-1 text-xs">{deletedTaskCount}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Trash</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<div className="inline-flex rounded-md border border-[var(--color-border)]">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 rounded-r-none px-2',
|
||||
viewMode === 'grid'
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
|
||||
: 'text-[var(--color-text-muted)]'
|
||||
)}
|
||||
onClick={() => switchViewMode('grid')}
|
||||
aria-label="Grid view"
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Grid view</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 rounded-l-none border-l border-[var(--color-border)] px-2',
|
||||
viewMode === 'columns'
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
|
||||
: 'text-[var(--color-text-muted)]'
|
||||
)}
|
||||
onClick={() => switchViewMode('columns')}
|
||||
aria-label="Columns view"
|
||||
>
|
||||
<Columns3 size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Columns view</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewMode === 'grid' ? (
|
||||
<KanbanGridLayout
|
||||
allColumnIds={COLUMNS.map((column) => column.id)}
|
||||
primaryColumnId={primaryVisibleColumnId}
|
||||
onPrimaryColumnWidthChange={setGridPrimaryColumnWidth}
|
||||
skeletonDelayMs={gridSkeletonDelayMs}
|
||||
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) => {
|
||||
{viewMode === 'grid' ? (
|
||||
<KanbanGridLayout
|
||||
allColumnIds={COLUMNS.map((column) => column.id)}
|
||||
primaryColumnId={primaryVisibleColumnId}
|
||||
onPrimaryColumnWidthChange={setGridPrimaryColumnWidth}
|
||||
skeletonDelayMs={gridSkeletonDelayMs}
|
||||
columns={visibleColumns.map((column) => {
|
||||
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>
|
||||
);
|
||||
|
||||
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 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>
|
||||
);
|
||||
|
||||
if (enableTaskSorting) {
|
||||
return (
|
||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||
{boardContent}
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return boardContent;
|
||||
};
|
||||
if (enableTaskSorting) {
|
||||
return (
|
||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||
{boardContent}
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
return boardContent;
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/* 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 { usePersistedGridLayout } from '@renderer/hooks/usePersistedGridLayout';
|
||||
|
|
@ -387,74 +387,76 @@ const LoadedKanbanGridLayout = ({
|
|||
);
|
||||
};
|
||||
|
||||
export const KanbanGridLayout = ({
|
||||
columns,
|
||||
allColumnIds,
|
||||
primaryColumnId,
|
||||
onPrimaryColumnWidthChange,
|
||||
skeletonDelayMs = SKELETON_HIDE_DELAY_MS,
|
||||
}: KanbanGridLayoutProps): React.JSX.Element => {
|
||||
const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]);
|
||||
const { visibleItems, applyVisibleItems, isLoaded } = usePersistedGridLayout({
|
||||
scopeKey: GRID_SCOPE_KEY,
|
||||
allItemIds: allColumnIds,
|
||||
visibleItemIds: visibleColumnIds,
|
||||
cols: GRID_COLS,
|
||||
repository: browserGridLayoutRepository,
|
||||
buildDefaultItems,
|
||||
});
|
||||
const [showResolvedLayout, setShowResolvedLayout] = useState(false);
|
||||
export const KanbanGridLayout = memo(
|
||||
({
|
||||
columns,
|
||||
allColumnIds,
|
||||
primaryColumnId,
|
||||
onPrimaryColumnWidthChange,
|
||||
skeletonDelayMs = SKELETON_HIDE_DELAY_MS,
|
||||
}: KanbanGridLayoutProps): React.JSX.Element => {
|
||||
const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]);
|
||||
const { visibleItems, applyVisibleItems, isLoaded } = usePersistedGridLayout({
|
||||
scopeKey: GRID_SCOPE_KEY,
|
||||
allItemIds: allColumnIds,
|
||||
visibleItemIds: visibleColumnIds,
|
||||
cols: GRID_COLS,
|
||||
repository: browserGridLayoutRepository,
|
||||
buildDefaultItems,
|
||||
});
|
||||
const [showResolvedLayout, setShowResolvedLayout] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (showResolvedLayout) return;
|
||||
useEffect(() => {
|
||||
if (showResolvedLayout) return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setShowResolvedLayout(true);
|
||||
}, skeletonDelayMs);
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setShowResolvedLayout(true);
|
||||
}, skeletonDelayMs);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [showResolvedLayout, skeletonDelayMs]);
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [showResolvedLayout, skeletonDelayMs]);
|
||||
|
||||
const applyReactGridLayout = useCallback(
|
||||
(layout: Layout, options?: { persist?: boolean }) => {
|
||||
if (options?.persist) {
|
||||
applyVisibleItems(fromReactGridLayout(layout), options);
|
||||
}
|
||||
},
|
||||
[applyVisibleItems]
|
||||
);
|
||||
const showSkeletonOverlay = !showResolvedLayout || !isLoaded;
|
||||
const applyReactGridLayout = useCallback(
|
||||
(layout: Layout, options?: { persist?: boolean }) => {
|
||||
if (options?.persist) {
|
||||
applyVisibleItems(fromReactGridLayout(layout), options);
|
||||
}
|
||||
},
|
||||
[applyVisibleItems]
|
||||
);
|
||||
const showSkeletonOverlay = !showResolvedLayout || !isLoaded;
|
||||
|
||||
const gridKey = visibleItems.map((item) => item.id).join('|');
|
||||
const gridKey = visibleItems.map((item) => item.id).join('|');
|
||||
|
||||
return (
|
||||
<div className="relative min-w-0 max-w-full">
|
||||
<LoadedKanbanGridLayout
|
||||
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
|
||||
return (
|
||||
<div className="relative min-w-0 max-w-full">
|
||||
<LoadedKanbanGridLayout
|
||||
key={gridKey}
|
||||
columns={columns}
|
||||
visibleItems={visibleItems}
|
||||
onPersistLayout={applyReactGridLayout}
|
||||
primaryColumnId={primaryColumnId}
|
||||
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}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
{showSkeletonOverlay ? (
|
||||
<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 };
|
||||
/* eslint-enable tailwindcss/no-custom-classname -- stable class hooks remain scoped to this file. */
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +1,5 @@
|
|||
import { memo } from 'react';
|
||||
|
||||
import {
|
||||
KANBAN_COLUMN_DISPLAY,
|
||||
REVIEW_STATE_DISPLAY,
|
||||
|
|
@ -12,7 +14,7 @@ interface TaskRowProps {
|
|||
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 blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
|
||||
const kanbanColumn = getTaskKanbanColumn(task);
|
||||
|
|
@ -62,4 +64,4 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
|
|||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue