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

@@ -105,4 +105,4 @@
margin-top: 8px;
width: 100%;
text-align: center;
}
}

View File

@@ -85,7 +85,7 @@ const createFilters = (items: { id: string; name: string }[]) =>
const ProjectList: React.FC = () => {
const [filteredInfo, setFilteredInfo] = useState<Record<string, FilterValue | null>>({});
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation('all-project-list');
const dispatch = useAppDispatch();
const navigate = useNavigate();
@@ -94,14 +94,14 @@ const ProjectList: React.FC = () => {
const { trackMixpanelEvent } = useMixpanelTracking();
// Get view state from Redux
const { mode: viewMode, groupBy } = useAppSelector((state) => state.projectViewReducer);
const { requestParams, groupedRequestParams, groupedProjects } = useAppSelector(state => state.projectsReducer);
const { mode: viewMode, groupBy } = useAppSelector(state => state.projectViewReducer);
const { requestParams, groupedRequestParams, groupedProjects } = useAppSelector(
state => state.projectsReducer
);
const { projectStatuses } = useAppSelector(state => state.projectStatusesReducer);
const { projectHealths } = useAppSelector(state => state.projectHealthReducer);
const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer);
const { filteredCategories, filteredStatuses } = useAppSelector(
state => state.projectsReducer
);
const { filteredCategories, filteredStatuses } = useAppSelector(state => state.projectsReducer);
const {
data: projectsData,
@@ -175,18 +175,20 @@ const ProjectList: React.FC = () => {
);
// Memoize category filters to prevent unnecessary recalculations
const categoryFilters = useMemo(() =>
createFilters(
projectCategories.map(category => ({ id: category.id || '', name: category.name || '' }))
),
const categoryFilters = useMemo(
() =>
createFilters(
projectCategories.map(category => ({ id: category.id || '', name: category.name || '' }))
),
[projectCategories]
);
// Memoize status filters to prevent unnecessary recalculations
const statusFilters = useMemo(() =>
createFilters(
projectStatuses.map(status => ({ id: status.id || '', name: status.name || '' }))
),
const statusFilters = useMemo(
() =>
createFilters(
projectStatuses.map(status => ({ id: status.id || '', name: status.name || '' }))
),
[projectStatuses]
);
@@ -221,39 +223,36 @@ const ProjectList: React.FC = () => {
if (viewMode === ProjectViewType.LIST) {
return projectsData?.body?.total || 0;
} else {
return groupedProjects.data?.data?.reduce((total, group) => total + group.project_count, 0) || 0;
return (
groupedProjects.data?.data?.reduce((total, group) => total + group.project_count, 0) || 0
);
}
}, [viewMode, projectsData?.body?.total, groupedProjects.data?.data]);
// Memoize the grouped projects data transformation
const transformedGroupedProjects = useMemo(() => {
return groupedProjects.data?.data?.map(group => ({
groupKey: group.group_key,
groupName: group.group_name,
groupColor: group.group_color,
projects: group.projects,
count: group.project_count,
totalProgress: 0,
totalTasks: 0
})) || [];
return (
groupedProjects.data?.data?.map(group => ({
groupKey: group.group_key,
groupName: group.group_name,
groupColor: group.group_color,
projects: group.projects,
count: group.project_count,
totalProgress: 0,
totalTasks: 0,
})) || []
);
}, [groupedProjects.data?.data]);
// Memoize the table data source
const tableDataSource = useMemo(() =>
projectsData?.body?.data || [],
[projectsData?.body?.data]
);
const tableDataSource = useMemo(() => projectsData?.body?.data || [], [projectsData?.body?.data]);
// Memoize the empty text component
const emptyText = useMemo(() =>
<Empty description={t('noProjects')} />,
[t]
);
const emptyText = useMemo(() => <Empty description={t('noProjects')} />, [t]);
// Memoize the pagination show total function
const paginationShowTotal = useMemo(() =>
(total: number, range: [number, number]) =>
`${range[0]}-${range[1]} of ${total} groups`,
const paginationShowTotal = useMemo(
() => (total: number, range: [number, number]) => `${range[0]}-${range[1]} of ${total} groups`,
[]
);
@@ -291,18 +290,20 @@ const ProjectList: React.FC = () => {
newParams.size = newPagination.pageSize || DEFAULT_PAGE_SIZE;
dispatch(setRequestParams(newParams));
// Also update grouped request params to keep them in sync
dispatch(setGroupedRequestParams({
...groupedRequestParams,
statuses: newParams.statuses,
categories: newParams.categories,
order: newParams.order,
field: newParams.field,
index: newParams.index,
size: newParams.size,
}));
dispatch(
setGroupedRequestParams({
...groupedRequestParams,
statuses: newParams.statuses,
categories: newParams.categories,
order: newParams.order,
field: newParams.field,
index: newParams.index,
size: newParams.size,
})
);
setFilteredInfo(filters);
},
[dispatch, setSortingValues, groupedRequestParams]
@@ -332,24 +333,28 @@ const ProjectList: React.FC = () => {
(value: IProjectFilter) => {
const newFilterIndex = filters.indexOf(value);
setFilterIndex(newFilterIndex);
// Update both request params for consistency
dispatch(setRequestParams({ filter: newFilterIndex }));
dispatch(setGroupedRequestParams({
...groupedRequestParams,
filter: newFilterIndex,
index: 1 // Reset to first page when changing filter
}));
dispatch(
setGroupedRequestParams({
...groupedRequestParams,
filter: newFilterIndex,
index: 1, // Reset to first page when changing filter
})
);
// Refresh data based on current view mode
if (viewMode === ProjectViewType.LIST) {
refetchProjects();
} else if (viewMode === ProjectViewType.GROUP && groupBy) {
dispatch(fetchGroupedProjects({
...groupedRequestParams,
filter: newFilterIndex,
index: 1
}));
dispatch(
fetchGroupedProjects({
...groupedRequestParams,
filter: newFilterIndex,
index: 1,
})
);
}
},
[filters, setFilterIndex, dispatch, refetchProjects, viewMode, groupBy, groupedRequestParams]
@@ -369,18 +374,18 @@ const ProjectList: React.FC = () => {
(e: React.ChangeEvent<HTMLInputElement>) => {
const searchValue = e.target.value;
trackMixpanelEvent(evt_projects_search);
// Update both request params for consistency
dispatch(setRequestParams({ search: searchValue, index: 1 }));
if (viewMode === ProjectViewType.GROUP) {
const newGroupedParams = {
...groupedRequestParams,
search: searchValue,
index: 1
search: searchValue,
index: 1,
};
dispatch(setGroupedRequestParams(newGroupedParams));
// Trigger debounced search in group mode
debouncedGroupedSearch(newGroupedParams);
}
@@ -429,13 +434,16 @@ const ProjectList: React.FC = () => {
dispatch(setProjectId(null));
}, [dispatch]);
const navigateToProject = useCallback((project_id: string | undefined, default_view: string | undefined) => {
if (project_id) {
navigate(
`/worklenz/projects/${project_id}?tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}&pinned_tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}`
);
}
}, [navigate]);
const navigateToProject = useCallback(
(project_id: string | undefined, default_view: string | undefined) => {
if (project_id) {
navigate(
`/worklenz/projects/${project_id}?tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}&pinned_tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}`
);
}
},
[navigate]
);
// Preload project view components on hover for smoother navigation
const handleProjectHover = useCallback((project_id: string | undefined) => {
@@ -444,7 +452,7 @@ const ProjectList: React.FC = () => {
import('@/pages/projects/projectView/project-view').catch(() => {
// Silently fail if preload doesn't work
});
// Also preload critical task management components
import('@/components/task-management/task-list-board').catch(() => {
// Silently fail if preload doesn't work
@@ -536,7 +544,17 @@ const ProjectList: React.FC = () => {
),
},
],
[t, categoryFilters, statusFilters, filteredInfo, filteredCategories, filteredStatuses, navigate, dispatch, isOwnerOrAdmin]
[
t,
categoryFilters,
statusFilters,
filteredInfo,
filteredCategories,
filteredStatuses,
navigate,
dispatch,
isOwnerOrAdmin,
]
);
useEffect(() => {
@@ -551,17 +569,19 @@ const ProjectList: React.FC = () => {
const filterIndex = getFilterIndex();
dispatch(setRequestParams({ filter: filterIndex }));
// Also sync with grouped request params on initial load
dispatch(setGroupedRequestParams({
filter: filterIndex,
index: 1,
size: DEFAULT_PAGE_SIZE,
field: 'name',
order: 'ascend',
search: '',
groupBy: '',
statuses: null,
categories: null,
}));
dispatch(
setGroupedRequestParams({
filter: filterIndex,
index: 1,
size: DEFAULT_PAGE_SIZE,
field: 'name',
order: 'ascend',
search: '',
groupBy: '',
statuses: null,
categories: null,
})
);
}, [dispatch, getFilterIndex]);
useEffect(() => {
@@ -605,11 +625,7 @@ const ProjectList: React.FC = () => {
defaultValue={filters[getFilterIndex()] ?? filters[0]}
onChange={handleSegmentChange}
/>
<Segmented
options={viewToggleOptions}
value={viewMode}
onChange={handleViewToggle}
/>
<Segmented options={viewToggleOptions} value={viewMode} onChange={handleViewToggle} />
{viewMode === ProjectViewType.GROUP && (
<Select
value={groupBy}
@@ -658,15 +674,19 @@ const ProjectList: React.FC = () => {
loading={groupedProjects.loading}
t={t}
/>
{!groupedProjects.loading && groupedProjects.data?.data && groupedProjects.data.data.length > 0 && (
<div style={{ marginTop: '24px', textAlign: 'center' }}>
<Pagination
{...groupedPaginationConfig}
onChange={(page, pageSize) => handleGroupedTableChange({ current: page, pageSize })}
showTotal={paginationShowTotal}
/>
</div>
)}
{!groupedProjects.loading &&
groupedProjects.data?.data &&
groupedProjects.data.data.length > 0 && (
<div style={{ marginTop: '24px', textAlign: 'center' }}>
<Pagination
{...groupedPaginationConfig}
onChange={(page, pageSize) =>
handleGroupedTableChange({ current: page, pageSize })
}
showTotal={paginationShowTotal}
/>
</div>
)}
</div>
)}
</Skeleton>
@@ -677,4 +697,4 @@ const ProjectList: React.FC = () => {
);
};
export default ProjectList;
export default ProjectList;

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}>

View File

@@ -17,30 +17,32 @@ 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 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;
};
@@ -48,14 +50,14 @@ const BoardCreateSectionCard = () => {
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'
const todoCategory = statusCategories.find(
category =>
category.name?.toLowerCase() === 'to do' || category.name?.toLowerCase() === 'todo'
);
if (todoCategory && todoCategory.id) {
// Create a new status
const body = {
@@ -63,21 +65,25 @@ const BoardCreateSectionCard = () => {
project_id: projectId,
category_id: todoCategory.id,
};
try {
// Create the status
const response = await dispatch(createStatus({ body, currentProjectId: projectId })).unwrap();
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,
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
@@ -97,7 +103,7 @@ const BoardCreateSectionCard = () => {
})
);
}
}
}
if (groupBy === IGroupBy.PHASE && projectId) {
const body = {
@@ -105,7 +111,7 @@ const BoardCreateSectionCard = () => {
project_id: projectId,
};
try {
try {
const response = await phasesApiService.addPhaseOption(projectId);
if (response.done && response.body) {
dispatch(fetchBoardTaskGroups(projectId));

View File

@@ -50,7 +50,10 @@ import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.t
import { update } from 'lodash';
import logger from '@/utils/errorLogger';
import { toggleDrawer } from '@/features/projects/status/StatusSlice';
import { deleteStatusToggleDrawer, seletedStatusCategory } from '@/features/projects/status/DeleteStatusSlice';
import {
deleteStatusToggleDrawer,
seletedStatusCategory,
} from '@/features/projects/status/DeleteStatusSlice';
interface BoardSectionCardHeaderProps {
groupId: string;
@@ -110,20 +113,20 @@ const BoardSectionCardHeader: React.FC<BoardSectionCardHeaderProps> = ({
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;
};
@@ -198,11 +201,18 @@ const BoardSectionCardHeader: React.FC<BoardSectionCardHeaderProps> = ({
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.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(
seletedStatusCategory({
id: groupId,
name: name,
category_id: categoryId ?? '',
message: res.message ?? '',
})
);
dispatch(deleteStatusToggleDrawer());
}
} else if (groupBy === IGroupBy.PHASE) {
@@ -373,5 +383,3 @@ const BoardSectionCardHeader: React.FC<BoardSectionCardHeaderProps> = ({
};
export default BoardSectionCardHeader;

View File

@@ -108,7 +108,7 @@ const BoardSectionCardContainer = ({
{datasource?.map((data: any) => <BoardSectionCard key={data.id} taskGroup={data} />)}
</SortableContext>
{(group !== 'priority' && (isOwnerorAdmin || isProjectManager)) && <BoardCreateSectionCard />}
{group !== 'priority' && (isOwnerorAdmin || isProjectManager) && <BoardCreateSectionCard />}
</Flex>
);
};

View File

@@ -85,7 +85,7 @@ const BoardCreateSubtaskCard = ({
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
@@ -96,16 +96,19 @@ const BoardCreateSubtaskCard = ({
}, 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));
});
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) {

View File

@@ -1,7 +1,12 @@
import { useCallback, useState } from 'react';
import dayjs, { Dayjs } from 'dayjs';
import { Col, Flex, Typography, List, Dropdown, MenuProps, Popconfirm } from 'antd';
import { UserAddOutlined, DeleteOutlined, ExclamationCircleFilled, InboxOutlined } from '@ant-design/icons';
import {
UserAddOutlined,
DeleteOutlined,
ExclamationCircleFilled,
InboxOutlined,
} from '@ant-design/icons';
import CustomAvatarGroup from '@/components/board/custom-avatar-group';
import CustomDueDatePicker from '@/components/board/custom-due-date-picker';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
@@ -97,7 +102,10 @@ const BoardSubTaskCard = ({ subtask, sectionId }: IBoardSubTaskCardProps) => {
if (!projectId || !subtask.id) return;
try {
const res = await taskListBulkActionsApiService.deleteTasks({ tasks: [subtask.id] }, projectId);
const res = await taskListBulkActionsApiService.deleteTasks(
{ tasks: [subtask.id] },
projectId
);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_context_menu_delete);
dispatch(deleteBoardTask({ sectionId, taskId: subtask.id }));

View File

@@ -62,7 +62,7 @@ const BoardViewCreateTaskCard = ({
const createRequestBody = (): ITaskCreateRequest | null => {
if (!projectId || !currentSession) return null;
const body: ITaskCreateRequest = {
project_id: projectId,
name: newTaskName.trim(),
@@ -108,7 +108,7 @@ const BoardViewCreateTaskCard = ({
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({
@@ -121,17 +121,17 @@ const BoardViewCreateTaskCard = ({
},
})
);
// 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) {
@@ -152,7 +152,7 @@ const BoardViewCreateTaskCard = ({
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({
@@ -165,17 +165,17 @@ const BoardViewCreateTaskCard = ({
},
})
);
// 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) {
@@ -225,15 +225,12 @@ const BoardViewCreateTaskCard = ({
/>
{newTaskName.trim() && (
<Flex gap={8} justify="flex-end">
<Button
size="small"
onClick={() => setShowNewCard(false)}
>
<Button size="small" onClick={() => setShowNewCard(false)}>
{t('cancel')}
</Button>
<Button
type="primary"
size="small"
<Button
type="primary"
size="small"
onClick={position === 'bottom' ? handleAddTaskToTheBottom : handleAddTaskToTheTop}
loading={creatingTask}
>

View File

@@ -86,27 +86,33 @@ const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
},
});
const style = useMemo(() => ({
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}), [transform, transition, isDragging]);
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();
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;
// 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);
// 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]);
return () => clearTimeout(clickTimeout);
},
[dispatch, isDragging]
);
const handleAssignToMe = useCallback(async () => {
if (!projectId || !task.id || updatingAssignToMe) return;
@@ -189,54 +195,58 @@ const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
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,
const handleSubtaskButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
handleSubTaskExpand();
},
{
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]);
[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 renderLabels = useMemo(() => {
if (!task?.labels?.length) return null;
@@ -245,9 +255,7 @@ const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
<>
{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>
<span style={{ color: themeMode === 'dark' ? '#383838' : '' }}>{label.name}</span>
</Tag>
))}
{task.labels.length > 2 && <Tag>+ {task.labels.length - 2}</Tag>}
@@ -273,29 +281,28 @@ const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
}}
className={`group outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline-solid board-task-card`}
data-id={task.id}
data-dragging={isDragging ? "true" : "false"}
data-dragging={isDragging ? 'true' : 'false'}
>
<Dropdown menu={{ items }} trigger={['contextMenu']}>
{/* Task Card */}
<Flex vertical gap={8}
onClick={e => handleCardClick(e, task.id || '')}>
<Flex vertical gap={8} onClick={e => handleCardClick(e, task.id || '')}>
{/* Labels and Progress */}
<Flex align="center" justify="space-between">
<Flex>
{renderLabels}
</Flex>
<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} />
<Progress
type="circle"
percent={task?.complete_ratio}
size={26}
strokeWidth={(task.complete_ratio || 0) >= 100 ? 9 : 7}
/>
</Tooltip>
</Flex>
<Flex gap={4} align="center">
{/* Action Icons */}
<PrioritySection task={task} />
<Typography.Text
style={{ fontWeight: 500 }}
ellipsis={{ tooltip: task.name }}
>
<Typography.Text style={{ fontWeight: 500 }} ellipsis={{ tooltip: task.name }}>
{task.name}
</Typography.Text>
</Flex>
@@ -350,7 +357,8 @@ const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
</List.Item>
)}
{!task.sub_tasks_loading && task?.sub_tasks &&
{!task.sub_tasks_loading &&
task?.sub_tasks &&
task?.sub_tasks.map((subtask: any) => (
<BoardSubTaskCard key={subtask.id} subtask={subtask} sectionId={sectionId} />
))}
@@ -379,7 +387,6 @@ const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
)}
</Flex>
</Flex>
);
};

View File

@@ -36,7 +36,10 @@ 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 {
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';
@@ -66,7 +69,9 @@ const ProjectViewBoard = () => {
const [isLoading, setIsLoading] = useState(true);
const { projectId } = useAppSelector(state => state.projectReducer);
const { taskGroups, groupBy, loadingGroups, search, archived } = useAppSelector(state => state.boardReducer);
const { taskGroups, groupBy, loadingGroups, search, archived } = useAppSelector(
state => state.boardReducer
);
const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
state => state.taskStatusReducer
);
@@ -140,9 +145,7 @@ const ProjectViewBoard = () => {
// Start by finding any intersecting droppable
const pointerIntersections = pointerWithin(args);
const intersections =
pointerIntersections.length > 0
? pointerIntersections
: rectIntersection(args);
pointerIntersections.length > 0 ? pointerIntersections : rectIntersection(args);
let overId = getFirstCollision(intersections, 'id');
if (overId !== null) {
@@ -151,17 +154,14 @@ const ProjectViewBoard = () => {
);
if (overContainer?.data.current?.type === 'section') {
const containerItems = taskGroups.find(
(group) => group.id === overId
)?.tasks || [];
const containerItems = taskGroups.find(group => group.id === overId)?.tasks || [];
if (containerItems.length > 0) {
overId = closestCenter({
...args,
droppableContainers: args.droppableContainers.filter(
(container: DroppableContainer) =>
container.id !== overId &&
container.data.current?.type === 'task'
container.id !== overId && container.data.current?.type === 'task'
),
})[0]?.id;
}
@@ -193,16 +193,19 @@ const ProjectViewBoard = () => {
// Debounced move task function to prevent rapid updates
const debouncedMoveTask = useCallback(
debounce((taskId: string, sourceGroupId: string, targetGroupId: string, targetIndex: number) => {
dispatch(
moveTaskBetweenGroups({
taskId,
sourceGroupId,
targetGroupId,
targetIndex,
})
);
}, 100),
debounce(
(taskId: string, sourceGroupId: string, targetGroupId: string, targetIndex: number) => {
dispatch(
moveTaskBetweenGroups({
taskId,
sourceGroupId,
targetGroupId,
targetIndex,
})
);
},
100
),
[dispatch]
);
@@ -241,11 +244,7 @@ const ProjectViewBoard = () => {
const overGroupId = findGroupForId(overId as string);
// Only move if both groups exist and are different, and the active is a task
if (
activeGroupId &&
overGroupId &&
active.data.current?.type === 'task'
) {
if (activeGroupId && overGroupId && active.data.current?.type === 'task') {
// Find the target index in the over group
const targetGroup = taskGroups.find(g => g.id === overGroupId);
let targetIndex = 0;
@@ -259,12 +258,7 @@ const ProjectViewBoard = () => {
}
}
// Use debounced move task to prevent rapid updates
debouncedMoveTask(
activeId as string,
activeGroupId,
overGroupId,
targetIndex
);
debouncedMoveTask(activeId as string, activeGroupId, overGroupId, targetIndex);
}
} catch (error) {
console.error('handleDragOver error:', error);
@@ -281,9 +275,12 @@ const ProjectViewBoard = () => {
};
socket.emit(SocketEvents.TASK_PRIORITY_CHANGE.toString(), JSON.stringify(payload));
socket.once(SocketEvents.TASK_PRIORITY_CHANGE.toString(), (data: ITaskListPriorityChangeResponse) => {
dispatch(updateBoardTaskPriority(data));
});
socket.once(
SocketEvents.TASK_PRIORITY_CHANGE.toString(),
(data: ITaskListPriorityChangeResponse) => {
dispatch(updateBoardTaskPriority(data));
}
);
};
const handleDragEnd = async (event: DragEndEvent) => {
@@ -373,7 +370,8 @@ const ProjectViewBoard = () => {
}
// Calculate toPos similar to Angular implementation
const toPos = targetGroup.tasks[toIndex]?.sort_order ||
const toPos =
targetGroup.tasks[toIndex]?.sort_order ||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
-1;
@@ -387,7 +385,7 @@ const ProjectViewBoard = () => {
to_group: targetGroupId,
group_by: groupBy || 'status',
task,
team_id: currentSession?.team_id
team_id: currentSession?.team_id,
};
// Emit socket event
@@ -428,7 +426,8 @@ const ProjectViewBoard = () => {
}
// Calculate toPos similar to Angular implementation
const toPos = targetGroup.tasks[toIndex]?.sort_order ||
const toPos =
targetGroup.tasks[toIndex]?.sort_order ||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
-1;
// Prepare socket event payload
@@ -438,10 +437,10 @@ const ProjectViewBoard = () => {
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
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
team_id: currentSession?.team_id,
};
// Emit socket event
if (socket) {
@@ -488,7 +487,7 @@ const ProjectViewBoard = () => {
try {
// Use the correct API endpoint based on the Angular code
const requestBody: ITaskStatusCreateRequest = {
status_order: columnOrder
status_order: columnOrder,
};
const response = await statusApiService.updateStatusOrder(requestBody, projectId);
@@ -556,7 +555,7 @@ const ProjectViewBoard = () => {
return (
<Flex vertical gap={16}>
<TaskListFilters position={'board'} />
<Skeleton active loading={isLoading} className='mt-4 p-4'>
<Skeleton active loading={isLoading} className="mt-4 p-4">
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}

View File

@@ -6,11 +6,7 @@ const ProjectViewEnhancedBoard: React.FC = () => {
const { project } = useAppSelector(state => state.projectReducer);
if (!project?.id) {
return (
<div className="p-4 text-center text-gray-500">
Project not found
</div>
);
return <div className="p-4 text-center text-gray-500">Project not found</div>;
}
return (
@@ -20,4 +16,4 @@ const ProjectViewEnhancedBoard: React.FC = () => {
);
};
export default ProjectViewEnhancedBoard;
export default ProjectViewEnhancedBoard;

View File

@@ -6,11 +6,7 @@ const ProjectViewEnhancedTasks: React.FC = () => {
const { project } = useAppSelector(state => state.projectReducer);
if (!project?.id) {
return (
<div className="p-4 text-center text-gray-500">
Project not found
</div>
);
return <div className="p-4 text-center text-gray-500">Project not found</div>;
}
return (
@@ -20,4 +16,4 @@ const ProjectViewEnhancedTasks: React.FC = () => {
);
};
export default ProjectViewEnhancedTasks;
export default ProjectViewEnhancedTasks;

View File

@@ -47,7 +47,6 @@ const ProjectViewFiles = () => {
defaultPageSize: DEFAULT_PAGE_SIZE,
});
const fetchAttachments = async () => {
if (!projectId) return;
try {

View File

@@ -41,7 +41,7 @@ const TaskByMembersTable = () => {
useEffect(() => {
getProjectOverviewMembers();
}, [projectId,refreshTimestamp]);
}, [projectId, refreshTimestamp]);
// toggle members row expansions
const toggleRowExpansion = (memberId: string) => {

View File

@@ -17,7 +17,6 @@ const PriorityOverview = () => {
const [loading, setLoading] = useState(false);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const getTaskPriorityCounts = async () => {
if (!projectId) return;

View File

@@ -15,7 +15,6 @@ const StatusOverview = () => {
const [stats, setStats] = useState<ITaskStatusCounts[]>([]);
const [loading, setLoading] = useState(false);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const getTaskStatusCounts = async () => {
if (!projectId) return;
@@ -38,7 +37,7 @@ const StatusOverview = () => {
useEffect(() => {
getTaskStatusCounts();
}, [projectId, includeArchivedTasks,refreshTimestamp]);
}, [projectId, includeArchivedTasks, refreshTimestamp]);
const options: ChartOptions<'doughnut'> = {
responsive: true,

View File

@@ -18,7 +18,6 @@ const LastUpdatedTasks = () => {
const [loading, setLoading] = useState(false);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const getLastUpdatedTasks = async () => {
if (!projectId) return;
setLoading(true);
@@ -39,7 +38,7 @@ const LastUpdatedTasks = () => {
useEffect(() => {
getLastUpdatedTasks();
}, [projectId, includeArchivedTasks,refreshTimestamp]);
}, [projectId, includeArchivedTasks, refreshTimestamp]);
// table columns
const columns: TableProps['columns'] = [

View File

@@ -37,7 +37,7 @@ const ProjectDeadline = () => {
useEffect(() => {
getProjectDeadline();
}, [projectId, includeArchivedTasks,refreshTimestamp]);
}, [projectId, includeArchivedTasks, refreshTimestamp]);
// table columns
const columns: TableProps['columns'] = [

View File

@@ -15,7 +15,6 @@ const OverLoggedTasksTable = () => {
const [loading, setLoading] = useState(true);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const getOverLoggedTasks = async () => {
try {
setLoading(true);
@@ -35,7 +34,7 @@ const OverLoggedTasksTable = () => {
useEffect(() => {
getOverLoggedTasks();
}, [projectId, includeArchivedTasks,refreshTimestamp]);
}, [projectId, includeArchivedTasks, refreshTimestamp]);
// table columns
const columns: TableProps['columns'] = [

View File

@@ -18,7 +18,6 @@ const OverdueTasksTable = ({
const [loading, setLoading] = useState(true);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const getOverdueTasks = async () => {
setLoading(true);
try {
@@ -35,7 +34,7 @@ const OverdueTasksTable = ({
useEffect(() => {
getOverdueTasks();
}, [projectId, includeArchivedTasks,refreshTimestamp]);
}, [projectId, includeArchivedTasks, refreshTimestamp]);
// table columns
const columns: TableProps['columns'] = [

View File

@@ -20,7 +20,6 @@ const TaskCompletedEarlyTable = ({
const [loading, setLoading] = useState(true);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const getEarlyCompletedTasks = async () => {
try {
setLoading(true);

View File

@@ -18,7 +18,6 @@ const TaskCompletedLateTable = ({
const [lateCompletedTaskList, setLateCompletedTaskList] = useState<IInsightTasks[]>([]);
const [loading, setLoading] = useState(true);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const getLateCompletedTasks = async () => {
try {
@@ -39,7 +38,7 @@ const TaskCompletedLateTable = ({
useEffect(() => {
getLateCompletedTasks();
}, [projectId, includeArchivedTasks,refreshTimestamp]);
}, [projectId, includeArchivedTasks, refreshTimestamp]);
// table columns
const columns: TableProps['columns'] = [

View File

@@ -35,7 +35,7 @@ const MemberStats = () => {
useEffect(() => {
fetchMemberStats();
}, [projectId, includeArchivedTasks,refreshTimestamp]);
}, [projectId, includeArchivedTasks, refreshTimestamp]);
return (
<Flex gap={24} className="grid sm:grid-cols-2 sm:grid-rows-2 lg:grid-cols-3 lg:grid-rows-1">

View File

@@ -18,7 +18,6 @@ const ProjectStats = ({ t }: { t: TFunction }) => {
const [loading, setLoading] = useState(false);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const getProjectStats = async () => {
if (!projectId) return;
@@ -40,7 +39,7 @@ const ProjectStats = ({ t }: { t: TFunction }) => {
useEffect(() => {
getProjectStats();
}, [projectId, includeArchivedTasks,refreshTimestamp]);
}, [projectId, includeArchivedTasks, refreshTimestamp]);
const tooltipTable = (
<table>

View File

@@ -18,7 +18,11 @@ import { format } from 'date-fns';
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
import logo from '@/assets/images/worklenz-light-mode.png';
import { evt_project_insights_members_visit, evt_project_insights_overview_visit, evt_project_insights_tasks_visit } from '@/shared/worklenz-analytics-events';
import {
evt_project_insights_members_visit,
evt_project_insights_overview_visit,
evt_project_insights_tasks_visit,
} from '@/shared/worklenz-analytics-events';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
type SegmentType = 'Overview' | 'Members' | 'Tasks';
@@ -35,9 +39,7 @@ const ProjectViewInsights = () => {
const { activeSegment, includeArchivedTasks } = useAppSelector(
state => state.projectInsightsReducer
);
const {
project: selectedProject,
} = useAppSelector(state => state.projectReducer);
const { project: selectedProject } = useAppSelector(state => state.projectReducer);
const handleSegmentChange = (value: SegmentType) => {
dispatch(setActiveSegment(value));
@@ -113,7 +115,7 @@ const ProjectViewInsights = () => {
pdf.save(`${activeSegment} ${format(new Date(), 'yyyy-MM-dd')}.pdf`);
};
logoImg.onerror = (error) => {
logoImg.onerror = error => {
pdf.setFontSize(14);
pdf.setTextColor(0, 0, 0, 0.85);
pdf.text(
@@ -127,11 +129,11 @@ const ProjectViewInsights = () => {
};
};
useEffect(()=>{
if(projectId){
useEffect(() => {
if (projectId) {
dispatch(setActiveSegment('Overview'));
}
},[refreshTimestamp])
}, [refreshTimestamp]);
return (
<Flex vertical gap={24}>
@@ -169,9 +171,7 @@ const ProjectViewInsights = () => {
</Button>
</Flex>
</Flex>
<div ref={exportRef}>
{renderSegmentContent()}
</div>
<div ref={exportRef}>{renderSegmentContent()}</div>
</Flex>
);
};

View File

@@ -60,7 +60,7 @@ const ProjectViewMembers = () => {
const { trackMixpanelEvent } = useMixpanelTracking();
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
// State
const [isLoading, setIsLoading] = useState(false);
const [members, setMembers] = useState<IProjectMembersViewModel>();
@@ -138,7 +138,14 @@ const ProjectViewMembers = () => {
// Effects
useEffect(() => {
void getProjectMembers();
}, [refreshTimestamp, projectId, pagination.current, pagination.pageSize, pagination.field, pagination.order]);
}, [
refreshTimestamp,
projectId,
pagination.current,
pagination.pageSize,
pagination.field,
pagination.order,
]);
useEffect(() => {
trackMixpanelEvent(evt_project_members_visit);
@@ -151,9 +158,13 @@ const ProjectViewMembers = () => {
title: t('nameColumn'),
dataIndex: 'name',
sorter: true,
sortOrder: pagination.order === 'ascend' && pagination.field === 'name' ? 'ascend' :
pagination.order === 'descend' && pagination.field === 'name' ? 'descend' : null,
render: (_,record: IProjectMemberViewModel) => (
sortOrder:
pagination.order === 'ascend' && pagination.field === 'name'
? 'ascend'
: pagination.order === 'descend' && pagination.field === 'name'
? 'descend'
: null,
render: (_, record: IProjectMemberViewModel) => (
<Flex gap={8} align="center">
<Avatar size={28} src={record.avatar_url}>
{record.name?.charAt(0)}
@@ -167,8 +178,12 @@ const ProjectViewMembers = () => {
title: t('jobTitleColumn'),
dataIndex: 'job_title',
sorter: true,
sortOrder: pagination.order === 'ascend' && pagination.field === 'job_title' ? 'ascend' :
pagination.order === 'descend' && pagination.field === 'job_title' ? 'descend' : null,
sortOrder:
pagination.order === 'ascend' && pagination.field === 'job_title'
? 'ascend'
: pagination.order === 'descend' && pagination.field === 'job_title'
? 'descend'
: null,
render: (_, record: IProjectMemberViewModel) => (
<Typography.Text style={{ marginInlineStart: 12 }}>
{record?.job_title || '-'}
@@ -180,8 +195,12 @@ const ProjectViewMembers = () => {
title: t('emailColumn'),
dataIndex: 'email',
sorter: true,
sortOrder: pagination.order === 'ascend' && pagination.field === 'email' ? 'ascend' :
pagination.order === 'descend' && pagination.field === 'email' ? 'descend' : null,
sortOrder:
pagination.order === 'ascend' && pagination.field === 'email'
? 'ascend'
: pagination.order === 'descend' && pagination.field === 'email'
? 'descend'
: null,
render: (_, record: IProjectMemberViewModel) => (
<Typography.Text>{record.email}</Typography.Text>
),
@@ -210,8 +229,12 @@ const ProjectViewMembers = () => {
title: t('accessColumn'),
dataIndex: 'access',
sorter: true,
sortOrder: pagination.order === 'ascend' && pagination.field === 'access' ? 'ascend' :
pagination.order === 'descend' && pagination.field === 'access' ? 'descend' : null,
sortOrder:
pagination.order === 'ascend' && pagination.field === 'access'
? 'ascend'
: pagination.order === 'descend' && pagination.field === 'access'
? 'descend'
: null,
render: (_, record: IProjectMemberViewModel) => (
<Typography.Text style={{ textTransform: 'capitalize' }}>{record.access}</Typography.Text>
),

View File

@@ -1,9 +1,9 @@
import {
Button,
Dropdown,
Flex,
Tag,
Tooltip,
import {
Button,
Dropdown,
Flex,
Tag,
Tooltip,
Typography,
ArrowLeftOutlined,
BellFilled,
@@ -15,7 +15,7 @@ import {
SaveOutlined,
SettingOutlined,
SyncOutlined,
UsergroupAddOutlined
UsergroupAddOutlined,
} from '@/shared/antd-imports';
import { PageHeader } from '@ant-design/pro-components';
import { useTranslation } from 'react-i18next';
@@ -29,8 +29,18 @@ import { useAppSelector } from '@/hooks/useAppSelector';
import { SocketEvents } from '@/shared/socket-events';
import { useAuthService } from '@/hooks/useAuth';
import { useSocket } from '@/socket/socketContext';
import { setProject, setImportTaskTemplateDrawerOpen, setRefreshTimestamp, getProject } from '@features/project/project.slice';
import { addTask, fetchTaskGroups, fetchTaskListColumns, IGroupBy } from '@features/tasks/tasks.slice';
import {
setProject,
setImportTaskTemplateDrawerOpen,
setRefreshTimestamp,
getProject,
} from '@features/project/project.slice';
import {
addTask,
fetchTaskGroups,
fetchTaskListColumns,
IGroupBy,
} from '@features/tasks/tasks.slice';
import ProjectStatusIcon from '@/components/common/project-status-icon/project-status-icon';
import { formatDate } from '@/utils/timeUtils';
import { toggleSaveAsTemplateDrawer } from '@/features/projects/projectsSlice';
@@ -85,9 +95,9 @@ const ProjectViewHeader = memo(() => {
// Memoized refresh handler with optimized dependencies
const handleRefresh = useCallback(() => {
if (!projectId) return;
dispatch(getProject(projectId));
switch (tab) {
case 'tasks-list':
dispatch(fetchTaskListColumns(projectId));
@@ -110,7 +120,7 @@ const ProjectViewHeader = memo(() => {
// Optimized subscription handler with proper cleanup
const handleSubscribe = useCallback(() => {
if (!selectedProject?.id || !socket || subscriptionLoading) return;
try {
setSubscriptionLoading(true);
const newSubscriptionState = !selectedProject.subscribed;
@@ -131,16 +141,20 @@ const ProjectViewHeader = memo(() => {
// Listen for response with cleanup
const handleResponse = (response: any) => {
try {
dispatch(setProject({
...selectedProject,
subscribed: newSubscriptionState
}));
dispatch(
setProject({
...selectedProject,
subscribed: newSubscriptionState,
})
);
} catch (error) {
logger.error('Error handling project subscription response:', error);
dispatch(setProject({
...selectedProject,
subscribed: selectedProject.subscribed
}));
dispatch(
setProject({
...selectedProject,
subscribed: selectedProject.subscribed,
})
);
} finally {
setSubscriptionLoading(false);
if (subscriptionTimeoutRef.current) {
@@ -158,7 +172,6 @@ const ProjectViewHeader = memo(() => {
logger.error('Project subscription timeout - no response from server');
subscriptionTimeoutRef.current = null;
}, 5000);
} catch (error) {
logger.error('Error updating project subscription:', error);
setSubscriptionLoading(false);
@@ -235,16 +248,19 @@ const ProjectViewHeader = memo(() => {
}, [dispatch]);
// Memoized dropdown items
const dropdownItems = useMemo(() => [
{
key: 'import',
label: (
<div style={{ width: '100%', margin: 0, padding: 0 }} onClick={handleImportTaskTemplate}>
<ImportOutlined /> {t('importTask')}
</div>
),
},
], [handleImportTaskTemplate, t]);
const dropdownItems = useMemo(
() => [
{
key: 'import',
label: (
<div style={{ width: '100%', margin: 0, padding: 0 }} onClick={handleImportTaskTemplate}>
<ImportOutlined /> {t('importTask')}
</div>
),
},
],
[handleImportTaskTemplate, t]
);
// Memoized project attributes with optimized date formatting
const projectAttributes = useMemo(() => {
@@ -254,9 +270,9 @@ const ProjectViewHeader = memo(() => {
if (selectedProject.category_id) {
elements.push(
<Tag
<Tag
key="category"
color={colors.vibrantOrange}
color={colors.vibrantOrange}
style={{ borderRadius: 24, paddingInline: 8, margin: 0 }}
>
{selectedProject.category_name}
@@ -330,11 +346,7 @@ const ProjectViewHeader = memo(() => {
if (isOwnerOrAdmin) {
actions.push(
<Tooltip key="template" title={t('saveAsTemplate')}>
<Button
shape="circle"
icon={<SaveOutlined />}
onClick={handleSaveAsTemplate}
/>
<Button shape="circle" icon={<SaveOutlined />} onClick={handleSaveAsTemplate} />
</Tooltip>
);
}
@@ -363,12 +375,7 @@ const ProjectViewHeader = memo(() => {
// Invite button (owner/admin/project manager only)
if (isOwnerOrAdmin || isProjectManager) {
actions.push(
<Button
key="invite"
type="primary"
icon={<UsergroupAddOutlined />}
onClick={handleInvite}
>
<Button key="invite" type="primary" icon={<UsergroupAddOutlined />} onClick={handleInvite}>
{t('invite')}
</Button>
);
@@ -426,24 +433,27 @@ const ProjectViewHeader = memo(() => {
]);
// Memoized page header title
const pageHeaderTitle = useMemo(() => (
<Flex gap={8} align="center">
<ArrowLeftOutlined
style={{ fontSize: 16 }}
onClick={handleNavigateToProjects}
/>
<Typography.Title level={4} style={{ marginBlockEnd: 0, marginInlineStart: 12 }}>
{selectedProject?.name}
</Typography.Title>
{projectAttributes}
</Flex>
), [handleNavigateToProjects, selectedProject?.name, projectAttributes]);
const pageHeaderTitle = useMemo(
() => (
<Flex gap={8} align="center">
<ArrowLeftOutlined style={{ fontSize: 16 }} onClick={handleNavigateToProjects} />
<Typography.Title level={4} style={{ marginBlockEnd: 0, marginInlineStart: 12 }}>
{selectedProject?.name}
</Typography.Title>
{projectAttributes}
</Flex>
),
[handleNavigateToProjects, selectedProject?.name, projectAttributes]
);
// Memoized page header styles
const pageHeaderStyle = useMemo(() => ({
paddingInline: 0,
marginBlockEnd: 12,
}), []);
const pageHeaderStyle = useMemo(
() => ({
paddingInline: 0,
marginBlockEnd: 12,
}),
[]
);
// Cleanup timeout on unmount
useEffect(() => {

View File

@@ -54,7 +54,9 @@
[data-theme="default"] .project-view-tabs .ant-tabs-tab-active {
color: #1e40af !important;
background: #ffffff !important;
box-shadow: 0 -2px 8px rgba(59, 130, 246, 0.1), 0 4px 16px rgba(59, 130, 246, 0.1);
box-shadow:
0 -2px 8px rgba(59, 130, 246, 0.1),
0 4px 16px rgba(59, 130, 246, 0.1);
z-index: 1;
}
@@ -74,7 +76,9 @@
[data-theme="dark"] .project-view-tabs .ant-tabs-tab-active {
color: #60a5fa !important;
background: #1f1f1f !important;
box-shadow: 0 -2px 8px rgba(96, 165, 250, 0.15), 0 4px 16px rgba(96, 165, 250, 0.15);
box-shadow:
0 -2px 8px rgba(96, 165, 250, 0.15),
0 4px 16px rgba(96, 165, 250, 0.15);
z-index: 1;
}
@@ -151,14 +155,14 @@
.project-view-tabs .ant-tabs-nav {
padding: 0 8px;
}
.project-view-tabs .ant-tabs-tab {
margin: 0 2px 0 0;
padding: 6px 12px;
font-size: 12px;
min-height: 32px;
}
.project-view-tabs .borderless-icon-btn {
margin-left: 4px;
padding: 1px;
@@ -171,7 +175,7 @@
font-size: 11px;
min-height: 30px;
}
.project-view-tabs .borderless-icon-btn {
display: none; /* Hide pin buttons on very small screens */
}

View File

@@ -3,16 +3,16 @@ import { useLocation, useNavigate, useParams, useSearchParams } from 'react-rout
import { createPortal } from 'react-dom';
// Centralized Ant Design imports
import {
Button,
ConfigProvider,
Flex,
import {
Button,
ConfigProvider,
Flex,
Tooltip,
Badge,
Tabs,
PushpinFilled,
PushpinFilled,
PushpinOutlined,
type TabsProps
type TabsProps,
} from '@/shared/antd-imports';
import { useAppDispatch } from '@/hooks/useAppDispatch';
@@ -33,7 +33,11 @@ import { resetFields } from '@/features/task-management/taskListFields.slice';
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
import { tabItems } from '@/lib/project/project-view-constants';
import { setSelectedTaskId, setShowTaskDrawer, resetTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
import {
setSelectedTaskId,
setShowTaskDrawer,
resetTaskDrawer,
} from '@/features/task-drawer/task-drawer.slice';
import { resetState as resetEnhancedKanbanState } from '@/features/enhanced-kanban/enhanced-kanban.slice';
import { setProjectId as setInsightsProjectId } from '@/features/projects/insights/project-insights.slice';
import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback';
@@ -42,12 +46,16 @@ import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallba
import TaskDrawer from '@components/task-drawer/task-drawer';
// Lazy load non-critical components with better error handling
const DeleteStatusDrawer = React.lazy(() => import('@/components/project-task-filters/delete-status-drawer/delete-status-drawer'));
const DeleteStatusDrawer = React.lazy(
() => import('@/components/project-task-filters/delete-status-drawer/delete-status-drawer')
);
const PhaseDrawer = React.lazy(() => import('@/features/projects/singleProject/phase/PhaseDrawer'));
const StatusDrawer = React.lazy(() => import('@/components/project-task-filters/create-status-drawer/create-status-drawer'));
const ProjectMemberDrawer = React.lazy(() => import('@/components/projects/project-member-invite-drawer/project-member-invite-drawer'));
const StatusDrawer = React.lazy(
() => import('@/components/project-task-filters/create-status-drawer/create-status-drawer')
);
const ProjectMemberDrawer = React.lazy(
() => import('@/components/projects/project-member-invite-drawer/project-member-invite-drawer')
);
const ProjectView = React.memo(() => {
const location = useLocation();
@@ -59,16 +67,19 @@ const ProjectView = React.memo(() => {
// Memoized selectors to prevent unnecessary re-renders
const selectedProject = useAppSelector(state => state.projectReducer.project);
const projectLoading = useAppSelector(state => state.projectReducer.projectLoading);
// Optimize document title updates
useDocumentTitle(selectedProject?.name || 'Project View');
// Memoize URL params to prevent unnecessary state updates
const urlParams = useMemo(() => ({
tab: searchParams.get('tab') || tabItems[0].key,
pinnedTab: searchParams.get('pinned_tab') || '',
taskId: searchParams.get('task') || ''
}), [searchParams]);
const urlParams = useMemo(
() => ({
tab: searchParams.get('tab') || tabItems[0].key,
pinnedTab: searchParams.get('pinned_tab') || '',
taskId: searchParams.get('task') || '',
}),
[searchParams]
);
const [activeTab, setActiveTab] = useState<string>(urlParams.tab);
const [pinnedTab, setPinnedTab] = useState<string>(urlParams.pinnedTab);
@@ -94,10 +105,10 @@ const ProjectView = React.memo(() => {
dispatch(resetSelection());
dispatch(resetFields());
dispatch(resetEnhancedKanbanState());
// Reset project insights
dispatch(setInsightsProjectId(''));
// Reset task drawer completely
dispatch(resetTaskDrawer());
}, [dispatch]);
@@ -113,7 +124,7 @@ const ProjectView = React.memo(() => {
// Effect for handling route changes (when navigating away from project view)
useEffect(() => {
const currentPath = location.pathname;
// If we're not on a project view path, clean up
if (!currentPath.includes('/worklenz/projects/') || currentPath === '/worklenz/projects') {
resetAllProjectData();
@@ -131,15 +142,15 @@ const ProjectView = React.memo(() => {
dispatch(resetTaskManagement());
dispatch(resetEnhancedKanbanState());
dispatch(deselectAll());
// Load new project data
dispatch(setProjectId(projectId));
// Load project and essential data in parallel
const [projectResult] = await Promise.allSettled([
dispatch(getProject(projectId)),
dispatch(fetchStatuses(projectId)),
dispatch(fetchLabels())
dispatch(fetchLabels()),
]);
if (projectResult.status === 'fulfilled' && !projectResult.value.payload) {
@@ -172,51 +183,63 @@ const ProjectView = React.memo(() => {
}, [dispatch, taskid, isInitialized]);
// Optimized pin tab function with better error handling
const pinToDefaultTab = useCallback(async (itemKey: string) => {
if (!itemKey || !projectId) return;
const pinToDefaultTab = useCallback(
async (itemKey: string) => {
if (!itemKey || !projectId) return;
try {
const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD';
const res = await projectsApiService.updateDefaultTab({
project_id: projectId,
default_view: defaultView,
});
if (res.done) {
setPinnedTab(itemKey);
// Optimize tab items update
tabItems.forEach(item => {
item.isPinned = item.key === itemKey;
try {
const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD';
const res = await projectsApiService.updateDefaultTab({
project_id: projectId,
default_view: defaultView,
});
navigate({
pathname: `/worklenz/projects/${projectId}`,
search: new URLSearchParams({
tab: activeTab,
pinned_tab: itemKey
}).toString(),
}, { replace: true }); // Use replace to avoid history pollution
if (res.done) {
setPinnedTab(itemKey);
// Optimize tab items update
tabItems.forEach(item => {
item.isPinned = item.key === itemKey;
});
navigate(
{
pathname: `/worklenz/projects/${projectId}`,
search: new URLSearchParams({
tab: activeTab,
pinned_tab: itemKey,
}).toString(),
},
{ replace: true }
); // Use replace to avoid history pollution
}
} catch (error) {
console.error('Error updating default tab:', error);
}
} catch (error) {
console.error('Error updating default tab:', error);
}
}, [projectId, activeTab, navigate]);
},
[projectId, activeTab, navigate]
);
// Optimized tab change handler
const handleTabChange = useCallback((key: string) => {
setActiveTab(key);
dispatch(setProjectView(key === 'board' ? 'kanban' : 'list'));
// Use replace for better performance and history management
navigate({
pathname: location.pathname,
search: new URLSearchParams({
tab: key,
pinned_tab: pinnedTab,
}).toString(),
}, { replace: true });
}, [dispatch, location.pathname, navigate, pinnedTab]);
const handleTabChange = useCallback(
(key: string) => {
setActiveTab(key);
dispatch(setProjectView(key === 'board' ? 'kanban' : 'list'));
// Use replace for better performance and history management
navigate(
{
pathname: location.pathname,
search: new URLSearchParams({
tab: key,
pinned_tab: pinnedTab,
}).toString(),
},
{ replace: true }
);
},
[dispatch, location.pathname, navigate, pinnedTab]
);
// Memoized tab menu items with enhanced styling
const tabMenuItems = useMemo(() => {
@@ -242,22 +265,22 @@ const ProjectView = React.memo(() => {
}}
icon={
item.key === pinnedTab ? (
<PushpinFilled
style={{
fontSize: '12px',
color: 'currentColor',
transform: 'rotate(-45deg)',
transition: 'all 0.3s ease',
}}
/>
) : (
<PushpinOutlined
style={{
fontSize: '12px',
color: 'currentColor',
transition: 'all 0.3s ease',
}}
/>
<PushpinFilled
style={{
fontSize: '12px',
color: 'currentColor',
transform: 'rotate(-45deg)',
transition: 'all 0.3s ease',
}}
/>
) : (
<PushpinOutlined
style={{
fontSize: '12px',
color: 'currentColor',
transition: 'all 0.3s ease',
}}
/>
)
}
onClick={e => {
@@ -290,22 +313,25 @@ const ProjectView = React.memo(() => {
}, [isInitialized]);
// Optimized portal elements with better error boundaries
const portalElements = useMemo(() => (
<>
{/* Critical component - load immediately without suspense */}
{createPortal(<TaskDrawer />, document.body, 'task-drawer')}
{/* Non-critical components - load after delay with suspense fallback */}
{shouldLoadSecondaryComponents && (
<Suspense fallback={<SuspenseFallback />}>
{createPortal(<ProjectMemberDrawer />, document.body, 'project-member-drawer')}
{createPortal(<PhaseDrawer />, document.body, 'phase-drawer')}
{createPortal(<StatusDrawer />, document.body, 'status-drawer')}
{createPortal(<DeleteStatusDrawer />, document.body, 'delete-status-drawer')}
</Suspense>
)}
</>
), [shouldLoadSecondaryComponents]);
const portalElements = useMemo(
() => (
<>
{/* Critical component - load immediately without suspense */}
{createPortal(<TaskDrawer />, document.body, 'task-drawer')}
{/* Non-critical components - load after delay with suspense fallback */}
{shouldLoadSecondaryComponents && (
<Suspense fallback={<SuspenseFallback />}>
{createPortal(<ProjectMemberDrawer />, document.body, 'project-member-drawer')}
{createPortal(<PhaseDrawer />, document.body, 'phase-drawer')}
{createPortal(<StatusDrawer />, document.body, 'status-drawer')}
{createPortal(<DeleteStatusDrawer />, document.body, 'delete-status-drawer')}
</Suspense>
)}
</>
),
[shouldLoadSecondaryComponents]
);
// Show loading state while project is being fetched
if (projectLoading || !isInitialized) {
@@ -325,7 +351,7 @@ const ProjectView = React.memo(() => {
activeKey={activeTab}
onChange={handleTabChange}
items={tabMenuItems}
tabBarStyle={{
tabBarStyle={{
paddingInline: 0,
marginBottom: 8,
background: 'transparent',

View File

@@ -37,12 +37,7 @@ interface TaskGroupProps {
activeId?: string | null;
}
const TaskGroup: React.FC<TaskGroupProps> = ({
taskGroup,
groupBy,
color,
activeId
}) => {
const TaskGroup: React.FC<TaskGroupProps> = ({ taskGroup, groupBy, color, activeId }) => {
const { t } = useTranslation('task-list-table');
const dispatch = useAppDispatch();
const { trackMixpanelEvent } = useMixpanelTracking();
@@ -113,7 +108,7 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
category_id: category,
project_id: projectId,
});
dispatch(fetchStatuses());
trackMixpanelEvent(evt_project_board_column_setting_click, {
column_id: taskGroup.id,
@@ -146,7 +141,7 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
await phasesApiService.updatePhase(phaseData);
dispatch(fetchPhasesByProjectId(projectId));
}
setIsRenaming(false);
} catch (error) {
logger.error('Error renaming group:', error);
@@ -172,10 +167,12 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
dispatch(fetchPhasesByProjectId(projectId));
}
dispatch(updateTaskGroupColor({
groupId: taskGroup.id,
color: baseColor,
}));
dispatch(
updateTaskGroupColor({
groupId: taskGroup.id,
color: baseColor,
})
);
} catch (error) {
logger.error('Error updating group color:', error);
}
@@ -215,7 +212,7 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
</Typography.Text>
)}
</Button>
{dropdownItems.length > 0 && !isRenaming && (
<Dropdown menu={{ items: dropdownItems }} trigger={['click']}>
<Button icon={<EllipsisOutlined />} className="borderless-icon-btn" />
@@ -238,4 +235,4 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
);
};
export default React.memo(TaskGroup);
export default React.memo(TaskGroup);

View File

@@ -90,9 +90,7 @@ const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => {
selected: true,
}));
const groupId = groups.find(group =>
group.tasks.some(task => task.id === data.id)
)?.id;
const groupId = groups.find(group => group.tasks.some(task => task.id === data.id))?.id;
if (groupId) {
dispatch(
@@ -129,13 +127,13 @@ const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => {
const overGroupId = over.data.current?.groupId;
const activeTaskId = active.id;
const overTaskId = over.id;
setGroups(prevGroups => {
// ... existing drag end logic ...
});
};
const getDropdownItems = (groupId: string): MenuProps['items'] => ([
const getDropdownItems = (groupId: string): MenuProps['items'] => [
{
key: '1',
icon: <EditOutlined />,
@@ -157,7 +155,7 @@ const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => {
type: 'group',
})),
},
]);
];
return (
<DndContext
@@ -184,20 +182,25 @@ const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => {
<Button
className="custom-collapse-button"
style={{
backgroundColor: themeMode === 'dark' ? group.color_code_dark : group.color_code,
backgroundColor:
themeMode === 'dark' ? group.color_code_dark : group.color_code,
border: 'none',
borderBottomLeftRadius: expandedGroups[group.id] ? 0 : 4,
borderBottomRightRadius: expandedGroups[group.id] ? 0 : 4,
color: colors.darkGray,
}}
icon={<RightOutlined rotate={expandedGroups[group.id] ? 90 : 0} />}
onClick={() => setExpandedGroups(prev => ({ ...prev, [group.id]: !prev[group.id] }))}
onClick={() =>
setExpandedGroups(prev => ({ ...prev, [group.id]: !prev[group.id] }))
}
>
{renamingGroup === group.id ? (
<Input
size="small"
value={groupNames[group.id]}
onChange={e => setGroupNames(prev => ({ ...prev, [group.id]: e.target.value }))}
onChange={e =>
setGroupNames(prev => ({ ...prev, [group.id]: e.target.value }))
}
onBlur={() => setRenamingGroup(null)}
onPressEnter={() => setRenamingGroup(null)}
onClick={e => e.stopPropagation()}
@@ -220,11 +223,7 @@ const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => {
className="border-l-[3px] relative after:content after:absolute after:h-full after:w-1 after:z-10 after:top-0 after:left-0 mt-1"
color={themeMode === 'dark' ? group.color_code_dark : group.color_code}
>
<TaskListTable
taskList={group.tasks}
tableId={group.id}
activeId={activeId}
/>
<TaskListTable taskList={group.tasks} tableId={group.id} activeId={activeId} />
</Collapsible>
</Flex>
</div>
@@ -232,7 +231,6 @@ const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => {
</Flex>
</ConfigProvider>
{createPortal(
<TaskTemplateDrawer showDrawer={false} selectedTemplateId={''} onClose={() => {}} />,
document.body,
@@ -242,4 +240,4 @@ const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => {
);
};
export default TaskGroupList;
export default TaskGroupList;

View File

@@ -35,14 +35,14 @@ const ProjectViewTaskList = () => {
// Simplified loading state - only wait for essential data
// Remove dependency on phases and status categories for initial render
const isLoading = useMemo(() =>
loadingGroups || !coreDataLoaded,
const isLoading = useMemo(
() => loadingGroups || !coreDataLoaded,
[loadingGroups, coreDataLoaded]
);
// Memoize the empty state check
const isEmptyState = useMemo(() =>
taskGroups && taskGroups.length === 0 && !isLoading,
const isEmptyState = useMemo(
() => taskGroups && taskGroups.length === 0 && !isLoading,
[taskGroups, isLoading]
);
@@ -117,11 +117,8 @@ const ProjectViewTaskList = () => {
{isEmptyState ? (
<Empty description="No tasks group found" />
) : (
<Skeleton active loading={isLoading} className='mt-4 p-4'>
<TaskGroupWrapperOptimized
taskGroups={memoizedTaskGroups}
groupBy={groupBy}
/>
<Skeleton active loading={isLoading} className="mt-4 p-4">
<TaskGroupWrapperOptimized taskGroups={memoizedTaskGroups} groupBy={groupBy} />
</Skeleton>
)}
</Flex>

View File

@@ -24,11 +24,12 @@ const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOpti
useTaskSocketHandlers();
// Memoize task groups with colors
const taskGroupsWithColors = useMemo(() =>
taskGroups?.map(taskGroup => ({
...taskGroup,
displayColor: themeMode === 'dark' ? taskGroup.color_code_dark : taskGroup.color_code,
})) || [],
const taskGroupsWithColors = useMemo(
() =>
taskGroups?.map(taskGroup => ({
...taskGroup,
displayColor: themeMode === 'dark' ? taskGroup.color_code_dark : taskGroup.color_code,
})) || [],
[taskGroups, themeMode]
);
@@ -69,8 +70,6 @@ const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOpti
/>
))}
{createPortal(
<TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />,
document.body,
@@ -80,4 +79,4 @@ const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOpti
);
};
export default React.memo(TaskGroupWrapperOptimized);
export default React.memo(TaskGroupWrapperOptimized);

View File

@@ -62,14 +62,16 @@ const TaskListFilters: React.FC<TaskListFiltersProps> = ({ position }) => {
// Load team members last (heaviest query)
setTimeout(() => {
dispatch(getTeamMembers({
index: 0,
size: 50, // Reduce initial load size
field: null,
order: null,
search: null,
all: false // Don't load all at once
}));
dispatch(
getTeamMembers({
index: 0,
size: 50, // Reduce initial load size
field: null,
order: null,
search: null,
all: false, // Don't load all at once
})
);
}, 300);
}
} catch (error) {

View File

@@ -71,10 +71,9 @@ const TaskContextMenu = ({ visible, position, selectedTask, onClose, t }: TaskCo
if (res.done) {
const { id: taskId, assignees } = res.body;
trackMixpanelEvent(evt_project_task_list_context_menu_assign_me);
const groupId = taskGroups.find(group =>
group.tasks.some(task =>
task.id === taskId ||
task.sub_tasks?.some(subtask => subtask.id === taskId)
const groupId = taskGroups.find(group =>
group.tasks.some(
task => task.id === taskId || task.sub_tasks?.some(subtask => subtask.id === taskId)
)
)?.id;
@@ -115,7 +114,8 @@ const TaskContextMenu = ({ visible, position, selectedTask, onClose, t }: TaskCo
trackMixpanelEvent(evt_project_task_list_context_menu_archive);
dispatch(deleteTask({ taskId: selectedTask.id }));
dispatch(deselectAll());
if (selectedTask.parent_task_id) socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), selectedTask.parent_task_id);
if (selectedTask.parent_task_id)
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), selectedTask.parent_task_id);
}
} catch (error) {
console.error(error);
@@ -135,7 +135,8 @@ const TaskContextMenu = ({ visible, position, selectedTask, onClose, t }: TaskCo
trackMixpanelEvent(evt_project_task_list_context_menu_delete);
dispatch(deleteTask({ taskId: selectedTask.id }));
dispatch(deselectAll());
if (selectedTask.parent_task_id) socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), selectedTask.parent_task_id);
if (selectedTask.parent_task_id)
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), selectedTask.parent_task_id);
}
} catch (error) {
console.error(error);
@@ -264,14 +265,16 @@ const TaskContextMenu = ({ visible, position, selectedTask, onClose, t }: TaskCo
label: t('contextMenu.moveTo'),
children: getMoveToOptions(),
},
...(!selectedTask?.parent_task_id ? [
{
key: '3',
icon: <InboxOutlined />,
label: archived ? t('contextMenu.unarchive' ) : t('contextMenu.archive'),
onClick: handleArchive,
},
] : []),
...(!selectedTask?.parent_task_id
? [
{
key: '3',
icon: <InboxOutlined />,
label: archived ? t('contextMenu.unarchive') : t('contextMenu.archive'),
onClick: handleArchive,
},
]
: []),
...(selectedTask?.sub_tasks_count === 0 && !selectedTask?.parent_task_id
? [
{

View File

@@ -7,14 +7,14 @@ import { useTranslation } from 'react-i18next';
import { colors } from '../../../../../../../../styles/colors';
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
const CustomColumnLabelCell = ({
labelsList,
const CustomColumnLabelCell = ({
labelsList,
selectedLabels = [],
onChange
}: {
labelsList: ITaskLabel[],
selectedLabels?: string[],
onChange?: (labels: string[]) => void
onChange,
}: {
labelsList: ITaskLabel[];
selectedLabels?: string[];
onChange?: (labels: string[]) => void;
}) => {
const [currentLabelOption, setCurrentLabelOption] = useState<ITaskLabel | null>(null);

View File

@@ -8,14 +8,14 @@ import { colors } from '../../../../../../../../styles/colors';
import { SelectionType } from '../../custom-column-modal/selection-type-column/selection-type-column';
import { ALPHA_CHANNEL } from '@/shared/constants';
const CustomColumnSelectionCell = ({
const CustomColumnSelectionCell = ({
selectionsList,
value,
onChange
}: {
selectionsList: SelectionType[],
value?: string,
onChange?: (value: string) => void
onChange,
}: {
selectionsList: SelectionType[];
value?: string;
onChange?: (value: string) => void;
}) => {
const [currentSelectionOption, setCurrentSelectionOption] = useState<SelectionType | null>(null);
@@ -23,10 +23,10 @@ const CustomColumnSelectionCell = ({
const { t } = useTranslation('task-list-table');
// Debug the selectionsList and value
console.log('CustomColumnSelectionCell props:', {
selectionsList,
console.log('CustomColumnSelectionCell props:', {
selectionsList,
value,
selectionsCount: selectionsList?.length || 0
selectionsCount: selectionsList?.length || 0,
});
// Set initial selection based on value prop
@@ -61,7 +61,7 @@ const CustomColumnSelectionCell = ({
// handle selection selection
const handleSelectionOptionSelect: MenuProps['onClick'] = e => {
if (e.key === 'noSelections') return;
const selectedOption = selectionsList.find(option => option.selection_id === e.key);
if (selectedOption) {
setCurrentSelectionOption(selectedOption);
@@ -105,7 +105,8 @@ const CustomColumnSelectionCell = ({
paddingInline: 8,
height: 22,
fontSize: 13,
backgroundColor: currentSelectionOption?.selection_color + ALPHA_CHANNEL || colors.transparent,
backgroundColor:
currentSelectionOption?.selection_color + ALPHA_CHANNEL || colors.transparent,
color: colors.darkGray,
cursor: 'pointer',
}}

View File

@@ -1,4 +1,15 @@
import { Button, Divider, Flex, Form, Input, message, Modal, Select, Typography, Popconfirm } from 'antd';
import {
Button,
Divider,
Flex,
Form,
Input,
message,
Modal,
Select,
Typography,
Popconfirm,
} from 'antd';
import SelectionTypeColumn from './selection-type-column/selection-type-column';
import NumberTypeColumn from './number-type-column/number-type-column';
import LabelTypeColumn from './label-type-column/label-type-column';
@@ -30,7 +41,10 @@ import {
import { themeWiseColor } from '@/utils/themeWiseColor';
import KeyTypeColumn from './key-type-column/key-type-column';
import logger from '@/utils/errorLogger';
import { addCustomColumn, deleteCustomColumn as deleteCustomColumnFromTasks } from '@/features/tasks/tasks.slice';
import {
addCustomColumn,
deleteCustomColumn as deleteCustomColumnFromTasks,
} from '@/features/tasks/tasks.slice';
import { useParams } from 'react-router-dom';
import { tasksCustomColumnsService } from '@/api/tasks/tasks-custom-columns.service';
import { ExclamationCircleFilled } from '@ant-design/icons';
@@ -76,22 +90,22 @@ const CustomColumnModal = () => {
// Function to handle deleting a custom column
const handleDeleteColumn = async () => {
if (!customColumnId) return;
try {
// Make API request to delete the custom column using the service
await tasksCustomColumnsService.deleteCustomColumn(openedColumn?.id || customColumnId);
// Dispatch actions to update the Redux store
dispatch(deleteCustomColumnFromTasks(customColumnId));
dispatch(deleteCustomColumnFromColumns(customColumnId));
// Close the modal
dispatch(toggleCustomColumnModalOpen(false));
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
// Show success message
message.success('Custom column deleted successfully');
// Reload the page to reflect the changes
window.location.reload();
} catch (error) {
@@ -211,14 +225,14 @@ const CustomColumnModal = () => {
field_type: value.fieldType,
width: 120,
is_visible: true,
configuration
configuration,
});
if (res.done) {
if (res.body.id) newColumn.id = res.body.id;
dispatch(addCustomColumn(newColumn));
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
dispatch(toggleCustomColumnModalOpen(false));
dispatch(addCustomColumn(newColumn));
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
dispatch(toggleCustomColumnModalOpen(false));
}
} catch (error) {
logger.error('Error creating custom column:', error);
@@ -290,7 +304,7 @@ const CustomColumnModal = () => {
field_type: value.fieldType,
width: 150,
is_visible: true,
configuration
configuration,
});
// Close modal
@@ -329,24 +343,33 @@ const CustomColumnModal = () => {
mainForm.resetFields();
}}
afterOpenChange={open => {
if (open && customColumnModalType === 'edit' && openedColumn) {
if (open && customColumnModalType === 'edit' && openedColumn) {
// Set the field type first so the correct form fields are displayed
dispatch(setCustomFieldType(openedColumn.custom_column_obj?.fieldType || 'people'));
// Set other field values based on the custom column type
if (openedColumn.custom_column_obj?.fieldType === 'number') {
dispatch(setCustomFieldNumberType(openedColumn.custom_column_obj?.numberType || 'formatted'));
dispatch(
setCustomFieldNumberType(openedColumn.custom_column_obj?.numberType || 'formatted')
);
dispatch(setDecimals(openedColumn.custom_column_obj?.decimals || 0));
dispatch(setLabel(openedColumn.custom_column_obj?.label || ''));
dispatch(setLabelPosition(openedColumn.custom_column_obj?.labelPosition || 'left'));
} else if (openedColumn.custom_column_obj?.fieldType === 'formula') {
dispatch(setExpression(openedColumn.custom_column_obj?.expression || 'add'));
dispatch(setFirstNumericColumn(openedColumn.custom_column_obj?.firstNumericColumn || null));
dispatch(setSecondNumericColumn(openedColumn.custom_column_obj?.secondNumericColumn || null));
dispatch(
setFirstNumericColumn(openedColumn.custom_column_obj?.firstNumericColumn || null)
);
dispatch(
setSecondNumericColumn(openedColumn.custom_column_obj?.secondNumericColumn || null)
);
} else if (openedColumn.custom_column_obj?.fieldType === 'selection') {
// Directly set the selections list in the Redux store
if (Array.isArray(openedColumn.custom_column_obj?.selectionsList)) {
console.log('Setting selections list:', openedColumn.custom_column_obj.selectionsList);
console.log(
'Setting selections list:',
openedColumn.custom_column_obj.selectionsList
);
dispatch(setSelectionsList(openedColumn.custom_column_obj.selectionsList));
}
} else if (openedColumn.custom_column_obj?.fieldType === 'labels') {
@@ -356,7 +379,7 @@ const CustomColumnModal = () => {
dispatch(setLabelsList(openedColumn.custom_column_obj.labelsList));
}
}
// Set form values
mainForm.setFieldsValue({
fieldTitle: openedColumn.name || openedColumn.custom_column_obj?.fieldTitle,

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { nanoid } from "nanoid";
import { nanoid } from 'nanoid';
import { PhaseColorCodes } from '../../../../../../../../shared/constants';
import { Button, Flex, Input, Select, Tag, Typography } from 'antd';
import { CloseCircleOutlined, HolderOutlined } from '@ant-design/icons';
@@ -27,9 +27,9 @@ const LabelTypeColumn = () => {
const { customColumnModalType, customColumnId } = useAppSelector(
state => state.taskListCustomColumnsReducer
);
// Get the opened column data if in edit mode
const openedColumn = useAppSelector(state =>
const openedColumn = useAppSelector(state =>
state.taskReducer.customColumns.find(col => col.key === customColumnId)
);
@@ -127,11 +127,7 @@ const LabelTypeColumn = () => {
))}
</Flex>
<Button
type="link"
onClick={handleAddLabel}
style={{ width: 'fit-content', padding: 0 }}
>
<Button type="link" onClick={handleAddLabel} style={{ width: 'fit-content', padding: 0 }}>
+ Add a label
</Button>
</Flex>

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { nanoid } from "nanoid";
import { nanoid } from 'nanoid';
import { PhaseColorCodes } from '../../../../../../../../shared/constants';
import { Button, Flex, Input, Select, Tag, Typography } from 'antd';
import { CloseCircleOutlined, HolderOutlined } from '@ant-design/icons';
@@ -25,12 +25,14 @@ const SelectionTypeColumn = () => {
]);
// Get the custom column modal type and column ID from the store
const { customColumnModalType, customColumnId, selectionsList: storeSelectionsList } = useAppSelector(
state => state.taskListCustomColumnsReducer
);
const {
customColumnModalType,
customColumnId,
selectionsList: storeSelectionsList,
} = useAppSelector(state => state.taskListCustomColumnsReducer);
// Get the opened column data if in edit mode
const openedColumn = useAppSelector(state =>
const openedColumn = useAppSelector(state =>
state.taskReducer.customColumns.find(col => col.key === customColumnId)
);
@@ -39,7 +41,8 @@ const SelectionTypeColumn = () => {
customColumnId,
openedColumn,
storeSelectionsList,
'openedColumn?.custom_column_obj?.selectionsList': openedColumn?.custom_column_obj?.selectionsList
'openedColumn?.custom_column_obj?.selectionsList':
openedColumn?.custom_column_obj?.selectionsList,
});
// Load existing selections when in edit mode
@@ -47,7 +50,7 @@ const SelectionTypeColumn = () => {
if (customColumnModalType === 'edit' && openedColumn?.custom_column_obj?.selectionsList) {
const existingSelections = openedColumn.custom_column_obj.selectionsList;
console.log('Loading existing selections:', existingSelections);
if (Array.isArray(existingSelections) && existingSelections.length > 0) {
setSelections(existingSelections);
dispatch(setSelectionsList(existingSelections));
@@ -86,7 +89,9 @@ const SelectionTypeColumn = () => {
// update selection name
const handleUpdateSelectionName = (selectionId: string, selectionName: string) => {
const updatedSelections = selections.map(selection =>
selection.selection_id === selectionId ? { ...selection, selection_name: selectionName } : selection
selection.selection_id === selectionId
? { ...selection, selection_name: selectionName }
: selection
);
setSelections(updatedSelections);
dispatch(setSelectionsList(updatedSelections)); // update the slice with the new selection name
@@ -95,7 +100,9 @@ const SelectionTypeColumn = () => {
// update selection color
const handleUpdateSelectionColor = (selectionId: string, selectionColor: string) => {
const updatedSelections = selections.map(selection =>
selection.selection_id === selectionId ? { ...selection, selection_color: selectionColor } : selection
selection.selection_id === selectionId
? { ...selection, selection_color: selectionColor }
: selection
);
setSelections(updatedSelections);
dispatch(setSelectionsList(updatedSelections)); // update the slice with the new selection color
@@ -103,7 +110,9 @@ const SelectionTypeColumn = () => {
// remove a selection
const handleRemoveSelection = (selectionId: string) => {
const updatedSelections = selections.filter(selection => selection.selection_id !== selectionId);
const updatedSelections = selections.filter(
selection => selection.selection_id !== selectionId
);
setSelections(updatedSelections);
dispatch(setSelectionsList(updatedSelections)); // update the slice after selection removal
};

View File

@@ -117,10 +117,11 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
(data: ITaskAssigneesUpdateResponse) => {
if (!data) return;
const updatedAssignees = data.assignees?.map(assignee => ({
...assignee,
selected: true,
})) || [];
const updatedAssignees =
data.assignees?.map(assignee => ({
...assignee,
selected: true,
})) || [];
const groupId = groups?.find(group =>
group.tasks?.some(
@@ -158,7 +159,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
const handleLabelsChange = useCallback(
async (labels: ILabelsChangeResponse) => {
if (!labels) return;
await Promise.all([
dispatch(updateTaskLabel(labels)),
dispatch(setTaskLabels(labels)),
@@ -226,11 +227,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
// Memoize socket event handlers
const handleEndDateChange = useCallback(
(task: {
id: string;
parent_task: string | null;
end_date: string;
}) => {
(task: { id: string; parent_task: string | null; end_date: string }) => {
if (!task) return;
const taskWithProgress = {
@@ -267,11 +264,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
// Memoize socket event handlers
const handleStartDateChange = useCallback(
(task: {
id: string;
parent_task: string | null;
start_date: string;
}) => {
(task: { id: string; parent_task: string | null; start_date: string }) => {
if (!task) return;
const taskWithProgress = {
@@ -297,11 +290,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
// Memoize socket event handlers
const handleEstimationChange = useCallback(
(task: {
id: string;
parent_task: string | null;
estimation: number;
}) => {
(task: { id: string; parent_task: string | null; estimation: number }) => {
if (!task) return;
const taskWithProgress = {
@@ -316,11 +305,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
// Memoize socket event handlers
const handleTaskDescriptionChange = useCallback(
(data: {
id: string;
parent_task: string;
description: string;
}) => {
(data: { id: string; parent_task: string; description: string }) => {
if (!data) return;
dispatch(updateTaskDescription(data));
@@ -342,11 +327,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
// Memoize socket event handlers
const handleTaskProgressUpdated = useCallback(
(data: {
task_id: string;
progress_value?: number;
weight?: number;
}) => {
(data: { task_id: string; progress_value?: number; weight?: number }) => {
if (!data || !taskGroups) return;
if (data.progress_value !== undefined) {
@@ -686,8 +667,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
/>
))}
{createPortal(
<TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />,
document.body,

View File

@@ -6,7 +6,7 @@ const TaskListDescriptionCell = ({ description }: { description: string }) => {
return (
<Typography.Paragraph
ellipsis={{
ellipsis={{
expandable: false,
rows: 1,
tooltip: description,

View File

@@ -3,7 +3,7 @@ import { Typography } from 'antd';
import React from 'react';
interface ITaskListEstimationCellProps {
task: IProjectTask
task: IProjectTask;
}
const TaskListEstimationCell = ({ task }: ITaskListEstimationCellProps) => {
return <Typography.Text>{task?.total_time_string}</Typography.Text>;

View File

@@ -11,13 +11,17 @@ interface TaskListLabelsCellProps {
const TaskListLabelsCell = ({ task }: TaskListLabelsCellProps) => {
return (
<Flex>
{task.labels?.map((label, index) => (
{task.labels?.map((label, index) =>
label.end && label.names && label.name ? (
<CustomNumberLabel key={`${label.id}-${index}`} labelList={label.names ?? []} namesString={label.name} />
<CustomNumberLabel
key={`${label.id}-${index}`}
labelList={label.names ?? []}
namesString={label.name}
/>
) : (
<CustomColordLabel key={`${label.id}-${index}`} label={label} />
)
))}
)}
<LabelsSelector task={task} />
</Flex>
);

View File

@@ -10,7 +10,10 @@ type TaskListProgressCellProps = {
const TaskListProgressCell = ({ task }: TaskListProgressCellProps) => {
const { project } = useAppSelector(state => state.projectReducer);
const isManualProgressEnabled = (task.project_use_manual_progress || task.project_use_weighted_progress || task.project_use_time_progress);;
const isManualProgressEnabled =
task.project_use_manual_progress ||
task.project_use_weighted_progress ||
task.project_use_time_progress;
const isSubtask = task.is_sub_task;
const hasManualProgress = task.manual_progress;
@@ -18,7 +21,7 @@ const TaskListProgressCell = ({ task }: TaskListProgressCellProps) => {
// 1. For subtasks when manual progress is enabled, show the progress
// 2. For parent tasks, always show progress
// 3. For subtasks when manual progress is not enabled, don't show progress (null)
if (isSubtask && !isManualProgressEnabled) {
return null; // Don't show progress for subtasks when manual progress is disabled
}
@@ -40,11 +43,11 @@ const TaskListProgressCell = ({ task }: TaskListProgressCellProps) => {
// For subtasks with manual progress enabled, show the progress
return (
<Tooltip
<Tooltip
title={hasManualProgress ? `Manual: ${task.progress_value || 0}%` : `${task.progress || 0}%`}
>
<Progress
percent={hasManualProgress ? (task.progress_value || 0) : (task.progress || 0)}
percent={hasManualProgress ? task.progress_value || 0 : task.progress || 0}
type="circle"
size={22} // Slightly smaller for subtasks
style={{ cursor: 'default' }}

View File

@@ -38,7 +38,7 @@ const AddSubTaskListRow: React.FC<AddSubTaskListRowProps> = ({
};
return (
<tr className={`add-subtask-row${customBorderColor}`}>
<tr className={`add-subtask-row${customBorderColor}`}>
{visibleColumns.map(col => (
<td key={col.key} style={{ padding: 0, background: 'inherit' }}>
{col.key === taskColumnKey ? (

View File

@@ -123,7 +123,7 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr
clearTimeout(taskCreationTimeout);
setTaskCreationTimeout(null);
}
setIsEdit(true);
setTimeout(() => {
@@ -142,12 +142,10 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr
}
};
const addInstantTask = async () => {
// Validation
if (creatingTask || !projectId || !currentSession) return;
const trimmedTaskName = taskName.trim();
if (trimmedTaskName === '') {
setError('Task name cannot be empty');
@@ -158,7 +156,7 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr
try {
setCreatingTask(true);
setError('');
const body = createRequestBody();
if (!body) {
setError('Failed to create task. Please try again.');
@@ -171,17 +169,17 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr
setCreatingTask(false);
setError('Task creation timed out. Please try again.');
}, 10000);
setTaskCreationTimeout(timeout);
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
// Handle success response - the global socket handler will handle task addition
socket?.once(SocketEvents.QUICK_TASK.toString(), (task: IProjectTask) => {
clearTimeout(timeout);
setTaskCreationTimeout(null);
setCreatingTask(false);
if (task && task.id) {
// Just reset the form - the global handler will add the task to Redux
reset(false);
@@ -204,7 +202,6 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr
const errorMessage = errorData?.message || 'Failed to create task';
setError(errorMessage);
});
} catch (error) {
console.error('Error adding task:', error);
setCreatingTask(false);
@@ -238,9 +235,9 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr
<div className="add-task-input-container">
<Input
className="add-task-input"
style={{
style={{
borderColor: error ? '#ff4d4f' : colors.skyBlue,
paddingRight: creatingTask ? '32px' : '12px'
paddingRight: creatingTask ? '32px' : '12px',
}}
placeholder={t('addTaskInputPlaceholder')}
value={taskName}
@@ -252,29 +249,19 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr
/>
{creatingTask && (
<div className="add-task-loading">
<Spin
size="small"
indicator={<LoadingOutlined style={{ fontSize: 14 }} spin />}
/>
</div>
)}
{error && (
<div className="add-task-error">
{error}
<Spin size="small" indicator={<LoadingOutlined style={{ fontSize: 14 }} spin />} />
</div>
)}
{error && <div className="add-task-error">{error}</div>}
</div>
) : (
<div
className="add-task-label"
onClick={() => setIsEdit(true)}
>
<div className="add-task-label" onClick={() => setIsEdit(true)}>
<span className="add-task-text">
{parentTask ? t('addSubTaskText') : t('addTaskText')}
</span>
</div>
)}
<style>{`
.add-task-row-container {
width: 100%;

View File

@@ -16,7 +16,12 @@ import TaskListTable from '../task-list-table';
import { useAppSelector } from '@/hooks/useAppSelector';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import Collapsible from '@/components/collapsible/collapsible';
import { fetchTaskGroups, fetchTaskListColumns, IGroupBy, updateTaskGroupColor } from '@/features/tasks/tasks.slice';
import {
fetchTaskGroups,
fetchTaskListColumns,
IGroupBy,
updateTaskGroupColor,
} from '@/features/tasks/tasks.slice';
import { useAuthService } from '@/hooks/useAuth';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types';
@@ -131,7 +136,11 @@ const TaskListTableWrapper = ({
await updateStatus();
} else if (groupBy === IGroupBy.PHASE) {
const body = { id: tableId, name: tableName.trim() };
const res = await phasesApiService.updateNameOfPhase(tableId, body as ITaskPhase, projectId);
const res = await phasesApiService.updateNameOfPhase(
tableId,
body as ITaskPhase,
projectId
);
if (res.done) {
trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Phase' });
dispatch(fetchPhasesByProjectId(projectId));
@@ -211,7 +220,7 @@ const TaskListTableWrapper = ({
icon={<RightOutlined rotate={isExpanded ? 90 : 0} />}
onClick={handlToggleExpand}
>
{(showRenameInput && name !== 'Unmapped') ? (
{showRenameInput && name !== 'Unmapped' ? (
<Input
size="small"
value={tableName}
@@ -234,22 +243,30 @@ const TaskListTableWrapper = ({
</Typography.Text>
)}
</Button>
{groupBy !== IGroupBy.PRIORITY && !showRenameInput && isEditable && name !== 'Unmapped' && (
<Dropdown menu={{ items }}>
<Button
icon={<EllipsisOutlined />}
className="borderless-icon-btn"
title={isEditable ? undefined : t('noPermission')}
/>
</Dropdown>
)}
{groupBy !== IGroupBy.PRIORITY &&
!showRenameInput &&
isEditable &&
name !== 'Unmapped' && (
<Dropdown menu={{ items }}>
<Button
icon={<EllipsisOutlined />}
className="borderless-icon-btn"
title={isEditable ? undefined : t('noPermission')}
/>
</Dropdown>
)}
</Flex>
<Collapsible
isOpen={isExpanded}
className={`border-l-[3px] relative after:content after:absolute after:h-full after:w-1 after:z-10 after:top-0 after:left-0`}
color={color}
>
<TaskListTable taskList={taskList} tableId={tableId} activeId={activeId} groupBy={groupBy} />
<TaskListTable
taskList={taskList}
tableId={tableId}
activeId={activeId}
groupBy={groupBy}
/>
</Collapsible>
</Flex>
</ConfigProvider>
@@ -257,4 +274,4 @@ const TaskListTableWrapper = ({
);
};
export default TaskListTableWrapper;
export default TaskListTableWrapper;