expand sub tasks

This commit is contained in:
chamiakJ
2025-07-03 01:31:05 +05:30
parent 3bef18901a
commit ecd4d29a38
435 changed files with 13150 additions and 11087 deletions

View File

@@ -13,11 +13,7 @@ import {
TouchSensor,
UniqueIdentifier,
} from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { useAppSelector } from '@/hooks/useAppSelector';
@@ -35,28 +31,25 @@ import { evt_project_task_list_drag_and_move } from '@/shared/worklenz-analytics
interface DraggableRowProps {
task: IProjectTask;
visibleColumns: Array<{ key: string; width: number }>;
renderCell: (columnKey: string | number, task: IProjectTask, isSubtask?: boolean) => React.ReactNode;
renderCell: (
columnKey: string | number,
task: IProjectTask,
isSubtask?: boolean
) => React.ReactNode;
hoverRow: string | null;
onRowHover: (taskId: string | null) => void;
isSubtask?: boolean;
}
const DraggableRow = ({
task,
visibleColumns,
renderCell,
hoverRow,
const DraggableRow = ({
task,
visibleColumns,
renderCell,
hoverRow,
onRowHover,
isSubtask = false
isSubtask = false,
}: DraggableRowProps) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: task.id as UniqueIdentifier,
data: {
type: 'task',
@@ -119,11 +112,11 @@ const TaskListTable = ({
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
const tableRef = useRef<HTMLDivElement | null>(null);
const parentRef = useRef<HTMLDivElement | null>(null);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { projectId } = useAppSelector(state => state.projectReducer);
const groupBy = useAppSelector(state => state.taskReducer.groupBy);
const dispatch = useAppDispatch();
const { socket } = useSocket();
const currentSession = useAuthService().getCurrentSession();
@@ -176,7 +169,7 @@ const TaskListTable = ({
);
},
task: () => (
<Flex align="center" className={isSubtask ? "pl-6" : "pl-2"}>
<Flex align="center" className={isSubtask ? 'pl-6' : 'pl-2'}>
{task.name}
</Flex>
),
@@ -195,69 +188,74 @@ const TaskListTable = ({
}, []);
// Handle drag end with socket integration
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
document.body.style.cursor = '';
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
setActiveId(null);
document.body.style.cursor = '';
const activeIndex = mainTasks.findIndex(task => task.id === active.id);
const overIndex = mainTasks.findIndex(task => task.id === over.id);
if (activeIndex !== -1 && overIndex !== -1) {
const activeTask = mainTasks[activeIndex];
const overTask = mainTasks[overIndex];
// Create updated task arrays
const updatedTasks = [...mainTasks];
updatedTasks.splice(activeIndex, 1);
updatedTasks.splice(overIndex, 0, activeTask);
// Dispatch Redux action for optimistic update
dispatch(reorderTasks({
activeGroupId: tableId,
overGroupId: tableId,
fromIndex: activeIndex,
toIndex: overIndex,
task: activeTask,
updatedSourceTasks: updatedTasks,
updatedTargetTasks: updatedTasks,
}));
// Emit socket event for backend persistence
if (socket && projectId && currentSession?.team_id) {
const toPos = overTask?.sort_order || mainTasks[mainTasks.length - 1]?.sort_order || -1;
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
project_id: projectId,
from_index: activeTask.sort_order,
to_index: toPos,
to_last_index: overIndex === mainTasks.length - 1,
from_group: tableId,
to_group: tableId,
group_by: groupBy,
task: activeTask,
team_id: currentSession.team_id,
});
// Track analytics event
trackMixpanelEvent(evt_project_task_list_drag_and_move);
if (!over || active.id === over.id) {
return;
}
}
}, [
mainTasks,
tableId,
dispatch,
socket,
projectId,
currentSession?.team_id,
groupBy,
trackMixpanelEvent
]);
const activeIndex = mainTasks.findIndex(task => task.id === active.id);
const overIndex = mainTasks.findIndex(task => task.id === over.id);
if (activeIndex !== -1 && overIndex !== -1) {
const activeTask = mainTasks[activeIndex];
const overTask = mainTasks[overIndex];
// Create updated task arrays
const updatedTasks = [...mainTasks];
updatedTasks.splice(activeIndex, 1);
updatedTasks.splice(overIndex, 0, activeTask);
// Dispatch Redux action for optimistic update
dispatch(
reorderTasks({
activeGroupId: tableId,
overGroupId: tableId,
fromIndex: activeIndex,
toIndex: overIndex,
task: activeTask,
updatedSourceTasks: updatedTasks,
updatedTargetTasks: updatedTasks,
})
);
// Emit socket event for backend persistence
if (socket && projectId && currentSession?.team_id) {
const toPos = overTask?.sort_order || mainTasks[mainTasks.length - 1]?.sort_order || -1;
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
project_id: projectId,
from_index: activeTask.sort_order,
to_index: toPos,
to_last_index: overIndex === mainTasks.length - 1,
from_group: tableId,
to_group: tableId,
group_by: groupBy,
task: activeTask,
team_id: currentSession.team_id,
});
// Track analytics event
trackMixpanelEvent(evt_project_task_list_drag_and_move);
}
}
},
[
mainTasks,
tableId,
dispatch,
socket,
projectId,
currentSession?.team_id,
groupBy,
trackMixpanelEvent,
]
);
// Memoize header rendering
const TableHeader = useMemo(
@@ -291,15 +289,14 @@ const TaskListTable = ({
const activeTask = activeId ? flattenedTasks.find(task => task.id === activeId) : null;
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div ref={parentRef} className="h-[400px] overflow-auto" onScroll={handleScroll}>
{TableHeader}
<SortableContext items={mainTasks.map(task => task.id)} strategy={verticalListSortingStrategy}>
<SortableContext
items={mainTasks.map(task => task.id)}
strategy={verticalListSortingStrategy}
>
<div ref={tableRef} style={{ width: '100%' }}>
{flattenedTasks.map((task, index) => (
<DraggableRow

View File

@@ -40,10 +40,7 @@ const ProjectViewTaskList = () => {
return (
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
<TaskListBoard
projectId={projectId}
className="task-list-board"
/>
<TaskListBoard projectId={projectId} className="task-list-board" />
</Flex>
);
};

View File

@@ -28,10 +28,7 @@ const MembersFilterDropdown = () => {
const { t } = useTranslation('task-list-filters');
const membersList = [
...members,
useAppSelector(state => state.memberReducer.owner),
];
const membersList = [...members, useAppSelector(state => state.memberReducer.owner)];
const themeMode = useAppSelector(state => state.themeReducer.mode);

View File

@@ -24,7 +24,7 @@ const ShowFieldsFilterDropdown = () => {
key: col.key,
columnHeader: col.custom_column_obj.columnHeader,
isCustomColumn: col.custom_column,
}))
})),
];
const columnsVisibility = useAppSelector(
@@ -77,7 +77,7 @@ const ShowFieldsFilterDropdown = () => {
}
/>
{col.custom_column
? col.columnHeader
? col.columnHeader
: t(col.key === 'phases' ? 'phasesText' : `${col.columnHeader}Text`)}
</Space>
</List.Item>

View File

@@ -50,7 +50,9 @@ const TaskListTable = ({
const selectedProject = useSelectedProject();
// get columns list details
const columnsVisibility = useAppSelector( state => state.projectViewTaskListColumnsReducer.columnList );
const columnsVisibility = useAppSelector(
state => state.projectViewTaskListColumnsReducer.columnList
);
const visibleColumns = columnList.filter(
column => columnsVisibility[column.key as keyof typeof columnsVisibility]
);

View File

@@ -1,4 +1,17 @@
import { Button, ConfigProvider, Flex, Form, Mentions, Skeleton, Space, Tooltip, Typography, Dropdown, Menu, Popconfirm } from 'antd';
import {
Button,
ConfigProvider,
Flex,
Form,
Mentions,
Skeleton,
Space,
Tooltip,
Typography,
Dropdown,
Menu,
Popconfirm,
} from 'antd';
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import DOMPurify from 'dompurify';
@@ -24,13 +37,10 @@ const MAX_COMMENT_LENGTH = 2000;
const urlRegex = /((https?:\/\/|www\.)[\w\-._~:/?#[\]@!$&'()*+,;=%]+)/gi;
function linkify(text: string): string {
return text.replace(
urlRegex,
url => {
const href = url.startsWith('http') ? url : `https://${url}`;
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${url}</a>`;
}
);
return text.replace(urlRegex, url => {
const href = url.startsWith('http') ? url : `https://${url}`;
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${url}</a>`;
});
}
const ProjectViewUpdates = () => {
@@ -54,7 +64,14 @@ const ProjectViewUpdates = () => {
if (!projectId) return;
try {
setIsLoading(true);
const res = await projectCommentsApiService.getMentionMembers(projectId, 1, 15, null, null, null);
const res = await projectCommentsApiService.getMentionMembers(
projectId,
1,
15,
null,
null,
null
);
if (res.done) {
setMembers(res.body as IMentionMemberViewModel[]);
}
@@ -85,7 +102,7 @@ const ProjectViewUpdates = () => {
try {
setIsSubmitting(true);
if (!commentValue) {
console.error('Comment content is empty');
return;
@@ -95,7 +112,7 @@ const ProjectViewUpdates = () => {
project_id: projectId,
team_id: getUserSession()?.team_id,
content: commentValue.trim(),
mentions: selectedMembers
mentions: selectedMembers,
};
const res = await projectCommentsApiService.createProjectComment(body);
@@ -107,8 +124,11 @@ const ProjectViewUpdates = () => {
created_by: getUserSession()?.name || '',
created_at: new Date().toISOString(),
content: commentValue.trim(),
mentions: (res.body as IProjectUpdateCommentViewModel).mentions ?? [undefined, undefined],
}
mentions: (res.body as IProjectUpdateCommentViewModel).mentions ?? [
undefined,
undefined,
],
},
]);
handleCancel();
}
@@ -132,16 +152,18 @@ const ProjectViewUpdates = () => {
setSelectedMembers([]);
}, [form]);
const mentionsOptions = useMemo(() =>
members?.map(member => ({
value: member.id,
label: member.name,
})) ?? [], [members]
const mentionsOptions = useMemo(
() =>
members?.map(member => ({
value: member.id,
label: member.name,
})) ?? [],
[members]
);
const memberSelectHandler = useCallback((member: IMentionMemberSelectOption) => {
if (!member?.value || !member?.label) return;
setSelectedMembers(prev =>
prev.some(mention => mention.id === member.value)
? prev
@@ -188,30 +210,36 @@ const ProjectViewUpdates = () => {
}
}, []);
const configProviderTheme = useMemo(() => ({
components: {
Button: {
defaultColor: colors.lightGray,
defaultHoverColor: colors.darkGray,
const configProviderTheme = useMemo(
() => ({
components: {
Button: {
defaultColor: colors.lightGray,
defaultHoverColor: colors.darkGray,
},
},
},
}), []);
}),
[]
);
// Context menu for each comment (memoized)
const getCommentMenu = useCallback((commentId: string) => (
<Menu>
<Menu.Item key="delete">
<Popconfirm
title="Are you sure you want to delete this comment?"
onConfirm={() => handleDeleteComment(commentId)}
okText="Yes"
cancelText="No"
>
Delete
</Popconfirm>
</Menu.Item>
</Menu>
), [handleDeleteComment]);
const getCommentMenu = useCallback(
(commentId: string) => (
<Menu>
<Menu.Item key="delete">
<Popconfirm
title="Are you sure you want to delete this comment?"
onConfirm={() => handleDeleteComment(commentId)}
okText="Yes"
cancelText="No"
>
Delete
</Popconfirm>
</Menu.Item>
</Menu>
),
[handleDeleteComment]
);
const renderComment = useCallback(
(comment: IProjectUpdateCommentViewModel) => {
@@ -224,7 +252,7 @@ const ProjectViewUpdates = () => {
<Dropdown
key={comment.id ?? ''}
overlay={getCommentMenu(comment.id ?? '')}
trigger={["contextMenu"]}
trigger={['contextMenu']}
>
<div>
<Flex gap={8}>
@@ -240,7 +268,10 @@ const ProjectViewUpdates = () => {
</Typography.Text>
</Tooltip>
</Space>
<Typography.Paragraph style={{ margin: '8px 0' }} ellipsis={{ rows: 3, expandable: true }}>
<Typography.Paragraph
style={{ margin: '8px 0' }}
ellipsis={{ rows: 3, expandable: true }}
>
<div
className={`mentions-${themeClass}`}
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
@@ -256,18 +287,12 @@ const ProjectViewUpdates = () => {
[theme, configProviderTheme, handleDeleteComment, handleCommentLinkClick]
);
const commentsList = useMemo(() =>
comments.map(renderComment), [comments, renderComment]
);
const commentsList = useMemo(() => comments.map(renderComment), [comments, renderComment]);
return (
<Flex gap={24} vertical>
<Flex vertical gap={16}>
{isLoadingComments ? (
<Skeleton active />
) : (
commentsList
)}
{isLoadingComments ? <Skeleton active /> : commentsList}
</Flex>
<Form onFinish={handleAddComment}>