expand sub tasks
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user