diff --git a/src/app/detail/ui/ActivityWrapper.tsx b/src/app/detail/ui/ActivityWrapper.tsx index 8bd1b1ff9..219d702e5 100644 --- a/src/app/detail/ui/ActivityWrapper.tsx +++ b/src/app/detail/ui/ActivityWrapper.tsx @@ -13,6 +13,7 @@ import { Token } from '@/types/common' import { CreateAttachmentRequest } from '@/types/dto/attachments.dto' import { CreateComment } from '@/types/dto/comment.dto' import { fetcher } from '@/utils/fetcher' +import { getCommentActivityId, isPendingCommentId } from '@/utils/commentActivity' import { generateRandomString } from '@/utils/generateRandomString' import { checkOptimisticStableId, getOptimisticData, getTempLog } from '@/utils/optimisticCommentUtils' import { LogResponse } from '@api/activity-logs/schemas/LogResponseSchema' @@ -22,7 +23,6 @@ import { useEffect, useMemo, useRef, useState, useCallback } from 'react' import { useSelector } from 'react-redux' import { TransitionGroup } from 'react-transition-group' import useSWR, { useSWRConfig } from 'swr' -import { z } from 'zod' interface OptimisticUpdate { tempId: string @@ -127,7 +127,25 @@ export const ActivityWrapper = ({ } // Handle comment deletion - const handleDeleteComment = async (commentId: string, logId: string, replyId?: string, softDelete?: boolean) => { + const resolveCommentIdToDelete = async (commentId: string): Promise => { + if (!isPendingCommentId(commentId)) return commentId + + const attempts = Array.from({ length: 6 }) + for (const _attempt of attempts) { + const matchedUpdate = optimisticUpdates.find((update) => update.tempId === commentId) + if (matchedUpdate?.serverId) return matchedUpdate.serverId + await new Promise((resolve) => setTimeout(resolve, 500)) + } + + return undefined + } + + const handleDeleteComment = async ( + commentId: string | undefined, + logId: string, + replyId?: string, + softDelete?: boolean, + ) => { let optimisticData if (replyId) { optimisticData = activities @@ -173,24 +191,14 @@ export const ActivityWrapper = ({ cacheKey, async () => { shouldRefetchRef.current = false - let commentIdToDelete = commentId - if (commentIdToDelete.includes('temp-comment')) { - const maxAttempts = 6 - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const matchedUpdate = optimisticUpdates.find((update) => update.tempId === commentIdToDelete) - if (matchedUpdate?.serverId) { - commentIdToDelete = matchedUpdate.serverId - break - } - await new Promise((resolve) => setTimeout(resolve, 500)) - } - if (commentIdToDelete.includes('temp-comment')) { - console.warn('Comment is still pending server sync. Try again later.') - return activities - } - } //Due to optimistic updates on comment creation applied in our ui, some deleted comments might have tempId which are yet to be replaced by the server id. Although the usecase frequency for this is very very minimal, we are waiting for serverId to replace tempId if the deleted comment has tempId by polling method. + const commentIdToDelete = getCommentActivityId({ id: commentId }) + const resolvedCommentId = commentIdToDelete ? await resolveCommentIdToDelete(commentIdToDelete) : undefined + if (!resolvedCommentId) { + console.warn('Comment is missing a server id. Try again later.') + return activities + } - await deleteComment(token, commentIdToDelete) + await deleteComment(token, resolvedCommentId) return await fetcher(cacheKey) }, { @@ -217,32 +225,35 @@ export const ActivityWrapper = ({ ) : ( - {activities?.data?.map((item: LogResponse, index: number) => ( - - - {item.type === ActivityType.COMMENT_ADDED ? ( - - handleDeleteComment(commentId, item.id, replyId, softDelete) - } - task_id={task_id} - stableId={z.string().parse(item.details.id) ?? item.id} - optimisticUpdates={optimisticUpdates} - /> - ) : Object.keys(item).length === 0 ? null : ( - - )} - - - ))} + {activities?.data?.map((item: LogResponse, index: number) => { + const commentActivityId = getCommentActivityId(item.details) + return ( + + + {item.type === ActivityType.COMMENT_ADDED ? ( + + handleDeleteComment(commentId, item.id, replyId, softDelete) + } + task_id={task_id} + stableId={commentActivityId ?? item.id} + optimisticUpdates={optimisticUpdates} + /> + ) : Object.keys(item).length === 0 ? null : ( + + )} + + + ) + })} diff --git a/src/components/cards/CommentCard.tsx b/src/components/cards/CommentCard.tsx index d408aed74..ea72d36f2 100644 --- a/src/components/cards/CommentCard.tsx +++ b/src/components/cards/CommentCard.tsx @@ -32,6 +32,7 @@ import { getAssigneeName } from '@/utils/assignee' import { deleteEditorAttachmentsHandler, getAttachmentPayload, getCustomFilePath } from '@/utils/attachmentUtils' import { createUploadFn } from '@/utils/createUploadFn' import { fetcher } from '@/utils/fetcher' +import { getCommentActivityId, isPendingCommentId } from '@/utils/commentActivity' import { getTimeDifference } from '@/utils/getTimeDifference' import { isTapwriteContentEmpty } from '@/utils/isTapwriteContentEmpty' import { checkOptimisticStableId, OptimisticUpdate } from '@/utils/optimisticCommentUtils' @@ -85,6 +86,7 @@ export const CommentCard = ({ const [deletedReplies, setDeletedReplies] = useState([]) const { postAttachment } = usePostAttachment() + const commentId = getCommentActivityId(comment.details) const windowWidth = useWindowWidth() const isMobile = () => { @@ -111,11 +113,11 @@ export const CommentCard = ({ return () => clearInterval(intervalId) }, [comment.createdAt]) - const commentIdRef = useRef(comment.details.id) + const commentIdRef = useRef(commentId) useEffect(() => { - commentIdRef.current = comment.details.id - }, [comment.details.id]) //done because tapwrite only takes uploadFn once on mount where commentId will be temp from optimistic update. So we need an actual commentId for uploadFn to work. + commentIdRef.current = commentId + }, [commentId]) //done because tapwrite only takes uploadFn once on mount where commentId will be temp from optimistic update. So we need an actual commentId for uploadFn to work. const uploadFn = createUploadFn({ token, @@ -139,7 +141,7 @@ export const CommentCard = ({ setIsReadOnly(true) return } - const commentId = z.string().parse(comment.details.id) + const commentId = z.string().parse(commentIdRef.current) const updateCommentPayload: UpdateComment = { content: editedContent, // mentions : add mentions in the future @@ -173,7 +175,7 @@ export const CommentCard = ({ const replyCount = (comment.details as CommentResponse).replyCount - const cacheKey = `/api/comments/?token=${token}&parentId=${comment.details.id}` + const cacheKey = commentId ? `/api/comments/?token=${token}&parentId=${commentId}` : null const { trigger } = useSWRMutation(cacheKey, fetcher, { optimisticData: optimisticUpdates.filter((update) => update.tempId), }) @@ -182,7 +184,9 @@ export const CommentCard = ({ const updatedComment = await trigger() setReplies(updatedComment?.comments || comment.details.replies || []) - store.dispatch(setExpandedComments([...expandedComments, z.string().parse(comment.details.id ?? '')])) + if (commentId) { + store.dispatch(setExpandedComments([...expandedComments, commentId])) + } } catch (error) { console.error('Failed to fetch replies:', error) } @@ -191,7 +195,7 @@ export const CommentCard = ({ useEffect(() => { const replies = (comment.details.replies as ReplyResponse[]) || [] - if (expandedComments.length && expandedComments.includes(z.string().parse(comment.details.id))) { + if (expandedComments.length && commentId && expandedComments.includes(commentId)) { const lastReply = replies[replies.length - 1] if (deletedReplies.length > 0) { const pendingReplyToBeRemoved = deletedReplies[0] @@ -199,7 +203,7 @@ export const CommentCard = ({ setDeletedReplies((prev) => prev.slice(1)) return } //handle optimistic updates on reply deletion when view all button is active. - if (lastReply && lastReply.id.includes('temp-comment')) { + if (lastReply && isPendingCommentId(getCommentActivityId(lastReply))) { setReplies((prev) => [...prev, lastReply]) return } //handle optimistic updates on reply creation when view all button is active. @@ -359,7 +363,7 @@ export const CommentCard = ({ {((Array.isArray((comment as LogResponse).details?.replies) && ((comment as LogResponse).details.replies as ReplyResponse[]).length > 0) || showReply) && } - {replyCount > 3 && !expandedComments.includes(z.string().parse(comment.details.id)) && ( + {replyCount > 3 && commentId && !expandedComments.includes(commentId) && ( - {(Array.isArray((comment as LogResponse).details?.replies) && + {commentId && + ((Array.isArray((comment as LogResponse).details?.replies) && ((comment as LogResponse).details.replies as LogResponse[]).length > 0) || - showReply ? ( + showReply) ? ( setShowConfirmDeleteModal(false)} handleDelete={() => { - deleteComment((comment as LogResponse).details.id as string, undefined, replies.length > 0) + if (commentId) { + deleteComment(commentId, undefined, replies.length > 0) + } setShowConfirmDeleteModal(false) }} bodyTag="comment" diff --git a/src/components/inputs/ReplyInput.tsx b/src/components/inputs/ReplyInput.tsx index 0d35e313f..3927a6d2e 100644 --- a/src/components/inputs/ReplyInput.tsx +++ b/src/components/inputs/ReplyInput.tsx @@ -14,12 +14,13 @@ import { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } fr import { useSelector } from 'react-redux' import { Tapwrite } from 'tapwrite' import { createUploadFn } from '@/utils/createUploadFn' +import { getCommentActivityId, isPendingCommentId } from '@/utils/commentActivity' import { AttachmentTypes } from '@/types/interfaces' interface ReplyInputProps { token: string task_id: string - comment: any + comment: { details?: { id?: unknown } } createComment: (postCommentPayload: CreateComment) => void focusReplyInput: boolean setFocusReplyInput: Dispatch> @@ -45,6 +46,7 @@ export const ReplyInput = ({ const currentUserId = tokenPayload?.internalUserId ?? tokenPayload?.clientId const currentUserDetails = assignee.find((el) => el.id === currentUserId) const [pendingReplies, setPendingReplies] = useState<{ content: string; taskId: string }[]>([]) + const commentId = getCommentActivityId(comment.details) const handleReplySubmission = useCallback(() => { let content = detail @@ -57,19 +59,19 @@ export const ReplyInput = ({ setDetail('') setPendingReplies((prev) => [...prev, { content, taskId: task_id }]) } - }, [comment, detail, task_id]) + }, [detail, task_id]) useEffect(() => { - if (pendingReplies.length > 0 && !comment.details.id.includes('temp-comment')) { - const { content, taskId } = pendingReplies[0] - createComment({ - content, - taskId, - parentId: comment.details.id, - }) - setPendingReplies((prev) => prev.slice(1)) //handling the reply submission 1 by 1 - } - }, [comment.details.id, pendingReplies]) + if (pendingReplies.length === 0 || !commentId || isPendingCommentId(commentId)) return + + const { content, taskId } = pendingReplies[0] + createComment({ + content, + taskId, + parentId: commentId, + }) + setPendingReplies((prev) => prev.slice(1)) //handling the reply submission 1 by 1 + }, [commentId, pendingReplies]) useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { diff --git a/src/utils/commentActivity.test.ts b/src/utils/commentActivity.test.ts new file mode 100644 index 000000000..de824f0a0 --- /dev/null +++ b/src/utils/commentActivity.test.ts @@ -0,0 +1,26 @@ +import { getCommentActivityId, isPendingCommentId } from '@/utils/commentActivity' + +describe('commentActivity utils', () => { + describe('getCommentActivityId', () => { + it('returns a string id when activity details contain one', () => { + expect(getCommentActivityId({ id: 'comment-id' })).toBe('comment-id') + }) + + it('returns undefined for missing, empty, or non-string ids', () => { + expect(getCommentActivityId(undefined)).toBeUndefined() + expect(getCommentActivityId({})).toBeUndefined() + expect(getCommentActivityId({ id: '' })).toBeUndefined() + expect(getCommentActivityId({ id: 123 })).toBeUndefined() + }) + }) + + describe('isPendingCommentId', () => { + it('detects optimistic comment ids', () => { + expect(isPendingCommentId('temp-comment-123')).toBe(true) + }) + + it('treats missing ids as not pending without throwing', () => { + expect(isPendingCommentId(undefined)).toBe(false) + }) + }) +}) diff --git a/src/utils/commentActivity.ts b/src/utils/commentActivity.ts new file mode 100644 index 000000000..4cd053ce1 --- /dev/null +++ b/src/utils/commentActivity.ts @@ -0,0 +1,4 @@ +export const getCommentActivityId = (details: { id?: unknown } | undefined): string | undefined => + typeof details?.id === 'string' && details.id.length > 0 ? details.id : undefined + +export const isPendingCommentId = (commentId: string | undefined): boolean => commentId?.includes('temp-comment') ?? false