feat(enhanced-kanban): enhance EnhancedKanbanBoard with new task creation options and layout adjustments

- Updated the EnhancedKanbanBoard component to include a new section for creating tasks at both the top and bottom of each group.
- Adjusted the CSS for the kanban groups container to improve layout responsiveness.
- Refactored EnhancedKanbanGroup to manage task creation visibility and interactions more effectively, enhancing user experience during task management.
This commit is contained in:
shancds
2025-06-24 12:24:54 +05:30
parent ad76563543
commit 4f7cbf3527
6 changed files with 424 additions and 74 deletions

View File

@@ -11,7 +11,7 @@
.kanban-groups-container {
display: flex;
gap: 16px;
min-height: calc(100vh - 200px);
min-height: calc(100vh - 350px);
padding-bottom: 16px;
}

View File

@@ -43,6 +43,7 @@ import { statusApiService } from '@/api/taskAttributes/status/status.api.service
import { ITaskStatusCreateRequest } from '@/types/tasks/task-status-create-request';
import alertService from '@/services/alerts/alertService';
import { IGroupBy } from '@/features/enhanced-kanban/enhanced-kanban.slice';
import EnhancedKanbanCreateSection from './EnhancedKanbanCreateSection';
// Import the TaskListFilters component
const TaskListFilters = React.lazy(() => import('@/pages/projects/projectView/taskList/task-list-filters/task-list-filters'));
@@ -380,9 +381,7 @@ const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, cl
}
return (
<div className={`enhanced-kanban-board ${className}`}>
{/* Performance Monitor - only show for large datasets */}
{/* {performanceMetrics.totalTasks > 100 && <PerformanceMonitor />} */}
<>
<Card
size="small"
className="mb-4"
@@ -392,57 +391,62 @@ const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, cl
<TaskListFilters position="board" />
</React.Suspense>
</Card>
<div className={`enhanced-kanban-board ${className}`}>
{/* Performance Monitor - only show for large datasets */}
{/* {performanceMetrics.totalTasks > 100 && <PerformanceMonitor />} */}
{loadingGroups ? (
<Card>
<div className="flex justify-center items-center py-8">
<Spin size="large" />
</div>
</Card>
) : taskGroups.length === 0 ? (
<Card>
<Empty description="No tasks found" image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Card>
) : (
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<SortableContext items={allGroupIds} strategy={horizontalListSortingStrategy}>
<div className="kanban-groups-container">
{taskGroups.map(group => (
<EnhancedKanbanGroup
key={group.id}
group={group}
activeTaskId={dragState.activeTaskId}
overId={overId as string | null}
/>
))}
{loadingGroups ? (
<Card>
<div className="flex justify-center items-center py-8">
<Spin size="large" />
</div>
</SortableContext>
<DragOverlay>
{activeTask && (
<EnhancedKanbanTaskCard
task={activeTask}
isDragOverlay={true}
/>
)}
{activeGroup && (
<div className="group-drag-overlay">
<div className="group-header-content">
<h3>{activeGroup.name}</h3>
<span className="task-count">({activeGroup.tasks.length})</span>
</div>
</Card>
) : taskGroups.length === 0 ? (
<Card>
<Empty description="No tasks found" image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Card>
) : (
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<SortableContext items={allGroupIds} strategy={horizontalListSortingStrategy}>
<div className="kanban-groups-container">
{taskGroups.map(group => (
<EnhancedKanbanGroup
key={group.id}
group={group}
activeTaskId={dragState.activeTaskId}
overId={overId as string | null}
/>
))}
<EnhancedKanbanCreateSection />
</div>
)}
</DragOverlay>
</DndContext>
)}
</div>
</SortableContext>
<DragOverlay>
{activeTask && (
<EnhancedKanbanTaskCard
task={activeTask}
isDragOverlay={true}
/>
)}
{activeGroup && (
<div className="group-drag-overlay">
<div className="group-header-content">
<h3>{activeGroup.name}</h3>
<span className="task-count">({activeGroup.tasks.length})</span>
</div>
</div>
)}
</DragOverlay>
</DndContext>
)}
</div>
</>
);
};

View File

@@ -0,0 +1,150 @@
import React from 'react';
import { Button, Flex } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { nanoid } from '@reduxjs/toolkit';
import { useAppSelector } from '@/hooks/useAppSelector';
import { themeWiseColor } from '@/utils/themeWiseColor';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { IGroupBy, fetchEnhancedKanbanGroups } from '@/features/enhanced-kanban/enhanced-kanban.slice';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import { createStatus, fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
import { ALPHA_CHANNEL } from '@/shared/constants';
import logger from '@/utils/errorLogger';
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
import { useAuthService } from '@/hooks/useAuth';
import useIsProjectManager from '@/hooks/useIsProjectManager';
const EnhancedKanbanCreateSection: React.FC = () => {
const { t } = useTranslation('kanban-board');
const themeMode = useAppSelector((state) => state.themeReducer.mode);
const { projectId } = useAppSelector((state) => state.projectReducer);
const groupBy = useAppSelector((state) => state.enhancedKanbanReducer.groupBy);
const { statusCategories, status: existingStatuses } = useAppSelector((state) => state.taskStatusReducer);
const dispatch = useAppDispatch();
const isOwnerorAdmin = useAuthService().isOwnerOrAdmin();
const isProjectManager = useIsProjectManager();
// Don't show for priority grouping or if user doesn't have permissions
if (groupBy === IGroupBy.PRIORITY || (!isOwnerorAdmin && !isProjectManager)) {
return null;
}
const getUniqueSectionName = (baseName: string): string => {
// Check if the base name already exists
const existingNames = existingStatuses.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 handleAddSection = async () => {
const sectionId = nanoid();
const baseNameSection = 'Untitled section';
const sectionName = getUniqueSectionName(baseNameSection);
if (groupBy === IGroupBy.STATUS && projectId) {
// Find the "To do" category
const todoCategory = statusCategories.find(category =>
category.name?.toLowerCase() === 'to do' ||
category.name?.toLowerCase() === 'todo'
);
if (todoCategory && todoCategory.id) {
// Create a new status
const body = {
name: sectionName,
project_id: projectId,
category_id: todoCategory.id,
};
try {
// Create the status
const response = await dispatch(createStatus({ body, currentProjectId: projectId })).unwrap();
if (response.done && response.body) {
// Refresh the board to show the new section
dispatch(fetchEnhancedKanbanGroups(projectId));
// Refresh statuses
dispatch(fetchStatuses(projectId));
}
} catch (error) {
logger.error('Failed to create status:', error);
}
}
}
if (groupBy === IGroupBy.PHASE && projectId) {
const body = {
name: sectionName,
project_id: projectId,
};
try {
const response = await phasesApiService.addPhaseOption(projectId);
if (response.done && response.body) {
dispatch(fetchEnhancedKanbanGroups(projectId));
}
} catch (error) {
logger.error('Failed to create phase:', error);
}
}
};
return (
<Flex
vertical
gap={16}
style={{
minWidth: 325,
padding: 8,
borderRadius: 12,
}}
className="h-[400px] max-h-[400px] overflow-y-scroll"
>
<div
style={{
borderRadius: 6,
padding: 8,
height: 300,
background: themeWiseColor(
'linear-gradient( 180deg, #fafafa, rgba(245, 243, 243, 0))',
'linear-gradient( 180deg, #2a2b2d, rgba(42, 43, 45, 0))',
themeMode
),
}}
>
<Button
type="text"
style={{
height: '38px',
width: '100%',
borderRadius: 6,
boxShadow: 'none',
}}
icon={<PlusOutlined />}
onClick={handleAddSection}
>
{t('addSectionButton')}
</Button>
</div>
</Flex>
);
};
export default React.memo(EnhancedKanbanCreateSection);

View File

@@ -0,0 +1,134 @@
import React, { useRef, useState, useEffect } from 'react';
import { Button, Flex, Input, InputRef } from 'antd';
import { useTranslation } from 'react-i18next';
import { nanoid } from '@reduxjs/toolkit';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { themeWiseColor } from '@/utils/themeWiseColor';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { useAuthService } from '@/hooks/useAuth';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { ITaskCreateRequest } from '@/types/tasks/task-create-request';
import { addTaskToGroup } from '@/features/enhanced-kanban/enhanced-kanban.slice';
interface EnhancedKanbanCreateTaskCardProps {
sectionId: string;
setShowNewCard: (x: boolean) => void;
position?: 'top' | 'bottom';
}
const EnhancedKanbanCreateTaskCard: React.FC<EnhancedKanbanCreateTaskCardProps> = ({
sectionId,
setShowNewCard,
position = 'bottom',
}) => {
const { t } = useTranslation('kanban-board');
const dispatch = useAppDispatch();
const { socket } = useSocket();
const currentSession = useAuthService().getCurrentSession();
const [newTaskName, setNewTaskName] = useState('');
const [creatingTask, setCreatingTask] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<InputRef>(null);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const projectId = useAppSelector(state => state.projectReducer.projectId);
const groupBy = useAppSelector(state => state.enhancedKanbanReducer.groupBy);
useEffect(() => {
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}, []);
const createRequestBody = (): ITaskCreateRequest | null => {
if (!projectId || !currentSession) return null;
const body: ITaskCreateRequest = {
project_id: projectId,
name: newTaskName.trim(),
reporter_id: currentSession.id,
team_id: currentSession.team_id,
};
if (groupBy === 'status') body.status_id = sectionId;
else if (groupBy === 'priority') body.priority_id = sectionId;
else if (groupBy === 'phase') body.phase_id = sectionId;
return body;
};
const resetForm = () => {
setNewTaskName('');
setCreatingTask(false);
setShowNewCard(false);
setTimeout(() => {
inputRef.current?.focus();
}, 100);
};
const handleAddTask = async () => {
if (creatingTask || !projectId || !currentSession || newTaskName.trim() === '') return;
setCreatingTask(true);
const body = createRequestBody();
if (!body) return;
// Real-time socket event handler
const eventHandler = (task: IProjectTask) => {
setCreatingTask(false);
dispatch(addTaskToGroup({ sectionId, task: { ...task, id: task.id || nanoid(), name: task.name || newTaskName.trim() } }));
socket?.off(SocketEvents.QUICK_TASK.toString(), eventHandler);
resetForm();
};
socket?.once(SocketEvents.QUICK_TASK.toString(), eventHandler);
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
};
const handleCancel = () => {
setNewTaskName('');
setShowNewCard(false);
setCreatingTask(false);
};
return (
<Flex
ref={cardRef}
vertical
gap={12}
style={{
width: '100%',
padding: 12,
backgroundColor: themeWiseColor('#292929', '#fafafa', themeMode),
borderRadius: 6,
cursor: 'pointer',
overflow: 'hidden',
}}
className={`outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline`}
>
<Input
ref={inputRef}
value={newTaskName}
onChange={e => setNewTaskName(e.target.value)}
onPressEnter={handleAddTask}
placeholder={t('newTaskNamePlaceholder')}
style={{
width: '100%',
borderRadius: 6,
padding: 8,
}}
disabled={creatingTask}
/>
{newTaskName.trim() && (
<Flex gap={8} justify="flex-end">
<Button size="small" onClick={handleCancel}>
{t('cancel')}
</Button>
<Button type="primary" size="small" onClick={handleAddTask} loading={creatingTask}>
{t('addTask')}
</Button>
</Flex>
)}
</Flex>
);
};
export default EnhancedKanbanCreateTaskCard;

View File

@@ -19,7 +19,6 @@ import { Typography } from 'antd';
import { Dropdown } from 'antd';
import { Button } from 'antd';
import { PlusOutlined } from '@ant-design/icons/lib/icons';
import { deleteSection, IGroupBy, setBoardGroupName } from '@/features/board/board-slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useTranslation } from 'react-i18next';
import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types';
@@ -31,6 +30,9 @@ 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;
@@ -57,10 +59,11 @@ const EnhancedKanbanGroup: React.FC<EnhancedKanbanGroupProps> = React.memo(({
const themeMode = useAppSelector(state => state.themeReducer.mode);
const dispatch = useAppDispatch();
const { projectId } = useAppSelector(state => state.projectReducer);
const { groupBy } = useAppSelector(state => state.boardReducer);
const { groupBy } = useAppSelector(state => state.enhancedKanbanReducer);
const { statusCategories, status } = useAppSelector(state => state.taskStatusReducer);
const { trackMixpanelEvent } = useMixpanelTracking();
const [showNewCard, setShowNewCard] = useState(false);
const [showNewCardTop, setShowNewCardTop] = useState(false);
const [showNewCardBottom, setShowNewCardBottom] = useState(false);
const { t } = useTranslation('kanban-board');
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
@@ -167,15 +170,7 @@ const EnhancedKanbanGroup: React.FC<EnhancedKanbanGroupProps> = React.memo(({
};
const res = await statusApiService.updateStatus(group.id, body, projectId);
if (res.done) {
dispatch(
setBoardGroupName({
groupId: group.id,
name: sectionName ?? '',
colorCode: res.body.color_code ?? '',
colorCodeDark: res.body.color_code_dark ?? '',
categoryId: category,
})
);
dispatch(fetchEnhancedKanbanGroups(projectId));
dispatch(fetchStatuses(projectId));
setName(sectionName);
} else {
@@ -198,8 +193,8 @@ const EnhancedKanbanGroup: React.FC<EnhancedKanbanGroupProps> = React.memo(({
};
const handleBlur = async () => {
if (group.name === 'Untitled section') {
dispatch(deleteSection({ sectionId: group.id }));
if (name === 'Untitled section') {
dispatch(fetchEnhancedKanbanGroups(projectId ?? ''));
}
setIsEditable(false);
@@ -218,14 +213,14 @@ const EnhancedKanbanGroup: React.FC<EnhancedKanbanGroupProps> = React.memo(({
const res = await phasesApiService.updateNameOfPhase(group.id, body as ITaskPhase, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Phase' });
// dispatch(fetchPhasesByProjectId(projectId));
dispatch(fetchEnhancedKanbanGroups(projectId));
}
}
};
const handlePressEnter = () => {
setShowNewCard(true);
setIsEditable(false);
setShowNewCardTop(true);
setShowNewCardBottom(false);
handleBlur();
};
const handleDeleteSection = async () => {
@@ -237,7 +232,7 @@ const EnhancedKanbanGroup: React.FC<EnhancedKanbanGroupProps> = React.memo(({
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(deleteSection({ sectionId: group.id }));
dispatch(fetchEnhancedKanbanGroups(projectId));
} else {
dispatch(seletedStatusCategory({ id: group.id, name: name, category_id: group.category_id ?? '', message: res.message ?? '' }));
dispatch(deleteStatusToggleDrawer());
@@ -245,7 +240,7 @@ const EnhancedKanbanGroup: React.FC<EnhancedKanbanGroupProps> = React.memo(({
} else if (groupBy === IGroupBy.PHASE) {
const res = await phasesApiService.deletePhaseOption(group.id, projectId);
if (res.done) {
dispatch(deleteSection({ sectionId: group.id }));
dispatch(fetchEnhancedKanbanGroups(projectId));
}
}
} catch (error) {
@@ -327,6 +322,9 @@ const EnhancedKanbanGroup: React.FC<EnhancedKanbanGroupProps> = React.memo(({
style={{
fontWeight: 600,
borderRadius: 6,
width: '100%',
alignItems: 'center',
justifyContent: 'space-between',
}}
onMouseEnter={() => setIsHover(true)}
onMouseLeave={() => setIsHover(false)}
@@ -335,9 +333,13 @@ const EnhancedKanbanGroup: React.FC<EnhancedKanbanGroupProps> = React.memo(({
gap={6}
align="center"
style={{ cursor: 'pointer' }}
onClick={() => {
onClick={(e) => {
e.stopPropagation();
if ((isProjectManager || isOwnerOrAdmin) && group.name !== 'Unmapped') setIsEditable(true);
}}
onMouseDown={(e) => {
e.stopPropagation();
}}
>
<Flex
align="center"
@@ -356,7 +358,7 @@ const EnhancedKanbanGroup: React.FC<EnhancedKanbanGroupProps> = React.memo(({
{isEditable ? (
<Input
ref={inputRef}
value={group.name}
value={name}
variant="borderless"
style={{
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
@@ -364,6 +366,15 @@ const EnhancedKanbanGroup: React.FC<EnhancedKanbanGroupProps> = React.memo(({
onChange={handleChange}
onBlur={handleBlur}
onPressEnter={handlePressEnter}
onMouseDown={(e) => {
e.stopPropagation();
}}
onKeyDown={(e) => {
e.stopPropagation();
}}
onClick={(e) => {
e.stopPropagation();
}}
/>
) : (
<Tooltip title={isEllipsisActive ? name : null}>
@@ -378,6 +389,17 @@ const EnhancedKanbanGroup: React.FC<EnhancedKanbanGroupProps> = React.memo(({
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}
@@ -392,7 +414,10 @@ const EnhancedKanbanGroup: React.FC<EnhancedKanbanGroupProps> = React.memo(({
size="small"
shape="circle"
style={{ color: themeMode === 'dark' ? '#383838' : '' }}
onClick={() => setShowNewCard(true)}
onClick={() => {
setShowNewCardTop(true);
setShowNewCardBottom(false);
}}
>
<PlusOutlined />
</Button>
@@ -427,6 +452,10 @@ const EnhancedKanbanGroup: React.FC<EnhancedKanbanGroupProps> = React.memo(({
</div>
<div className="enhanced-kanban-group-tasks" ref={groupRef}>
{/* Create card at top */}
{showNewCardTop && (isOwnerOrAdmin || isProjectManager) && (
<EnhancedKanbanCreateTaskCard sectionId={group.id} setShowNewCard={setShowNewCardTop} position="top" />
)}
{group.tasks.length === 0 && isDraggingOver && (
<div className="drop-preview-empty">
<div className="drop-indicator">Drop here</div>
@@ -475,6 +504,30 @@ const EnhancedKanbanGroup: React.FC<EnhancedKanbanGroupProps> = React.memo(({
))}
</SortableContext>
)}
{/* Create card at bottom */}
{showNewCardBottom && (isOwnerOrAdmin || isProjectManager) && (
<EnhancedKanbanCreateTaskCard sectionId={group.id} setShowNewCard={setShowNewCardBottom} position="bottom" />
)}
{/* Footer Add Task Button */}
{(isOwnerOrAdmin || isProjectManager) && !showNewCardTop && !showNewCardBottom && (
<Button
type="text"
style={{
height: '38px',
width: '100%',
borderRadius: 6,
boxShadow: 'none',
marginTop: 8,
}}
icon={<PlusOutlined />}
onClick={() => {
setShowNewCardBottom(true);
setShowNewCardTop(false);
}}
>
{t('addTask')}
</Button>
)}
</div>
</div>
);

View File

@@ -440,6 +440,14 @@ const enhancedKanbanSlice = createSlice({
}, {} as Record<string, ITaskListGroup>);
state.columnOrder = reorderedGroups.map(group => group.id);
},
addTaskToGroup: (state, action) => {
const { sectionId, task } = action.payload;
const group = state.taskGroups.find(g => g.id === sectionId);
if (group) {
group.tasks.push(task);
}
},
},
extraReducers: (builder) => {
builder
@@ -528,6 +536,7 @@ export const {
resetState,
reorderTasks,
reorderGroups,
addTaskToGroup,
} = enhancedKanbanSlice.actions;
export default enhancedKanbanSlice.reducer;