This commit is contained in:
chamikaJ
2025-04-17 18:28:54 +05:30
parent f583291d8a
commit 8825b0410a
2837 changed files with 241385 additions and 127578 deletions

View File

@@ -0,0 +1,160 @@
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 { addBoardSectionCard, fetchBoardTaskGroups, IGroupBy } from '@features/board/board-slice';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import { ITaskStatusCreateRequest } from '@/types/tasks/task-status-create-request';
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';
const BoardCreateSectionCard = () => {
const { t } = useTranslation('kanban-board');
const themeMode = useAppSelector((state) => state.themeReducer.mode);
const { projectId } = useAppSelector((state) => state.projectReducer);
const groupBy = useAppSelector((state) => state.boardReducer.groupBy);
const { statusCategories, status: existingStatuses } = useAppSelector((state) => state.taskStatusReducer);
const dispatch = useAppDispatch();
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) {
dispatch(
addBoardSectionCard({
id: response.body.id as string,
name: sectionName,
colorCode: (response.body.color_code || todoCategory.color_code || '#d8d7d8') + ALPHA_CHANNEL,
colorCodeDark: '#989898',
})
);
// Refresh the board to show the new section
dispatch(fetchBoardTaskGroups(projectId));
// Refresh statuses
dispatch(fetchStatuses(projectId));
}
} catch (error) {
logger.error('Failed to create status:', error);
}
} else {
// Fallback if "To do" category not found
dispatch(
addBoardSectionCard({
id: sectionId,
name: sectionName,
colorCode: '#d8d7d8',
colorCodeDark: '#989898',
})
);
}
}
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(fetchBoardTaskGroups(projectId));
}
} catch (error) {
logger.error('Failed to create phase:', error);
}
}
};
return (
<Flex
vertical
gap={16}
style={{
minWidth: 375,
padding: 8,
borderRadius: 12,
}}
className="h-[600px] max-h-[600px] overflow-y-scroll"
>
<div
style={{
borderRadius: 6,
padding: 8,
height: 640,
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 BoardCreateSectionCard;

View File

@@ -0,0 +1,377 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Badge,
Button,
Dropdown,
Flex,
Input,
InputRef,
Popconfirm,
Tooltip,
Typography,
} from 'antd';
import {
DeleteOutlined,
EditOutlined,
ExclamationCircleFilled,
LoadingOutlined,
MoreOutlined,
PlusOutlined,
RetweetOutlined,
} from '@ant-design/icons';
import { MenuProps } from 'antd';
import { useTranslation } from 'react-i18next';
import ChangeCategoryDropdown from '@/components/board/changeCategoryDropdown/ChangeCategoryDropdown';
import { useAppSelector } from '@/hooks/useAppSelector';
import { colors } from '@/styles/colors';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
deleteSection,
IGroupBy,
setBoardGroupName,
setEditableSection,
} from '@features/board/board-slice';
import { themeWiseColor } from '@/utils/themeWiseColor';
import { useAuthService } from '@/hooks/useAuth';
import useIsProjectManager from '@/hooks/useIsProjectManager';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
import { evt_project_board_column_setting_click } from '@/shared/worklenz-analytics-events';
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
import { updateTaskGroupColor } from '@/features/tasks/tasks.slice';
import { ALPHA_CHANNEL } from '@/shared/constants';
import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types';
import { update } from 'lodash';
import logger from '@/utils/errorLogger';
import { toggleDrawer } from '@/features/projects/status/StatusSlice';
import { deleteStatusToggleDrawer, seletedStatusCategory } from '@/features/projects/status/DeleteStatusSlice';
interface BoardSectionCardHeaderProps {
groupId: string;
name: string;
tasksCount: number;
isLoading: boolean;
setName: (newName: string) => void;
colorCode: string;
onHoverChange: (hovered: boolean) => void;
setShowNewCard: (x: boolean) => void;
categoryId: string | null;
}
const BoardSectionCardHeader: React.FC<BoardSectionCardHeaderProps> = ({
groupId,
name,
tasksCount,
isLoading,
setName,
colorCode,
onHoverChange,
setShowNewCard,
categoryId = null,
}) => {
const { trackMixpanelEvent } = useMixpanelTracking();
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
const isProjectManager = useIsProjectManager();
const [isEditable, setIsEditable] = useState(false);
const [editName, setEdit] = useState(name);
const [isEllipsisActive, setIsEllipsisActive] = useState(false);
const inputRef = useRef<InputRef>(null);
const { editableSectionId, groupBy } = useAppSelector(state => state.boardReducer);
const { projectId } = useAppSelector(state => state.projectReducer);
const { statusCategories, status } = useAppSelector(state => state.taskStatusReducer);
const { t } = useTranslation('kanban-board');
const themeMode = useAppSelector(state => state.themeReducer.mode);
const dispatch = useAppDispatch();
useEffect(() => {
if (isEditable && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditable]);
useEffect(() => {
if (editableSectionId === groupId && (isProjectManager || isOwnerOrAdmin)) {
setIsEditable(true);
dispatch(setEditableSection(null));
}
}, [editableSectionId, groupId, dispatch]);
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 = categoryId) => {
if (!category || !projectId || !groupId) return;
const sectionName = getUniqueSectionName(name);
const body: ITaskStatusUpdateModel = {
name: sectionName,
project_id: projectId,
category_id: category,
};
const res = await statusApiService.updateStatus(groupId, body, projectId);
if (res.done) {
dispatch(
setBoardGroupName({
groupId,
name: sectionName ?? '',
colorCode: res.body.color_code ?? '',
colorCodeDark: res.body.color_code_dark ?? '',
categoryId: category,
})
);
dispatch(fetchStatuses(projectId));
setName(sectionName);
} else {
setName(editName);
logger.error('Error updating status', res.message);
}
};
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const taskName = e.target.value;
setName(taskName);
};
const handleBlur = async () => {
if (name === 'Untitled section') {
dispatch(deleteSection({ sectionId: groupId }));
}
setIsEditable(false);
if (!projectId || !groupId) return;
if (groupBy === IGroupBy.STATUS) {
await updateStatus();
}
if (groupBy === IGroupBy.PHASE) {
const body = {
id: groupId,
name: name,
};
const res = await phasesApiService.updateNameOfPhase(groupId, body as ITaskPhase, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Phase' });
// dispatch(fetchPhasesByProjectId(projectId));
}
}
};
const handlePressEnter = () => {
setShowNewCard(true);
setIsEditable(false);
handleBlur();
};
const handleDeleteSection = async () => {
if (!projectId || !groupId) return;
try {
if (groupBy === IGroupBy.STATUS) {
const replacingStatusId = '';
const res = await statusApiService.deleteStatus(groupId, projectId, replacingStatusId);
if (res.message === 'At least one status should exists under each category.') return
if (res.done) {
dispatch(deleteSection({ sectionId: groupId }));
} else {
dispatch(seletedStatusCategory({ id: groupId, name: name, category_id: categoryId ?? '', message: res.message ?? '' }));
dispatch(deleteStatusToggleDrawer());
}
} else if (groupBy === IGroupBy.PHASE) {
const res = await phasesApiService.deletePhaseOption(groupId, projectId);
if (res.done) {
dispatch(deleteSection({ sectionId: groupId }));
}
}
} catch (error) {
logger.error('Error deleting section', error);
}
};
const items: MenuProps['items'] = [
{
key: '1',
label: (
<div
style={{
display: 'flex',
justifyContent: 'flex-start',
width: '100%',
gap: '8px',
}}
onClick={() => setIsEditable(true)}
>
<EditOutlined /> <span>{t('rename')}</span>
</div>
),
},
groupBy === IGroupBy.STATUS && {
key: '2',
icon: <RetweetOutlined />,
label: 'Change category',
children: statusCategories?.map(status => ({
key: status.id,
label: (
<Flex
gap={8}
onClick={() => status.id && updateStatus(status.id)}
style={categoryId === status.id ? { fontWeight: 700 } : {}}
>
<Badge color={status.color_code} />
{status.name}
</Flex>
),
})),
},
groupBy !== IGroupBy.PRIORITY && {
key: '3',
label: (
<Popconfirm
title={t('deleteConfirmationTitle')}
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
okText={t('deleteConfirmationOk')}
cancelText={t('deleteConfirmationCancel')}
onConfirm={handleDeleteSection}
>
<Flex gap={8} align="center" style={{ width: '100%' }}>
<DeleteOutlined />
{t('delete')}
</Flex>
</Popconfirm>
),
},
].filter(Boolean) as MenuProps['items'];
return (
<Flex
align="center"
justify="space-between"
style={{
fontWeight: 600,
padding: '8px',
backgroundColor: colorCode,
borderRadius: 6,
}}
onMouseEnter={() => onHoverChange(true)}
onMouseLeave={() => onHoverChange(false)}
>
<Flex
gap={8}
align="center"
style={{ cursor: 'pointer' }}
onClick={() => {
if ((isProjectManager || isOwnerOrAdmin) && name !== 'Unmapped') setIsEditable(true);
}}
>
<Flex
align="center"
justify="center"
style={{
minWidth: 26,
height: 26,
borderRadius: 120,
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
}}
>
{tasksCount}
</Flex>
{isLoading && <LoadingOutlined style={{ color: colors.darkGray }} />}
{isEditable ? (
<Input
ref={inputRef}
value={name}
variant="borderless"
style={{
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
}}
onChange={handleChange}
onBlur={handleBlur}
onPressEnter={handlePressEnter}
/>
) : (
<Tooltip title={isEllipsisActive ? name : null}>
<Typography.Text
ellipsis={{
tooltip: false,
onEllipsis: ellipsed => setIsEllipsisActive(ellipsed),
}}
style={{
minWidth: 200,
textTransform: 'capitalize',
color: themeMode === 'dark' ? '#383838' : '',
display: 'inline-block',
overflow: 'hidden',
}}
>
{name}
</Typography.Text>
</Tooltip>
)}
</Flex>
<div style={{ display: 'flex' }}>
<Button
type="text"
size="small"
shape="circle"
style={{ color: themeMode === 'dark' ? '#383838' : '' }}
onClick={() => setShowNewCard(true)}
>
<PlusOutlined />
</Button>
{(isOwnerOrAdmin || isProjectManager) && name !== 'Unmapped' && (
<Dropdown
overlayClassName="todo-threedot-dropdown"
trigger={['click']}
menu={{ items }}
placement="bottomLeft"
>
<Button type="text" size="small" shape="circle">
<MoreOutlined
style={{
rotate: '90deg',
fontSize: '25px',
color: themeMode === 'dark' ? '#383838' : '',
}}
/>
</Button>
</Dropdown>
)}
</div>
</Flex>
);
};
export default BoardSectionCardHeader;

View File

@@ -0,0 +1,220 @@
import { Button, Flex } from 'antd';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useAppSelector } from '@/hooks/useAppSelector';
import { themeWiseColor } from '@/utils/themeWiseColor';
import BoardSectionCardHeader from './board-section-card-header';
import { PlusOutlined } from '@ant-design/icons';
import BoardViewTaskCard from '../board-task-card/board-view-task-card';
import BoardViewCreateTaskCard from '../board-task-card/board-view-create-task-card';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import { DEFAULT_TASK_NAME } from '@/shared/constants';
import { ITaskCreateRequest } from '@/types/tasks/task-create-request.types';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import logger from '@/utils/errorLogger';
interface IBoardSectionCardProps {
taskGroup: ITaskListGroup;
}
const BoardSectionCard = ({ taskGroup }: IBoardSectionCardProps) => {
const { t } = useTranslation('kanban-board');
const scrollContainerRef = useRef<any>(null);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { projectId } = useAppSelector(state => state.projectReducer);
const { team_id: teamId, id: reporterId } = useAppSelector(state => state.userReducer);
const { socket } = useSocket();
const [name, setName] = useState<string>(taskGroup.name);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isHover, setIsHover] = useState<boolean>(false);
const [showNewCardTop, setShowNewCardTop] = useState<boolean>(false);
const [showNewCardBottom, setShowNewCardBottom] = useState<boolean>(false);
const [creatingTempTask, setCreatingTempTask] = useState<boolean>(false);
const { setNodeRef: setDroppableRef } = useDroppable({
id: taskGroup.id,
data: {
type: 'section',
section: taskGroup,
},
});
const {
attributes,
listeners,
setNodeRef: setSortableRef,
transform,
transition,
isDragging,
} = useSortable({
id: taskGroup.id,
data: {
type: 'section',
section: taskGroup,
},
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const setRefs = (el: HTMLElement | null) => {
setSortableRef(el);
setDroppableRef(el);
};
const getInstantTask = async ({
task_id,
group_id,
task,
}: {
task_id: string;
group_id: string;
task: IProjectTask;
}) => {
try {
} catch (error) {
logger.error('Error creating instant task', error);
}
};
const createTempTask = async () => {
if (creatingTempTask || !projectId) return;
setCreatingTempTask(true);
const body: ITaskCreateRequest = {
name: DEFAULT_TASK_NAME,
project_id: projectId,
team_id: teamId,
reporter_id: reporterId,
status_id: taskGroup.id,
};
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
};
const handleAddTaskToBottom = () => {
// createTempTask();
setShowNewCardBottom(true);
};
useEffect(() => {
if (showNewCardBottom && scrollContainerRef.current) {
const timeout = setTimeout(() => {
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
}, 300);
return () => clearTimeout(timeout);
}
}, [taskGroup.tasks, showNewCardBottom]);
return (
<Flex
vertical
gap={16}
ref={setRefs}
{...attributes}
{...listeners}
style={{
...style,
minWidth: 375,
outline: isHover
? `1px solid ${themeWiseColor('#edeae9', '#ffffff12', themeMode)}`
: 'none',
padding: 8,
borderRadius: 12,
}}
className="h-[600px] max-h-[600px] overflow-y-scroll board-section"
data-section-id={taskGroup.id}
data-droppable="true"
data-over="false"
>
<BoardSectionCardHeader
groupId={taskGroup.id}
key={taskGroup.id}
categoryId={taskGroup.category_id ?? null}
name={name}
tasksCount={taskGroup?.tasks.length}
isLoading={isLoading}
setName={setName}
colorCode={themeWiseColor(taskGroup?.color_code, taskGroup?.color_code_dark, themeMode)}
onHoverChange={setIsHover}
setShowNewCard={setShowNewCardTop}
/>
<Flex
vertical
gap={16}
ref={scrollContainerRef}
style={{
borderRadius: 6,
height: taskGroup?.tasks.length <= 0 ? 600 : 'auto',
maxHeight: taskGroup?.tasks.length <= 0 ? 600 : 'auto',
overflowY: 'scroll',
padding: taskGroup?.tasks.length <= 0 ? 8 : 6,
background:
taskGroup?.tasks.length <= 0 && !showNewCardTop && !showNewCardBottom
? themeWiseColor(
'linear-gradient( 180deg, #fafafa, rgba(245, 243, 243, 0))',
'linear-gradient( 180deg, #2a2b2d, rgba(42, 43, 45, 0))',
themeMode
)
: 'transparent',
}}
>
<SortableContext
items={taskGroup.tasks.map(task => task.id ?? '')}
strategy={verticalListSortingStrategy}
>
<Flex vertical gap={16} align="center">
{showNewCardTop && (
<BoardViewCreateTaskCard
position="top"
sectionId={taskGroup.id}
setShowNewCard={setShowNewCardTop}
/>
)}
{taskGroup.tasks.map((task: any) => (
<BoardViewTaskCard key={task.id} sectionId={taskGroup.id} task={task} />
))}
{showNewCardBottom && (
<BoardViewCreateTaskCard
position="bottom"
sectionId={taskGroup.id}
setShowNewCard={setShowNewCardBottom}
/>
)}
</Flex>
</SortableContext>
<Button
type="text"
style={{
height: '38px',
width: '100%',
borderRadius: 6,
boxShadow: 'none',
}}
icon={<PlusOutlined />}
onClick={handleAddTaskToBottom}
>
{t('addTask')}
</Button>
</Flex>
</Flex>
);
};
export default BoardSectionCard;

View File

@@ -0,0 +1,116 @@
import { Flex } from 'antd';
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
import BoardSectionCard from './board-section-card/board-section-card';
import BoardCreateSectionCard from './board-section-card/board-create-section-card';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import { useEffect } from 'react';
import { setTaskAssignee, setTaskEndDate } from '@/features/task-drawer/task-drawer.slice';
import { fetchTaskAssignees } from '@/features/taskAttributes/taskMemberSlice';
import { SocketEvents } from '@/shared/socket-events';
import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response';
import { useSocket } from '@/socket/socketContext';
import { useAuthService } from '@/hooks/useAuth';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { updateTaskAssignees, updateTaskEndDate } from '@/features/board/board-slice';
import useIsProjectManager from '@/hooks/useIsProjectManager';
const BoardSectionCardContainer = ({
datasource,
group,
}: {
datasource: ITaskListGroup[];
group: 'status' | 'priority' | 'phases' | 'members';
}) => {
const { socket } = useSocket();
const dispatch = useAppDispatch();
const currentSession = useAuthService().getCurrentSession();
const { taskGroups } = useAppSelector(state => state.boardReducer);
const { loadingAssignees } = useAppSelector(state => state.taskReducer);
const isOwnerorAdmin = useAuthService().isOwnerOrAdmin();
const isProjectManager = useIsProjectManager();
// Socket handler for assignee updates
useEffect(() => {
if (!socket) return;
const handleAssigneesUpdate = (data: ITaskAssigneesUpdateResponse) => {
if (!data) return;
const updatedAssignees = data.assignees.map(assignee => ({
...assignee,
selected: true,
}));
// Find the group that contains the task or its subtasks
const groupId = taskGroups.find(group =>
group.tasks.some(
task =>
task.id === data.id ||
(task.sub_tasks && task.sub_tasks.some(subtask => subtask.id === data.id))
)
)?.id;
if (groupId) {
dispatch(
updateTaskAssignees({
groupId,
taskId: data.id,
assignees: updatedAssignees,
names: data.names,
})
);
dispatch(setTaskAssignee(data));
if (currentSession?.team_id && !loadingAssignees) {
dispatch(fetchTaskAssignees(currentSession.team_id));
}
}
};
socket.on(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
return () => {
socket.off(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
};
}, [socket, currentSession?.team_id, loadingAssignees, taskGroups, dispatch]);
// Socket handler for due date updates
useEffect(() => {
if (!socket) return;
const handleEndDateChange = (task: {
id: string;
parent_task: string | null;
end_date: string;
}) => {
dispatch(updateTaskEndDate({ task }));
dispatch(setTaskEndDate(task));
};
socket.on(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
return () => {
socket.off(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
};
}, [socket, dispatch]);
return (
<Flex
gap={16}
align="flex-start"
className="max-w-screen max-h-[620px] min-h-[620px] overflow-x-scroll p-[1px]"
>
<SortableContext
items={datasource?.map((section: any) => section.id)}
strategy={horizontalListSortingStrategy}
>
{datasource?.map((data: any) => <BoardSectionCard key={data.id} taskGroup={data} />)}
</SortableContext>
{(group !== 'priority' && (isOwnerorAdmin || isProjectManager)) && <BoardCreateSectionCard />}
</Flex>
);
};
export default BoardSectionCardContainer;

View File

@@ -0,0 +1,172 @@
import { Flex, Input, InputRef } from 'antd';
import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
addSubtask,
GROUP_BY_PHASE_VALUE,
GROUP_BY_PRIORITY_VALUE,
GROUP_BY_STATUS_VALUE,
updateSubtask,
updateTaskProgress,
} from '@features/board/board-slice';
import { themeWiseColor } from '@/utils/themeWiseColor';
import { useAppSelector } from '@/hooks/useAppSelector';
import { getCurrentGroup } from '@/features/tasks/tasks.slice';
import { useAuthService } from '@/hooks/useAuth';
import { ITaskCreateRequest } from '@/types/tasks/task-create-request.types';
import { useParams } from 'react-router-dom';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import logger from '@/utils/errorLogger';
type BoardCreateSubtaskCardProps = {
sectionId: string;
parentTaskId: string;
setShowNewSubtaskCard: (x: boolean) => void;
};
const BoardCreateSubtaskCard = ({
sectionId,
parentTaskId,
setShowNewSubtaskCard,
}: BoardCreateSubtaskCardProps) => {
const { socket, connected } = useSocket();
const dispatch = useAppDispatch();
const [creatingTask, setCreatingTask] = useState<boolean>(false);
const [newSubtaskName, setNewSubtaskName] = useState<string>('');
const [isEnterKeyPressed, setIsEnterKeyPressed] = useState<boolean>(false);
const cardRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<InputRef>(null);
const { t } = useTranslation('kanban-board');
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { projectId } = useParams();
const currentSession = useAuthService().getCurrentSession();
const createRequestBody = (): ITaskCreateRequest | null => {
if (!projectId || !currentSession) return null;
const body: ITaskCreateRequest = {
project_id: projectId,
name: newSubtaskName,
reporter_id: currentSession.id,
team_id: currentSession.team_id,
};
const groupBy = getCurrentGroup();
if (groupBy.value === GROUP_BY_STATUS_VALUE) {
body.status_id = sectionId || undefined;
} else if (groupBy.value === GROUP_BY_PRIORITY_VALUE) {
body.priority_id = sectionId || undefined;
} else if (groupBy.value === GROUP_BY_PHASE_VALUE) {
body.phase_id = sectionId || undefined;
}
if (parentTaskId) {
body.parent_task_id = parentTaskId;
}
return body;
};
const handleAddSubtask = () => {
if (creatingTask || !projectId || !currentSession || newSubtaskName.trim() === '' || !connected)
return;
try {
setCreatingTask(true);
const body = createRequestBody();
if (!body) return;
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
socket?.once(SocketEvents.QUICK_TASK.toString(), (task: IProjectTask) => {
if (!task) return;
dispatch(updateSubtask({ sectionId, subtask: task, mode: 'add' }));
setCreatingTask(false);
// Clear the input field after successful task creation
setNewSubtaskName('');
// Focus back to the input field for adding another subtask
setTimeout(() => {
inputRef.current?.focus();
}, 0);
if (task.parent_task_id) {
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
socket?.once(SocketEvents.GET_TASK_PROGRESS.toString(), (data: {
id: string;
complete_ratio: number;
completed_count: number;
total_tasks_count: number;
parent_task: string;
}) => {
if (!data.parent_task) data.parent_task = task.parent_task_id || '';
dispatch(updateTaskProgress(data));
});
}
});
} catch (error) {
logger.error('Error adding task:', error);
setCreatingTask(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
setIsEnterKeyPressed(true);
handleAddSubtask();
}
};
const handleInputBlur = () => {
if (!isEnterKeyPressed && newSubtaskName.length > 0) {
handleAddSubtask();
}
setIsEnterKeyPressed(false);
};
const handleCancelNewCard = (e: React.FocusEvent<HTMLDivElement>) => {
if (cardRef.current && !cardRef.current.contains(e.relatedTarget)) {
setNewSubtaskName('');
setShowNewSubtaskCard(false);
}
};
return (
<Flex
ref={cardRef}
vertical
gap={12}
style={{
width: '100%',
padding: 12,
backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa',
borderRadius: 6,
cursor: 'pointer',
overflow: 'hidden',
}}
className={`outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline`}
onBlur={handleCancelNewCard}
>
<Input
ref={inputRef}
autoFocus
value={newSubtaskName}
onChange={e => setNewSubtaskName(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
placeholder={t('newSubtaskNamePlaceholder')}
style={{
width: '100%',
borderRadius: 6,
padding: 8,
}}
/>
</Flex>
);
};
export default BoardCreateSubtaskCard;

View File

@@ -0,0 +1,62 @@
import { useState } from 'react';
import dayjs, { Dayjs } from 'dayjs';
import { Col, Flex, Typography, List } from 'antd';
import CustomAvatarGroup from '@/components/board/custom-avatar-group';
import CustomDueDatePicker from '@/components/board/custom-due-date-picker';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
interface IBoardSubTaskCardProps {
subtask: IProjectTask;
sectionId: string;
}
const BoardSubTaskCard = ({ subtask, sectionId }: IBoardSubTaskCardProps) => {
const dispatch = useAppDispatch();
const [subtaskDueDate, setSubtaskDueDate] = useState<Dayjs | null>(
subtask?.end_date ? dayjs(subtask?.end_date) : null
);
const handleCardClick = (e: React.MouseEvent, id: string) => {
// Prevent the event from propagating to parent elements
e.stopPropagation();
// Add a small delay to ensure it's a click and not the start of a drag
const clickTimeout = setTimeout(() => {
dispatch(setSelectedTaskId(id));
dispatch(setShowTaskDrawer(true));
}, 50);
return () => clearTimeout(clickTimeout);
};
return (
<List.Item
key={subtask.id}
className="group"
style={{
width: '100%',
}}
onClick={e => handleCardClick(e, subtask.id || '')}
>
<Col span={10}>
<Typography.Text
style={{ fontWeight: 500, fontSize: 14 }}
delete={subtask.status === 'done'}
ellipsis={{ expanded: false }}
>
{subtask.name}
</Typography.Text>
</Col>
<Flex gap={8} justify="end" style={{ width: '100%' }}>
<CustomAvatarGroup task={subtask} sectionId={sectionId} />
<CustomDueDatePicker task={subtask} onDateChange={setSubtaskDueDate} />
</Flex>
</List.Item>
);
};
export default BoardSubTaskCard;

View File

@@ -0,0 +1,248 @@
import { Button, Flex, Input, InputRef } from 'antd';
import React, { useRef, useState, useEffect } from 'react';
import { Dayjs } from 'dayjs';
import { nanoid } from '@reduxjs/toolkit';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
addTaskCardToTheBottom,
addTaskCardToTheTop,
getCurrentGroupBoard,
GROUP_BY_STATUS_VALUE,
GROUP_BY_PRIORITY_VALUE,
GROUP_BY_PHASE_VALUE,
} from '@features/board/board-slice';
import { themeWiseColor } from '@/utils/themeWiseColor';
import { useAppSelector } from '@/hooks/useAppSelector';
import CustomDueDatePicker from '@/components/board/custom-due-date-picker';
import AddMembersDropdown from '@/components/add-members-dropdown-v2/add-members-dropdown';
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.types';
type BoardViewCreateTaskCardProps = {
position: 'top' | 'bottom';
sectionId: string;
setShowNewCard: (x: boolean) => void;
};
const BoardViewCreateTaskCard = ({
position,
sectionId,
setShowNewCard,
}: BoardViewCreateTaskCardProps) => {
const { t } = useTranslation('kanban-board');
const dispatch = useAppDispatch();
const { socket } = useSocket();
const currentSession = useAuthService().getCurrentSession();
const [newTaskName, setNewTaskName] = useState<string>('');
const [dueDate, setDueDate] = useState<Dayjs | null>(null);
const [creatingTask, setCreatingTask] = useState<boolean>(false);
const cardRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<InputRef>(null);
const focusInput = () => {
setTimeout(() => {
inputRef.current?.focus();
}, 100);
};
// Focus when component mounts or when showNewCard becomes true
useEffect(() => {
focusInput();
}, []);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const projectId = useAppSelector(state => state.projectReducer.projectId);
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,
};
// Set end date if provided
if (dueDate) {
body.end_date = dueDate.toISOString();
}
// Set the appropriate group ID based on the current grouping
const groupBy = getCurrentGroupBoard();
if (groupBy.value === GROUP_BY_STATUS_VALUE) {
body.status_id = sectionId;
} else if (groupBy.value === GROUP_BY_PRIORITY_VALUE) {
body.priority_id = sectionId;
} else if (groupBy.value === GROUP_BY_PHASE_VALUE) {
body.phase_id = sectionId;
}
return body;
};
const resetForm = () => {
setNewTaskName('');
setDueDate(null);
setCreatingTask(false);
setShowNewCard(true);
focusInput();
};
const handleAddTaskToTheTop = async () => {
if (creatingTask || !projectId || !currentSession || newTaskName.trim() === '') return;
try {
setCreatingTask(true);
const body = createRequestBody();
if (!body) return;
// Create a unique event handler for this specific task creation
const eventHandler = (task: IProjectTask) => {
// Set creating task to false
setCreatingTask(false);
// Add the task to the state at the top of the section
dispatch(
addTaskCardToTheTop({
sectionId: sectionId,
task: {
...task,
id: task.id || nanoid(),
name: task.name || newTaskName.trim(),
end_date: task.end_date || dueDate,
},
})
);
// Remove the event listener to prevent memory leaks
socket?.off(SocketEvents.QUICK_TASK.toString(), eventHandler);
// Reset the form
resetForm();
};
// Register the event handler before emitting
socket?.once(SocketEvents.QUICK_TASK.toString(), eventHandler);
// Emit the event
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
} catch (error) {
console.error('Error adding task:', error);
setCreatingTask(false);
}
};
const handleAddTaskToTheBottom = async () => {
if (creatingTask || !projectId || !currentSession || newTaskName.trim() === '') return;
try {
setCreatingTask(true);
const body = createRequestBody();
if (!body) return;
// Create a unique event handler for this specific task creation
const eventHandler = (task: IProjectTask) => {
// Set creating task to false
setCreatingTask(false);
// Add the task to the state at the bottom of the section
dispatch(
addTaskCardToTheBottom({
sectionId: sectionId,
task: {
...task,
id: task.id || nanoid(),
name: task.name || newTaskName.trim(),
end_date: task.end_date || dueDate,
},
})
);
// Remove the event listener to prevent memory leaks
socket?.off(SocketEvents.QUICK_TASK.toString(), eventHandler);
// Reset the form
resetForm();
};
// Register the event handler before emitting
socket?.once(SocketEvents.QUICK_TASK.toString(), eventHandler);
// Emit the event
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
} catch (error) {
console.error('Error adding task:', error);
setCreatingTask(false);
}
};
const handleCancelNewCard = (e: React.FocusEvent<HTMLDivElement>) => {
if (cardRef.current && !cardRef.current.contains(e.relatedTarget)) {
// Only reset the form without creating a task
setNewTaskName('');
setShowNewCard(false);
setDueDate(null);
setCreatingTask(false);
}
};
return (
<Flex
ref={cardRef}
vertical
gap={12}
style={{
width: '100%',
padding: 12,
backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa',
borderRadius: 6,
cursor: 'pointer',
overflow: 'hidden',
}}
className={`outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline`}
onBlur={handleCancelNewCard}
>
<Input
ref={inputRef}
value={newTaskName}
onChange={e => setNewTaskName(e.target.value)}
onPressEnter={position === 'bottom' ? handleAddTaskToTheBottom : handleAddTaskToTheTop}
placeholder={t('newTaskNamePlaceholder')}
style={{
width: '100%',
borderRadius: 6,
padding: 8,
}}
disabled={creatingTask}
/>
{newTaskName.trim() && (
<Flex gap={8} justify="flex-end">
<Button
size="small"
onClick={() => setShowNewCard(false)}
>
{t('cancel')}
</Button>
<Button
type="primary"
size="small"
onClick={position === 'bottom' ? handleAddTaskToTheBottom : handleAddTaskToTheTop}
loading={creatingTask}
>
{t('addTask')}
</Button>
</Flex>
)}
</Flex>
);
};
export default BoardViewCreateTaskCard;

View File

@@ -0,0 +1,413 @@
import { useState, useCallback, useMemo } from 'react';
import {
Tooltip,
Tag,
Progress,
Typography,
Dropdown,
MenuProps,
Button,
Flex,
List,
Divider,
Popconfirm,
Skeleton,
} from 'antd';
import {
DoubleRightOutlined,
PauseOutlined,
UserAddOutlined,
InboxOutlined,
DeleteOutlined,
MinusOutlined,
ForkOutlined,
CaretRightFilled,
CaretDownFilled,
ExclamationCircleFilled,
PlusOutlined,
} from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import { useTranslation } from 'react-i18next';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { themeWiseColor } from '@/utils/themeWiseColor';
import BoardSubTaskCard from '../board-sub-task-card/board-sub-task-card';
import CustomAvatarGroup from '@/components/board/custom-avatar-group';
import CustomDueDatePicker from '@/components/board/custom-due-date-picker';
import { colors } from '@/styles/colors';
import {
deleteBoardTask,
fetchBoardSubTasks,
updateBoardTaskAssignee,
} from '@features/board/board-slice';
import BoardCreateSubtaskCard from '../board-sub-task-card/board-create-sub-task-card';
import { setShowTaskDrawer, setSelectedTaskId } from '@/features/task-drawer/task-drawer.slice';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { IBulkAssignRequest } from '@/types/tasks/bulk-action-bar.types';
import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import {
evt_project_task_list_context_menu_archive,
evt_project_task_list_context_menu_assign_me,
evt_project_task_list_context_menu_delete,
} from '@/shared/worklenz-analytics-events';
import logger from '@/utils/errorLogger';
interface IBoardViewTaskCardProps {
task: IProjectTask;
sectionId: string;
}
const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
const dispatch = useAppDispatch();
const { t } = useTranslation('kanban-board');
const { trackMixpanelEvent } = useMixpanelTracking();
const themeMode = useAppSelector(state => state.themeReducer.mode);
const projectId = useAppSelector(state => state.projectReducer.projectId);
const [isSubTaskShow, setIsSubTaskShow] = useState(false);
const [showNewSubtaskCard, setShowNewSubtaskCard] = useState(false);
const [dueDate, setDueDate] = useState<Dayjs | null>(
task?.end_date ? dayjs(task?.end_date) : null
);
const [updatingAssignToMe, setUpdatingAssignToMe] = useState(false);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: task.id || '',
data: {
type: 'task',
task,
sectionId,
},
});
const style = useMemo(() => ({
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}), [transform, transition, isDragging]);
const handleCardClick = useCallback((e: React.MouseEvent, id: string) => {
// Prevent the event from propagating to parent elements
e.stopPropagation();
// Don't handle click if we're dragging
if (isDragging) return;
// Add a small delay to ensure it's a click and not the start of a drag
const clickTimeout = setTimeout(() => {
dispatch(setSelectedTaskId(id));
dispatch(setShowTaskDrawer(true));
}, 50);
return () => clearTimeout(clickTimeout);
}, [dispatch, isDragging]);
const handleAssignToMe = useCallback(async () => {
if (!projectId || !task.id || updatingAssignToMe) return;
try {
setUpdatingAssignToMe(true);
const body: IBulkAssignRequest = {
tasks: [task.id],
project_id: projectId,
};
const res = await taskListBulkActionsApiService.assignToMe(body);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_context_menu_assign_me);
dispatch(
updateBoardTaskAssignee({
body: res.body,
sectionId,
taskId: task.id,
})
);
}
} catch (error) {
logger.error('Error assigning task to me:', error);
} finally {
setUpdatingAssignToMe(false);
}
}, [projectId, task.id, updatingAssignToMe, dispatch, trackMixpanelEvent, sectionId]);
const handleArchive = useCallback(async () => {
if (!projectId || !task.id) return;
try {
const res = await taskListBulkActionsApiService.archiveTasks(
{
tasks: [task.id],
project_id: projectId,
},
false
);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_context_menu_archive);
dispatch(deleteBoardTask({ sectionId, taskId: task.id }));
}
} catch (error) {
logger.error('Error archiving task:', error);
}
}, [projectId, task.id, dispatch, trackMixpanelEvent, sectionId]);
const handleDelete = useCallback(async () => {
if (!projectId || !task.id) return;
try {
const res = await taskListBulkActionsApiService.deleteTasks({ tasks: [task.id] }, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_context_menu_delete);
dispatch(deleteBoardTask({ sectionId, taskId: task.id }));
}
} catch (error) {
logger.error('Error deleting task:', error);
}
}, [projectId, task.id, dispatch, trackMixpanelEvent, sectionId]);
const handleSubTaskExpand = useCallback(() => {
if (task && task.id && projectId) {
if (task.show_sub_tasks) {
// If subtasks are already loaded, just toggle visibility
setIsSubTaskShow(prev => !prev);
} else {
// If subtasks need to be fetched, show the section first with loading state
setIsSubTaskShow(true);
dispatch(fetchBoardSubTasks({ taskId: task.id, projectId }));
}
}
}, [task, projectId, dispatch]);
const handleAddSubtaskClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setShowNewSubtaskCard(true);
}, []);
const handleSubtaskButtonClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
handleSubTaskExpand();
}, [handleSubTaskExpand]);
const items: MenuProps['items'] = useMemo(() => [
{
label: (
<span>
<UserAddOutlined />
&nbsp;
<Typography.Text>{t('assignToMe')}</Typography.Text>
</span>
),
key: '1',
onClick: handleAssignToMe,
disabled: updatingAssignToMe,
},
{
label: (
<span>
<InboxOutlined />
&nbsp;
<Typography.Text>{t('archive')}</Typography.Text>
</span>
),
key: '2',
onClick: handleArchive,
},
{
label: (
<Popconfirm
title={t('deleteConfirmationTitle')}
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
okText={t('deleteConfirmationOk')}
cancelText={t('deleteConfirmationCancel')}
onConfirm={handleDelete}
>
<DeleteOutlined />
&nbsp;
{t('delete')}
</Popconfirm>
),
key: '3',
},
], [t, handleAssignToMe, handleArchive, handleDelete, updatingAssignToMe]);
const priorityIcon = useMemo(() => {
if (task.priority_value === 0) {
return (
<MinusOutlined
style={{
color: '#52c41a',
marginRight: '0.25rem',
}}
/>
);
} else if (task.priority_value === 1) {
return (
<PauseOutlined
style={{
color: '#faad14',
transform: 'rotate(90deg)',
marginRight: '0.25rem',
}}
/>
);
} else {
return (
<DoubleRightOutlined
style={{
color: '#f5222d',
transform: 'rotate(-90deg)',
marginRight: '0.25rem',
}}
/>
);
}
}, [task.priority_value]);
const renderLabels = useMemo(() => {
if (!task?.labels?.length) return null;
return (
<>
{task.labels.slice(0, 2).map((label: any) => (
<Tag key={label.id} style={{ marginRight: '4px' }} color={label?.color_code}>
<span style={{ color: themeMode === 'dark' ? '#383838' : '' }}>
{label.name}
</span>
</Tag>
))}
{task.labels.length > 2 && <Tag>+ {task.labels.length - 2}</Tag>}
</>
);
}, [task.labels, themeMode]);
return (
<Dropdown menu={{ items }} trigger={['contextMenu']}>
<Flex
ref={setNodeRef}
{...attributes}
{...listeners}
vertical
gap={12}
style={{
...style,
width: '100%',
padding: 12,
backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa',
borderRadius: 6,
cursor: 'grab',
overflow: 'hidden',
}}
className={`group outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline board-task-card`}
onClick={e => handleCardClick(e, task.id || '')}
data-id={task.id}
data-dragging={isDragging ? "true" : "false"}
>
{/* Labels and Progress */}
<Flex align="center" justify="space-between">
<Flex>
{renderLabels}
</Flex>
<Tooltip title={` ${task?.completed_count} / ${task?.total_tasks_count}`}>
<Progress type="circle" percent={task?.complete_ratio } size={26} strokeWidth={(task.complete_ratio || 0) >= 100 ? 9 : 7} />
</Tooltip>
</Flex>
{/* Action Icons */}
<Flex gap={4}>
{priorityIcon}
<Typography.Text
style={{ fontWeight: 500 }}
ellipsis={{ tooltip: task.name }}
>
{task.name}
</Typography.Text>
</Flex>
<Flex vertical gap={8}>
<Flex
align="center"
justify="space-between"
style={{
marginBlock: 8,
}}
>
{task && <CustomAvatarGroup task={task} sectionId={sectionId} />}
<Flex gap={4} align="center">
<CustomDueDatePicker task={task} onDateChange={setDueDate} />
{/* Subtask Section */}
<Button
onClick={handleSubtaskButtonClick}
size="small"
style={{
padding: 0,
}}
type="text"
>
<Tag
bordered={false}
style={{
display: 'flex',
alignItems: 'center',
margin: 0,
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
}}
>
<ForkOutlined rotate={90} />
<span>{task.sub_tasks_count}</span>
{isSubTaskShow ? <CaretDownFilled /> : <CaretRightFilled />}
</Tag>
</Button>
</Flex>
</Flex>
{isSubTaskShow && (
<Flex vertical>
<Divider style={{ marginBlock: 0 }} />
<List>
{task.sub_tasks_loading && (
<List.Item>
<Skeleton active paragraph={{ rows: 2 }} title={false} style={{ marginTop: 8 }} />
</List.Item>
)}
{!task.sub_tasks_loading && task?.sub_tasks &&
task?.sub_tasks.map((subtask: any) => (
<BoardSubTaskCard key={subtask.id} subtask={subtask} sectionId={sectionId} />
))}
{showNewSubtaskCard && (
<BoardCreateSubtaskCard
sectionId={sectionId}
parentTaskId={task.id || ''}
setShowNewSubtaskCard={setShowNewSubtaskCard}
/>
)}
</List>
<Button
type="text"
style={{
width: 'fit-content',
borderRadius: 6,
boxShadow: 'none',
}}
icon={<PlusOutlined />}
onClick={handleAddSubtaskClick}
>
{t('addSubtask', 'Add Subtask')}
</Button>
</Flex>
)}
</Flex>
</Flex>
</Dropdown>
);
};
export default BoardViewTaskCard;

View File

@@ -0,0 +1,431 @@
import { useEffect, useState, useRef } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import TaskListFilters from '../taskList/task-list-filters/task-list-filters';
import { Flex, Skeleton } from 'antd';
import BoardSectionCardContainer from './board-section/board-section-container';
import {
fetchBoardTaskGroups,
reorderTaskGroups,
moveTaskBetweenGroups,
IGroupBy,
updateTaskProgress,
} from '@features/board/board-slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
DndContext,
DragEndEvent,
DragOverEvent,
DragStartEvent,
closestCorners,
DragOverlay,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import BoardViewTaskCard from './board-section/board-task-card/board-view-task-card';
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
import useTabSearchParam from '@/hooks/useTabSearchParam';
import { useSocket } from '@/socket/socketContext';
import { useAuthService } from '@/hooks/useAuth';
import { SocketEvents } from '@/shared/socket-events';
import alertService from '@/services/alerts/alertService';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import { evt_project_board_visit, evt_project_task_list_drag_and_move } from '@/shared/worklenz-analytics-events';
import { ITaskStatusCreateRequest } from '@/types/tasks/task-status-create-request';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import logger from '@/utils/errorLogger';
import { tasksApiService } from '@/api/tasks/tasks.api.service';
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
const ProjectViewBoard = () => {
const dispatch = useAppDispatch();
const { projectView } = useTabSearchParam();
const { socket } = useSocket();
const authService = useAuthService();
const currentSession = authService.getCurrentSession();
const { trackMixpanelEvent } = useMixpanelTracking();
const [ currentTaskIndex, setCurrentTaskIndex] = useState(-1);
const { projectId } = useAppSelector(state => state.projectReducer);
const { taskGroups, groupBy, loadingGroups, search, archived } = useAppSelector(state => state.boardReducer);
const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
state => state.taskStatusReducer
);
const [activeItem, setActiveItem] = useState<any>(null);
// Store the original source group ID when drag starts
const originalSourceGroupIdRef = useRef<string | null>(null);
useEffect(() => {
if (projectId && groupBy && projectView === 'kanban') {
if (!loadingGroups) {
dispatch(fetchBoardTaskGroups(projectId));
}
}
}, [dispatch, projectId, groupBy, projectView, search, archived]);
const sensors = useSensors(
useSensor(MouseSensor, {
// Require the mouse to move by 10 pixels before activating
activationConstraint: {
distance: 10,
},
}),
useSensor(TouchSensor, {
// Press delay of 250ms, with tolerance of 5px of movement
activationConstraint: {
delay: 250,
tolerance: 5,
},
})
);
const handleTaskProgress = (data: {
id: string;
status: string;
complete_ratio: number;
completed_count: number;
total_tasks_count: number;
parent_task: string;
}) => {
dispatch(updateTaskProgress(data));
};
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
setActiveItem(active.data.current);
setCurrentTaskIndex(active.data.current?.sortable.index);
// Store the original source group ID when drag starts
if (active.data.current?.type === 'task') {
originalSourceGroupIdRef.current = active.data.current.sectionId;
}
};
const handleDragOver = (event: DragOverEvent) => {
const { active, over } = event;
if (!over) return;
const activeId = active.id;
const overId = over.id;
if (activeId === overId) return;
const isActiveTask = active.data.current?.type === 'task';
const isOverTask = over.data.current?.type === 'task';
const isOverSection = over.data.current?.type === 'section';
// Handle task movement between sections
if (isActiveTask && (isOverTask || isOverSection)) {
// If we're over a task, we want to insert at that position
// If we're over a section, we want to append to the end
const activeTaskId = active.data.current?.task.id;
// Use the original source group ID from ref instead of the potentially modified one
const sourceGroupId = originalSourceGroupIdRef.current || active.data.current?.sectionId;
// Fix: Ensure we correctly identify the target group ID
let targetGroupId;
if (isOverTask) {
// If over a task, get its section ID
targetGroupId = over.data.current?.sectionId;
} else if (isOverSection) {
// If over a section directly
targetGroupId = over.id;
} else {
// Fallback
targetGroupId = over.id;
}
// Find the target index
let targetIndex = -1;
if (isOverTask) {
const overTaskId = over.data.current?.task.id;
const targetGroup = taskGroups.find(group => group.id === targetGroupId);
if (targetGroup) {
targetIndex = targetGroup.tasks.findIndex(task => task.id === overTaskId);
}
}
// Dispatch the action to move the task
dispatch(
moveTaskBetweenGroups({
taskId: activeTaskId,
sourceGroupId,
targetGroupId,
targetIndex,
})
);
}
};
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || !projectId) {
setActiveItem(null);
originalSourceGroupIdRef.current = null; // Reset the ref
return;
}
const isActiveTask = active.data.current?.type === 'task';
const isActiveSection = active.data.current?.type === 'section';
// Handle task dragging between columns
if (isActiveTask) {
const task = active.data.current?.task;
// Use the original source group ID from ref instead of the potentially modified one
const sourceGroupId = originalSourceGroupIdRef.current || active.data.current?.sectionId;
// Fix: Ensure we correctly identify the target group ID
let targetGroupId;
if (over.data.current?.type === 'task') {
// If dropping on a task, get its section ID
targetGroupId = over.data.current?.sectionId;
} else if (over.data.current?.type === 'section') {
// If dropping directly on a section
targetGroupId = over.id;
} else {
// Fallback to the over ID if type is not specified
targetGroupId = over.id;
}
// Find source and target groups
const sourceGroup = taskGroups.find(group => group.id === sourceGroupId);
const targetGroup = taskGroups.find(group => group.id === targetGroupId);
if (!sourceGroup || !targetGroup || !task) {
logger.error('Could not find source or target group, or task is undefined');
setActiveItem(null);
originalSourceGroupIdRef.current = null; // Reset the ref
return;
}
if (targetGroupId !== sourceGroupId) {
const canContinue = await checkTaskDependencyStatus(task.id, targetGroupId);
if (!canContinue) {
alertService.error(
'Task is not completed',
'Please complete the task dependencies before proceeding'
);
dispatch(
moveTaskBetweenGroups({
taskId: task.id,
sourceGroupId: targetGroupId, // Current group (where it was moved optimistically)
targetGroupId: sourceGroupId, // Move it back to the original source group
targetIndex: currentTaskIndex !== -1 ? currentTaskIndex : 0, // Original position or append to end
})
);
setActiveItem(null);
originalSourceGroupIdRef.current = null;
return;
}
}
// Find indices
let fromIndex = sourceGroup.tasks.findIndex(t => t.id === task.id);
// Handle case where task is not found in source group (might have been moved already in UI)
if (fromIndex === -1) {
logger.info('Task not found in source group. Using task sort_order from task object.');
// Use the sort_order from the task object itself
const fromSortOrder = task.sort_order;
// Calculate target index and position
let toIndex = -1;
if (over.data.current?.type === 'task') {
const overTaskId = over.data.current?.task.id;
toIndex = targetGroup.tasks.findIndex(t => t.id === overTaskId);
} else {
// If dropping on a section, append to the end
toIndex = targetGroup.tasks.length;
}
// Calculate toPos similar to Angular implementation
const toPos = targetGroup.tasks[toIndex]?.sort_order ||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
-1;
// Prepare socket event payload
const body = {
project_id: projectId,
from_index: fromSortOrder,
to_index: toPos,
to_last_index: !toPos,
from_group: sourceGroupId,
to_group: targetGroupId,
group_by: groupBy || 'status',
task,
team_id: currentSession?.team_id
};
logger.error('Emitting socket event with payload (task not found in source):', body);
// Emit socket event
if (socket) {
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
// Set up listener for task progress update
socket.once(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), () => {
if (task.is_sub_task) {
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
} else {
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
}
});
}
// Track analytics event
trackMixpanelEvent(evt_project_task_list_drag_and_move);
setActiveItem(null);
originalSourceGroupIdRef.current = null; // Reset the ref
return;
}
// Calculate target index and position
let toIndex = -1;
if (over.data.current?.type === 'task') {
const overTaskId = over.data.current?.task.id;
toIndex = targetGroup.tasks.findIndex(t => t.id === overTaskId);
} else {
// If dropping on a section, append to the end
toIndex = targetGroup.tasks.length;
}
// Calculate toPos similar to Angular implementation
const toPos = targetGroup.tasks[toIndex]?.sort_order ||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
-1;
// Prepare socket event payload
const body = {
project_id: projectId,
from_index: sourceGroup.tasks[fromIndex].sort_order,
to_index: toPos,
to_last_index: !toPos,
from_group: sourceGroupId, // Use the direct IDs instead of group objects
to_group: targetGroupId, // Use the direct IDs instead of group objects
group_by: groupBy || 'status', // Use the current groupBy value
task,
team_id: currentSession?.team_id
};
// Emit socket event
if (socket) {
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
// Set up listener for task progress update
socket.once(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), () => {
if (task.is_sub_task) {
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
} else {
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
}
});
}
// Track analytics event
trackMixpanelEvent(evt_project_task_list_drag_and_move);
}
// Handle column reordering
else if (isActiveSection) {
// Don't allow reordering if groupBy is phases
if (groupBy === IGroupBy.PHASE) {
setActiveItem(null);
originalSourceGroupIdRef.current = null;
return;
}
const sectionId = active.id;
const fromIndex = taskGroups.findIndex(group => group.id === sectionId);
const toIndex = taskGroups.findIndex(group => group.id === over.id);
if (fromIndex !== -1 && toIndex !== -1) {
// Create a new array with the reordered groups
const reorderedGroups = [...taskGroups];
const [movedGroup] = reorderedGroups.splice(fromIndex, 1);
reorderedGroups.splice(toIndex, 0, movedGroup);
// Dispatch action to reorder columns with the new array
dispatch(reorderTaskGroups(reorderedGroups));
// Prepare column order for API
const columnOrder = reorderedGroups.map(group => group.id);
// Call API to update status order
try {
// Use the correct API endpoint based on the Angular code
const requestBody: ITaskStatusCreateRequest = {
status_order: columnOrder
};
const response = await statusApiService.updateStatusOrder(requestBody, projectId);
if (!response.done) {
const revertedGroups = [...reorderedGroups];
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
revertedGroups.splice(fromIndex, 0, movedBackGroup);
dispatch(reorderTaskGroups(revertedGroups));
alertService.error('Failed to update column order', 'Please try again');
}
} catch (error) {
// Revert the change if API call fails
const revertedGroups = [...reorderedGroups];
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
revertedGroups.splice(fromIndex, 0, movedBackGroup);
dispatch(reorderTaskGroups(revertedGroups));
alertService.error('Failed to update column order', 'Please try again');
}
}
}
setActiveItem(null);
originalSourceGroupIdRef.current = null; // Reset the ref
};
useEffect(() => {
if (socket) {
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
}
return () => {
socket?.off(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
};
}, [socket]);
useEffect(() => {
trackMixpanelEvent(evt_project_board_visit);
if (!statusCategories.length && projectId) {
dispatch(fetchStatusesCategories());
}
}, [dispatch, projectId]);
return (
<Flex vertical gap={16}>
<TaskListFilters position={'board'} />
<Skeleton active loading={loadingGroups} className='mt-4 p-4'>
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<BoardSectionCardContainer
datasource={taskGroups}
group={groupBy as 'status' | 'priority' | 'phases'}
/>
<DragOverlay>
{activeItem?.type === 'task' && (
<BoardViewTaskCard task={activeItem.task} sectionId={activeItem.sectionId} />
)}
</DragOverlay>
</DndContext>
</Skeleton>
</Flex>
);
};
export default ProjectViewBoard;