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 { 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue