Files
worklenz/worklenz-frontend/src/pages/projects/project-view-1/updates/project-view-updates.tsx
chamikaJ a6863d8280 refactor: update code to use centralized Ant Design imports
- Replaced direct import of '@ant-design/icons' with centralized import from '@/shared/antd-imports'
2025-07-23 12:07:48 +05:30

355 lines
11 KiB
TypeScript

import {
Button,
ConfigProvider,
Flex,
Form,
Mentions,
Skeleton,
Space,
Tooltip,
Typography,
Dropdown,
Menu,
Popconfirm,
} from '@/shared/antd-imports';
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import DOMPurify from 'dompurify';
import { useParams } from 'react-router-dom';
import CustomAvatar from '@components/CustomAvatar';
import { colors } from '@/styles/colors';
import {
IMentionMemberSelectOption,
IMentionMemberViewModel,
} from '@/types/project/projectComments.types';
import { projectCommentsApiService } from '@/api/projects/comments/project-comments.api.service';
import { IProjectUpdateCommentViewModel } from '@/types/project/project.types';
import { calculateTimeDifference } from '@/utils/calculate-time-difference';
import { getUserSession } from '@/utils/session-helper';
import './project-view-updates.css';
import { useAppSelector } from '@/hooks/useAppSelector';
import { DeleteOutlined } from '@/shared/antd-imports';
const MAX_COMMENT_LENGTH = 2000;
// Compile RegExp once for linkify
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>`;
});
}
const ProjectViewUpdates = () => {
const { projectId } = useParams();
const [characterLength, setCharacterLength] = useState<number>(0);
const [isCommentBoxExpand, setIsCommentBoxExpand] = useState<boolean>(false);
const [members, setMembers] = useState<IMentionMemberViewModel[]>([]);
const [selectedMembers, setSelectedMembers] = useState<{ id: string; name: string }[]>([]);
const [comments, setComments] = useState<IProjectUpdateCommentViewModel[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isLoadingComments, setIsLoadingComments] = useState<boolean>(false);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [commentValue, setCommentValue] = useState<string>('');
const theme = useAppSelector(state => state.themeReducer.mode);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const { t } = useTranslation('project-view-updates');
const [form] = Form.useForm();
const getMembers = useCallback(async () => {
if (!projectId) return;
try {
setIsLoading(true);
const res = await projectCommentsApiService.getMentionMembers(
projectId,
1,
15,
null,
null,
null
);
if (res.done) {
setMembers(res.body as IMentionMemberViewModel[]);
}
} catch (error) {
console.error('Failed to fetch members:', error);
} finally {
setIsLoading(false);
}
}, [projectId]);
const getComments = useCallback(async () => {
if (!projectId) return;
try {
setIsLoadingComments(true);
const res = await projectCommentsApiService.getByProjectId(projectId);
if (res.done) {
setComments(res.body);
}
} catch (error) {
console.error('Failed to fetch comments:', error);
} finally {
setIsLoadingComments(false);
}
}, [projectId]);
const handleAddComment = useCallback(async () => {
if (!projectId || characterLength === 0) return;
try {
setIsSubmitting(true);
if (!commentValue) {
console.error('Comment content is empty');
return;
}
const body = {
project_id: projectId,
team_id: getUserSession()?.team_id,
content: commentValue.trim(),
mentions: selectedMembers,
};
const res = await projectCommentsApiService.createProjectComment(body);
if (res.done) {
setComments(prev => [
...prev,
{
...(res.body as IProjectUpdateCommentViewModel),
created_by: getUserSession()?.name || '',
created_at: new Date().toISOString(),
content: commentValue.trim(),
mentions: (res.body as IProjectUpdateCommentViewModel).mentions ?? [
undefined,
undefined,
],
},
]);
handleCancel();
}
} catch (error) {
console.error('Failed to add comment:', error);
} finally {
setIsSubmitting(false);
setCommentValue('');
}
}, [projectId, characterLength, commentValue, selectedMembers, getComments]);
useEffect(() => {
void getMembers();
void getComments();
}, [getMembers, getComments, refreshTimestamp]);
const handleCancel = useCallback(() => {
form.resetFields(['comment']);
setCharacterLength(0);
setIsCommentBoxExpand(false);
setSelectedMembers([]);
}, [form]);
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
: [...prev, { id: member.value, name: member.label }]
);
setCommentValue(prev => {
const parts = prev.split('@');
const lastPart = parts[parts.length - 1];
const mentionText = member.label;
return prev.slice(0, prev.length - lastPart.length) + mentionText;
});
}, []);
const handleCommentChange = useCallback((value: string) => {
setCommentValue(value);
setCharacterLength(value.trim().length);
}, []);
const handleDeleteComment = useCallback(
async (commentId: string | undefined) => {
if (!commentId) return;
try {
const res = await projectCommentsApiService.deleteComment(commentId);
if (res.done) {
void getComments();
}
} catch (error) {
console.error('Failed to delete comment:', error);
}
},
[getComments]
);
// Memoize link click handler for comment links
const handleCommentLinkClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
if (target.tagName === 'A') {
e.preventDefault();
const href = (target as HTMLAnchorElement).getAttribute('href');
if (href) {
window.open(href, '_blank', 'noopener,noreferrer');
}
}
}, []);
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 renderComment = useCallback(
(comment: IProjectUpdateCommentViewModel) => {
const linkifiedContent = linkify(comment.content || '');
const sanitizedContent = DOMPurify.sanitize(linkifiedContent);
const timeDifference = calculateTimeDifference(comment.created_at || '');
const themeClass = theme === 'dark' ? 'dark' : 'light';
return (
<Dropdown
key={comment.id ?? ''}
overlay={getCommentMenu(comment.id ?? '')}
trigger={['contextMenu']}
>
<div>
<Flex gap={8}>
<CustomAvatar avatarName={comment.created_by || ''} />
<Flex vertical flex={1}>
<Space>
<Typography.Text strong style={{ fontSize: 13, color: colors.lightGray }}>
{comment.created_by || ''}
</Typography.Text>
<Tooltip title={comment.created_at}>
<Typography.Text style={{ fontSize: 13, color: colors.deepLightGray }}>
{timeDifference}
</Typography.Text>
</Tooltip>
</Space>
<Typography.Paragraph
style={{ margin: '8px 0' }}
ellipsis={{ rows: 3, expandable: true }}
>
<div
className={`mentions-${themeClass}`}
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
onClick={handleCommentLinkClick}
/>
</Typography.Paragraph>
</Flex>
</Flex>
</div>
</Dropdown>
);
},
[theme, configProviderTheme, handleDeleteComment, handleCommentLinkClick]
);
const commentsList = useMemo(() => comments.map(renderComment), [comments, renderComment]);
return (
<Flex gap={24} vertical>
<Flex vertical gap={16}>
{isLoadingComments ? <Skeleton active /> : commentsList}
</Flex>
<Form onFinish={handleAddComment}>
<Form.Item>
<Mentions
value={commentValue}
placeholder={t('inputPlaceholder')}
loading={isLoading}
options={mentionsOptions}
autoSize
maxLength={MAX_COMMENT_LENGTH}
onSelect={(option, prefix) => memberSelectHandler(option as IMentionMemberSelectOption)}
onClick={() => setIsCommentBoxExpand(true)}
onChange={handleCommentChange}
prefix="@"
split=""
filterOption={(input, option) => {
if (!input) return true;
const optionLabel = (option as any)?.label || '';
return optionLabel.toLowerCase().includes(input.toLowerCase());
}}
style={{
minHeight: isCommentBoxExpand ? 180 : 60,
paddingBlockEnd: 24,
}}
/>
<span
style={{
position: 'absolute',
bottom: 4,
right: 12,
color: colors.lightGray,
}}
>{`${characterLength}/${MAX_COMMENT_LENGTH}`}</span>
</Form.Item>
{isCommentBoxExpand && (
<Form.Item>
<Flex gap={8} justify="flex-end">
<Button onClick={handleCancel} disabled={isSubmitting}>
{t('cancelButton')}
</Button>
<Button
type="primary"
loading={isSubmitting}
disabled={characterLength === 0}
htmlType="submit"
>
{t('addButton')}
</Button>
</Flex>
</Form.Item>
)}
</Form>
</Flex>
);
};
export default ProjectViewUpdates;