Added comment functionality
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth, useProject } from '@/hooks'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { requirementService, validationService, relationshipService } from '@/services'
|
||||
import { requirementService, validationService, relationshipService, commentService } from '@/services'
|
||||
import type { Requirement } from '@/services/requirementService'
|
||||
import type { RelationshipType, RequirementLink, RequirementSearchResult } from '@/services/relationshipService'
|
||||
import type { ValidationStatus, ValidationHistory } from '@/types'
|
||||
import type { ValidationStatus, ValidationHistory, Comment } from '@/types'
|
||||
|
||||
// Tab types
|
||||
type TabType = 'description' | 'relationships' | 'acceptance-criteria' | 'shared-comments' | 'validate'
|
||||
@@ -41,6 +41,17 @@ export default function RequirementDetailPage() {
|
||||
const [addLinkError, setAddLinkError] = useState<string | null>(null)
|
||||
const [deletingLinkId, setDeletingLinkId] = useState<number | null>(null)
|
||||
|
||||
// Comments state
|
||||
const [comments, setComments] = useState<Comment[]>([])
|
||||
const [commentsLoading, setCommentsLoading] = useState(false)
|
||||
const [newCommentText, setNewCommentText] = useState('')
|
||||
const [postingComment, setPostingComment] = useState(false)
|
||||
const [replyingToCommentId, setReplyingToCommentId] = useState<number | null>(null)
|
||||
const [replyText, setReplyText] = useState('')
|
||||
const [postingReply, setPostingReply] = useState(false)
|
||||
const [deletingCommentId, setDeletingCommentId] = useState<number | null>(null)
|
||||
const [deletingReplyId, setDeletingReplyId] = useState<number | null>(null)
|
||||
|
||||
// Fetch requirement data on mount
|
||||
useEffect(() => {
|
||||
const fetchRequirement = async () => {
|
||||
@@ -112,6 +123,25 @@ export default function RequirementDetailPage() {
|
||||
fetchRelationshipsData()
|
||||
}, [activeTab, id, currentProject])
|
||||
|
||||
// Fetch comments data when shared-comments tab is active
|
||||
useEffect(() => {
|
||||
const fetchCommentsData = async () => {
|
||||
if (activeTab !== 'shared-comments' || !id) return
|
||||
|
||||
try {
|
||||
setCommentsLoading(true)
|
||||
const commentsData = await commentService.getComments(parseInt(id, 10))
|
||||
setComments(commentsData)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch comments:', err)
|
||||
} finally {
|
||||
setCommentsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchCommentsData()
|
||||
}, [activeTab, id])
|
||||
|
||||
// Debounced search for target requirements
|
||||
useEffect(() => {
|
||||
if (!showAddRelationshipModal || !currentProject || !id) return
|
||||
@@ -247,6 +277,107 @@ export default function RequirementDetailPage() {
|
||||
return user.role_id === 1 || link.created_by_id === user.db_user_id
|
||||
}
|
||||
|
||||
// Check if user can delete a comment or reply (author or admin)
|
||||
const canDelete = (authorId: number | null): boolean => {
|
||||
if (!user) return false
|
||||
return user.role_id === 3 || authorId === user.db_user_id
|
||||
}
|
||||
|
||||
// Get display name for comment author
|
||||
const getAuthorDisplayName = (fullName: string | null, username: string | null): string => {
|
||||
return fullName || username || 'Unknown User'
|
||||
}
|
||||
|
||||
// Get role display name
|
||||
const getRoleDisplayName = (role: string | null): string => {
|
||||
if (!role) return 'User'
|
||||
const roleDisplayNames: Record<string, string> = {
|
||||
'editor': 'Editor',
|
||||
'auditor': 'Auditor',
|
||||
'admin': 'Project Admin'
|
||||
}
|
||||
return roleDisplayNames[role] || role
|
||||
}
|
||||
|
||||
// Handle posting a new comment
|
||||
const handlePostComment = async () => {
|
||||
if (!newCommentText.trim() || !id) return
|
||||
|
||||
try {
|
||||
setPostingComment(true)
|
||||
const newComment = await commentService.createComment(parseInt(id, 10), {
|
||||
comment_text: newCommentText.trim()
|
||||
})
|
||||
setComments(prev => [...prev, newComment])
|
||||
setNewCommentText('')
|
||||
} catch (err) {
|
||||
console.error('Failed to post comment:', err)
|
||||
alert('Failed to post comment. Please try again.')
|
||||
} finally {
|
||||
setPostingComment(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle posting a reply
|
||||
const handlePostReply = async (commentId: number) => {
|
||||
if (!replyText.trim()) return
|
||||
|
||||
try {
|
||||
setPostingReply(true)
|
||||
const newReply = await commentService.createReply(commentId, {
|
||||
reply_text: replyText.trim()
|
||||
})
|
||||
setComments(prev => prev.map(c =>
|
||||
c.id === commentId
|
||||
? { ...c, replies: [...c.replies, newReply] }
|
||||
: c
|
||||
))
|
||||
setReplyText('')
|
||||
setReplyingToCommentId(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to post reply:', err)
|
||||
alert('Failed to post reply. Please try again.')
|
||||
} finally {
|
||||
setPostingReply(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a comment
|
||||
const handleDeleteComment = async (commentId: number) => {
|
||||
if (!confirm('Are you sure you want to delete this comment? This will also hide all replies.')) return
|
||||
|
||||
try {
|
||||
setDeletingCommentId(commentId)
|
||||
await commentService.deleteComment(commentId)
|
||||
setComments(prev => prev.filter(c => c.id !== commentId))
|
||||
} catch (err) {
|
||||
console.error('Failed to delete comment:', err)
|
||||
alert('Failed to delete comment. Please try again.')
|
||||
} finally {
|
||||
setDeletingCommentId(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a reply
|
||||
const handleDeleteReply = async (replyId: number, commentId: number) => {
|
||||
if (!confirm('Are you sure you want to delete this reply?')) return
|
||||
|
||||
try {
|
||||
setDeletingReplyId(replyId)
|
||||
await commentService.deleteReply(replyId)
|
||||
setComments(prev => prev.map(c =>
|
||||
c.id === commentId
|
||||
? { ...c, replies: c.replies.filter(r => r.id !== replyId) }
|
||||
: c
|
||||
))
|
||||
} catch (err) {
|
||||
console.error('Failed to delete reply:', err)
|
||||
alert('Failed to delete reply. Please try again.')
|
||||
} finally {
|
||||
setDeletingReplyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Get validation status style
|
||||
const getValidationStatusStyle = (status: string): string => {
|
||||
switch (status) {
|
||||
@@ -461,17 +592,157 @@ export default function RequirementDetailPage() {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-4">Shared Comments</h3>
|
||||
<p className="text-gray-500">No comments yet.</p>
|
||||
<div className="mt-4">
|
||||
|
||||
{/* New Comment Form */}
|
||||
<div className="mb-6 p-4 border border-gray-200 rounded bg-gray-50">
|
||||
<textarea
|
||||
value={newCommentText}
|
||||
onChange={(e) => setNewCommentText(e.target.value)}
|
||||
placeholder="Add a comment..."
|
||||
className="w-full p-3 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 mb-2"
|
||||
className="w-full p-3 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none"
|
||||
rows={3}
|
||||
disabled={postingComment}
|
||||
/>
|
||||
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
|
||||
Post Comment
|
||||
</button>
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
onClick={handlePostComment}
|
||||
disabled={postingComment || !newCommentText.trim()}
|
||||
className="px-4 py-1.5 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{postingComment ? 'Posting...' : 'Post Comment'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comments List */}
|
||||
{commentsLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600 mx-auto"></div>
|
||||
<p className="mt-2 text-gray-500 text-sm">Loading comments...</p>
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">No comments yet. Be the first to comment!</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.id} className="border border-gray-200 rounded">
|
||||
{/* Main Comment */}
|
||||
<div className="p-4 bg-white">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Author Info */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-semibold text-gray-800">
|
||||
{getAuthorDisplayName(comment.author_full_name, comment.author_username)}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">
|
||||
{getRoleDisplayName(comment.author_role)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{comment.created_at
|
||||
? new Date(comment.created_at).toLocaleString()
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Comment Text */}
|
||||
<p className="text-gray-700 whitespace-pre-wrap">{comment.comment_text}</p>
|
||||
</div>
|
||||
|
||||
{/* Delete Button */}
|
||||
{canDelete(comment.author_id) && (
|
||||
<button
|
||||
onClick={() => handleDeleteComment(comment.id)}
|
||||
disabled={deletingCommentId === comment.id}
|
||||
className="p-1 text-gray-400 hover:text-red-600 disabled:opacity-50"
|
||||
title="Delete comment"
|
||||
>
|
||||
{deletingCommentId === comment.id ? '⏳' : '🗑️'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reply Button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setReplyingToCommentId(replyingToCommentId === comment.id ? null : comment.id)
|
||||
setReplyText('')
|
||||
}}
|
||||
className="mt-2 text-xs text-teal-600 hover:text-teal-700 font-medium"
|
||||
>
|
||||
{replyingToCommentId === comment.id ? 'Cancel Reply' : 'Reply'}
|
||||
</button>
|
||||
|
||||
{/* Reply Form */}
|
||||
{replyingToCommentId === comment.id && (
|
||||
<div className="mt-3 pl-4 border-l-2 border-teal-200">
|
||||
<textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
placeholder="Write a reply..."
|
||||
className="w-full p-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none"
|
||||
rows={2}
|
||||
disabled={postingReply}
|
||||
/>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button
|
||||
onClick={() => handlePostReply(comment.id)}
|
||||
disabled={postingReply || !replyText.trim()}
|
||||
className="px-3 py-1 bg-teal-600 text-white rounded text-xs font-medium hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{postingReply ? 'Posting...' : 'Post Reply'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Replies */}
|
||||
{comment.replies.length > 0 && (
|
||||
<div className="border-t border-gray-200 bg-gray-50">
|
||||
{comment.replies.map((reply) => (
|
||||
<div key={reply.id} className="p-4 pl-8 border-b border-gray-100 last:border-b-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Reply Author Info */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-semibold text-gray-700 text-sm">
|
||||
{getAuthorDisplayName(reply.author_full_name, reply.author_username)}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-gray-200 text-gray-600 rounded">
|
||||
{getRoleDisplayName(reply.author_role)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{reply.created_at
|
||||
? new Date(reply.created_at).toLocaleString()
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Reply Text */}
|
||||
<p className="text-gray-600 text-sm whitespace-pre-wrap">{reply.reply_text}</p>
|
||||
</div>
|
||||
|
||||
{/* Reply Delete Button */}
|
||||
{canDelete(reply.author_id) && (
|
||||
<button
|
||||
onClick={() => handleDeleteReply(reply.id, comment.id)}
|
||||
disabled={deletingReplyId === reply.id}
|
||||
className="p-1 text-gray-400 hover:text-red-600 disabled:opacity-50"
|
||||
title="Delete reply"
|
||||
>
|
||||
{deletingReplyId === reply.id ? '⏳' : '🗑️'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
128
frontend/src/services/commentService.ts
Normal file
128
frontend/src/services/commentService.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { Comment, CommentReply, CommentCreateRequest, ReplyCreateRequest } from '@/types'
|
||||
|
||||
const API_BASE_URL = '/api'
|
||||
|
||||
class CommentService {
|
||||
/**
|
||||
* Get all comments for a requirement with their replies.
|
||||
*/
|
||||
async getComments(requirementId: number): Promise<Comment[]> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/comments`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const comments: Comment[] = await response.json()
|
||||
return comments
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch comments:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new comment on a requirement.
|
||||
*/
|
||||
async createComment(requirementId: number, data: CommentCreateRequest): Promise<Comment> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/comments`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const comment: Comment = await response.json()
|
||||
return comment
|
||||
} catch (error) {
|
||||
console.error('Failed to create comment:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a comment (soft delete).
|
||||
*/
|
||||
async deleteComment(commentId: number): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/comments/${commentId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete comment:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reply to a comment.
|
||||
*/
|
||||
async createReply(commentId: number, data: ReplyCreateRequest): Promise<CommentReply> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/comments/${commentId}/replies`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const reply: CommentReply = await response.json()
|
||||
return reply
|
||||
} catch (error) {
|
||||
console.error('Failed to create reply:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a reply (soft delete).
|
||||
*/
|
||||
async deleteReply(replyId: number): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/replies/${replyId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete reply:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const commentService = new CommentService()
|
||||
@@ -21,3 +21,4 @@ export type {
|
||||
} from './relationshipService'
|
||||
export { userService } from './userService'
|
||||
export type { Role, ProjectMember, UserRoleUpdateRequest } from './userService'
|
||||
export { commentService } from './commentService'
|
||||
|
||||
@@ -21,3 +21,33 @@ export interface ValidationCreateRequest {
|
||||
status_id: number
|
||||
comment?: string
|
||||
}
|
||||
|
||||
// Comment types
|
||||
export interface CommentReply {
|
||||
id: number
|
||||
reply_text: string
|
||||
created_at: string | null
|
||||
author_id: number | null
|
||||
author_username: string | null
|
||||
author_full_name: string | null
|
||||
author_role: string | null
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: number
|
||||
comment_text: string
|
||||
created_at: string | null
|
||||
author_id: number | null
|
||||
author_username: string | null
|
||||
author_full_name: string | null
|
||||
author_role: string | null
|
||||
replies: CommentReply[]
|
||||
}
|
||||
|
||||
export interface CommentCreateRequest {
|
||||
comment_text: string
|
||||
}
|
||||
|
||||
export interface ReplyCreateRequest {
|
||||
reply_text: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user