import React, { useMemo, useRef, useEffect, useState } from 'react'; import { useDroppable } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy, useSortable, defaultAnimateLayoutChanges, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { ITaskListGroup } from '@/types/tasks/taskList.types'; import EnhancedKanbanTaskCard from './EnhancedKanbanTaskCard'; import VirtualizedTaskList from './VirtualizedTaskList'; import { useAppSelector } from '@/hooks/useAppSelector'; import './EnhancedKanbanGroup.css'; import { Badge, Flex, InputRef, MenuProps, Popconfirm } from 'antd'; import { themeWiseColor } from '@/utils/themeWiseColor'; import useIsProjectManager from '@/hooks/useIsProjectManager'; import { useAuthService } from '@/hooks/useAuth'; import { DeleteOutlined, ExclamationCircleFilled, EditOutlined, LoadingOutlined, RetweetOutlined, MoreOutlined, } from '@ant-design/icons/lib/icons'; import { colors } from '@/styles/colors'; import { Input } from 'antd'; import { Tooltip } from 'antd'; import { Typography } from 'antd'; import { Dropdown } from 'antd'; import { Button } from 'antd'; import { PlusOutlined } from '@ant-design/icons/lib/icons'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useTranslation } from 'react-i18next'; import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types'; import { statusApiService } from '@/api/taskAttributes/status/status.api.service'; import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice'; import logger from '@/utils/errorLogger'; import { evt_project_board_column_setting_click } from '@/shared/worklenz-analytics-events'; import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service'; import { ITaskPhase } from '@/types/tasks/taskPhase.types'; import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; import { deleteStatusToggleDrawer, seletedStatusCategory, } from '@/features/projects/status/DeleteStatusSlice'; import { fetchEnhancedKanbanGroups, IGroupBy, } from '@/features/enhanced-kanban/enhanced-kanban.slice'; import EnhancedKanbanCreateTaskCard from './EnhancedKanbanCreateTaskCard'; interface EnhancedKanbanGroupProps { group: ITaskListGroup; activeTaskId?: string | null; overId?: string | null; } // Performance threshold for virtualization const VIRTUALIZATION_THRESHOLD = 50; const EnhancedKanbanGroup: React.FC = React.memo( ({ group, activeTaskId, overId }) => { const [isHover, setIsHover] = useState(false); const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin(); const [isEditable, setIsEditable] = useState(false); const isProjectManager = useIsProjectManager(); const [isLoading, setIsLoading] = useState(false); const [name, setName] = useState(group.name); const inputRef = useRef(null); const [editName, setEdit] = useState(group.name); const [isEllipsisActive, setIsEllipsisActive] = useState(false); const themeMode = useAppSelector(state => state.themeReducer.mode); const dispatch = useAppDispatch(); const { projectId } = useAppSelector(state => state.projectReducer); const { groupBy } = useAppSelector(state => state.enhancedKanbanReducer); const { statusCategories, status } = useAppSelector(state => state.taskStatusReducer); const { trackMixpanelEvent } = useMixpanelTracking(); const [showNewCardTop, setShowNewCardTop] = useState(false); const [showNewCardBottom, setShowNewCardBottom] = useState(false); const { t } = useTranslation('kanban-board'); const { setNodeRef: setDroppableRef, isOver } = useDroppable({ id: group.id, data: { type: 'group', group, }, }); // Add sortable functionality for group header const { attributes, listeners, setNodeRef: setSortableRef, transform, transition, isDragging: isGroupDragging, } = useSortable({ id: group.id, data: { type: 'group', group, }, animateLayoutChanges: defaultAnimateLayoutChanges, }); const groupRef = useRef(null); const [groupHeight, setGroupHeight] = useState(400); // Get task IDs for sortable context const taskIds = group.tasks.map(task => task.id!); // Check if this group is the target for dropping const isTargetGroup = overId === group.id; const isDraggingOver = isOver || isTargetGroup; // Determine if virtualization should be used const shouldVirtualize = useMemo(() => { return group.tasks.length > VIRTUALIZATION_THRESHOLD; }, [group.tasks.length]); // Calculate optimal height for virtualization useEffect(() => { if (groupRef.current) { const containerHeight = Math.min( Math.max(group.tasks.length * 80, 200), // Minimum 200px, scale with tasks 600 // Maximum 600px ); setGroupHeight(containerHeight); } }, [group.tasks.length]); // Memoize task rendering to prevent unnecessary re-renders const renderTask = useMemo( () => (task: any, index: number) => ( ), [activeTaskId, overId] ); // Performance optimization: Only render drop indicators when needed const shouldShowDropIndicators = isDraggingOver && !shouldVirtualize; // Combine refs for the main container const setRefs = (el: HTMLElement | null) => { setDroppableRef(el); setSortableRef(el); }; const style = { transform: CSS.Transform.toString(transform), transition, opacity: isGroupDragging ? 0.5 : 1, }; const getUniqueSectionName = (baseName: string): string => { // Check if the base name already exists const existingNames = status.map(status => status.name?.toLowerCase()); if (!existingNames.includes(baseName.toLowerCase())) { return baseName; } // If the base name exists, add a number suffix let counter = 1; let newName = `${baseName.trim()} (${counter})`; while (existingNames.includes(newName.toLowerCase())) { counter++; newName = `${baseName.trim()} (${counter})`; } return newName; }; const updateStatus = async (category = group.category_id ?? null) => { if (!category || !projectId || !group.id) return; const sectionName = getUniqueSectionName(name); const body: ITaskStatusUpdateModel = { name: sectionName, project_id: projectId, category_id: category, }; const res = await statusApiService.updateStatus(group.id, body, projectId); if (res.done) { dispatch(fetchEnhancedKanbanGroups(projectId)); dispatch(fetchStatuses(projectId)); setName(sectionName); } else { setName(editName); logger.error('Error updating status', res.message); } }; // Get the appropriate background color based on theme const headerBackgroundColor = useMemo(() => { if (themeMode === 'dark') { return group.color_code_dark || group.color_code || '#1e1e1e'; } return group.color_code || '#f5f5f5'; }, [themeMode, group.color_code, group.color_code_dark]); const handleChange = async (e: React.ChangeEvent) => { const taskName = e.target.value; setName(taskName); }; const handleBlur = async () => { if (name === 'Untitled section') { dispatch(fetchEnhancedKanbanGroups(projectId ?? '')); } setIsEditable(false); if (!projectId || !group.id) return; if (groupBy === IGroupBy.STATUS) { await updateStatus(); } if (groupBy === IGroupBy.PHASE) { const body = { id: group.id, name: name, }; const res = await phasesApiService.updateNameOfPhase( group.id, body as ITaskPhase, projectId ); if (res.done) { trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Phase' }); dispatch(fetchEnhancedKanbanGroups(projectId)); } } }; const handlePressEnter = () => { setShowNewCardTop(true); setShowNewCardBottom(false); handleBlur(); }; const handleDeleteSection = async () => { if (!projectId || !group.id) return; try { if (groupBy === IGroupBy.STATUS) { const replacingStatusId = ''; const res = await statusApiService.deleteStatus(group.id, projectId, replacingStatusId); if (res.message === 'At least one status should exists under each category.') return; if (res.done) { dispatch(fetchEnhancedKanbanGroups(projectId)); } else { dispatch( seletedStatusCategory({ id: group.id, name: name, category_id: group.category_id ?? '', message: res.message ?? '', }) ); dispatch(deleteStatusToggleDrawer()); } } else if (groupBy === IGroupBy.PHASE) { const res = await phasesApiService.deletePhaseOption(group.id, projectId); if (res.done) { dispatch(fetchEnhancedKanbanGroups(projectId)); } } } catch (error) { logger.error('Error deleting section', error); } }; const items: MenuProps['items'] = [ { key: '1', label: (
setIsEditable(true)} > {t('rename')}
), }, groupBy === IGroupBy.STATUS && { key: '2', icon: , label: 'Change category', children: statusCategories?.map(status => ({ key: status.id, label: ( status.id && updateStatus(status.id)} style={group.category_id === status.id ? { fontWeight: 700 } : {}} > {status.name} ), })), }, groupBy !== IGroupBy.PRIORITY && { key: '3', label: ( } okText={t('deleteConfirmationOk')} cancelText={t('deleteConfirmationCancel')} onConfirm={handleDeleteSection} > {t('delete')} ), }, ].filter(Boolean) as MenuProps['items']; return (
{/* section header */}
{/* ({group.tasks.length}) */} setIsHover(true)} onMouseLeave={() => setIsHover(false)} > { e.stopPropagation(); if ((isProjectManager || isOwnerOrAdmin) && group.name !== 'Unmapped') setIsEditable(true); }} onMouseDown={e => { e.stopPropagation(); }} > {isLoading && } {isEditable ? ( { e.stopPropagation(); }} onKeyDown={e => { e.stopPropagation(); }} onClick={e => { e.stopPropagation(); }} /> ) : ( setIsEllipsisActive(ellipsed), }} style={{ minWidth: 185, textTransform: 'capitalize', color: themeMode === 'dark' ? '#383838' : '', display: 'inline-block', overflow: 'hidden', userSelect: 'text', }} onMouseDown={e => { e.stopPropagation(); e.preventDefault(); }} onMouseUp={e => { e.stopPropagation(); }} onClick={e => { e.stopPropagation(); }} > {name} ({group.tasks.length}) )}
{(isOwnerOrAdmin || isProjectManager) && name !== 'Unmapped' && ( )}
{/*

{group.name}

*/} {/* {shouldVirtualize && ( )} */}
{/* Create card at top */} {showNewCardTop && (isOwnerOrAdmin || isProjectManager) && ( )} {group.tasks.length === 0 && isDraggingOver && (
Drop here
)} {shouldVirtualize ? ( // Use virtualization for large task lists ) : ( // Use standard rendering for smaller lists {group.tasks.map((task, index) => ( {/* Drop indicator before the card if this is the drop target */} {overId === task.id && (
)} {/* Drop indicator at the end if dropping at the end of the group */} {index === group.tasks.length - 1 && overId === group.id && (
)} ))} )} {/* Create card at bottom */} {showNewCardBottom && (isOwnerOrAdmin || isProjectManager) && ( )} {/* Footer Add Task Button */} {(isOwnerOrAdmin || isProjectManager) && !showNewCardTop && !showNewCardBottom && ( )}
); } ); export default EnhancedKanbanGroup;