mirror of
https://github.com/langgenius/dify.git
synced 2026-04-29 04:26:30 +08:00
can edit and delete a reply
This commit is contained in:
parent
7e86ead574
commit
29178d8adf
@ -1,10 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useReactFlow, useViewport } from 'reactflow'
|
import { useReactFlow, useViewport } from 'reactflow'
|
||||||
import { RiArrowDownSLine, RiArrowUpSLine, RiCheckboxCircleFill, RiCheckboxCircleLine, RiCloseLine, RiDeleteBinLine } from '@remixicon/react'
|
import { RiArrowDownSLine, RiArrowUpSLine, RiCheckboxCircleFill, RiCheckboxCircleLine, RiCloseLine, RiDeleteBinLine, RiMoreFill } from '@remixicon/react'
|
||||||
|
import Textarea from 'react-textarea-autosize'
|
||||||
import Avatar from '@/app/components/base/avatar'
|
import Avatar from '@/app/components/base/avatar'
|
||||||
|
import Button from '@/app/components/base/button'
|
||||||
import Divider from '@/app/components/base/divider'
|
import Divider from '@/app/components/base/divider'
|
||||||
import cn from '@/utils/classnames'
|
import cn from '@/utils/classnames'
|
||||||
import { useFormatTimeFromNow } from '@/app/components/workflow/hooks'
|
import { useFormatTimeFromNow } from '@/app/components/workflow/hooks'
|
||||||
@ -23,6 +26,8 @@ type CommentThreadProps = {
|
|||||||
canGoPrev?: boolean
|
canGoPrev?: boolean
|
||||||
canGoNext?: boolean
|
canGoNext?: boolean
|
||||||
onReply?: (content: string, mentionedUserIds?: string[]) => Promise<void> | void
|
onReply?: (content: string, mentionedUserIds?: string[]) => Promise<void> | void
|
||||||
|
onReplyEdit?: (replyId: string, content: string, mentionedUserIds?: string[]) => Promise<void> | void
|
||||||
|
onReplyDelete?: (replyId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThreadMessage: FC<{
|
const ThreadMessage: FC<{
|
||||||
@ -56,16 +61,6 @@ const ThreadMessage: FC<{
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderReply = (reply: WorkflowCommentDetailReply) => (
|
|
||||||
<ThreadMessage
|
|
||||||
key={reply.id}
|
|
||||||
authorName={reply.created_by_account?.name || 'User'}
|
|
||||||
avatarUrl={reply.created_by_account?.avatar_url || null}
|
|
||||||
createdAt={reply.created_at}
|
|
||||||
content={reply.content}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
export const CommentThread: FC<CommentThreadProps> = memo(({
|
export const CommentThread: FC<CommentThreadProps> = memo(({
|
||||||
comment,
|
comment,
|
||||||
loading = false,
|
loading = false,
|
||||||
@ -77,11 +72,16 @@ export const CommentThread: FC<CommentThreadProps> = memo(({
|
|||||||
canGoPrev,
|
canGoPrev,
|
||||||
canGoNext,
|
canGoNext,
|
||||||
onReply,
|
onReply,
|
||||||
|
onReplyEdit,
|
||||||
|
onReplyDelete,
|
||||||
}) => {
|
}) => {
|
||||||
|
const params = useParams()
|
||||||
const { flowToScreenPosition } = useReactFlow()
|
const { flowToScreenPosition } = useReactFlow()
|
||||||
const viewport = useViewport()
|
const viewport = useViewport()
|
||||||
const { userProfile } = useAppContext()
|
const { userProfile } = useAppContext()
|
||||||
const [replyContent, setReplyContent] = useState('')
|
const [replyContent, setReplyContent] = useState('')
|
||||||
|
const [activeReplyMenuId, setActiveReplyMenuId] = useState<string | null>(null)
|
||||||
|
const [editingReply, setEditingReply] = useState<{ id: string; content: string }>({ id: '', content: '' })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setReplyContent('')
|
setReplyContent('')
|
||||||
@ -106,6 +106,25 @@ export const CommentThread: FC<CommentThreadProps> = memo(({
|
|||||||
})
|
})
|
||||||
}, [comment.position_x, comment.position_y, viewport.x, viewport.y, viewport.zoom, flowToScreenPosition])
|
}, [comment.position_x, comment.position_y, viewport.x, viewport.y, viewport.zoom, flowToScreenPosition])
|
||||||
|
|
||||||
|
const handleStartEdit = useCallback((reply: WorkflowCommentDetailReply) => {
|
||||||
|
setEditingReply({ id: reply.id, content: reply.content })
|
||||||
|
setActiveReplyMenuId(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCancelEdit = useCallback(() => {
|
||||||
|
setEditingReply({ id: '', content: '' })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSaveEdit = useCallback(async () => {
|
||||||
|
if (!onReplyEdit || !editingReply) return
|
||||||
|
const trimmed = editingReply.content.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
await onReplyEdit(editingReply.id, trimmed, [])
|
||||||
|
setEditingReply({ id: '', content: '' })
|
||||||
|
}, [editingReply, onReplyEdit])
|
||||||
|
|
||||||
|
const replies = comment.replies || []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className='absolute z-50 w-[360px] max-w-[360px]'
|
className='absolute z-50 w-[360px] max-w-[360px]'
|
||||||
@ -173,9 +192,72 @@ export const CommentThread: FC<CommentThreadProps> = memo(({
|
|||||||
createdAt={comment.created_at}
|
createdAt={comment.created_at}
|
||||||
content={comment.content}
|
content={comment.content}
|
||||||
/>
|
/>
|
||||||
{comment.replies?.length > 0 && (
|
{replies.length > 0 && (
|
||||||
<div className='mt-3 space-y-3 pt-3'>
|
<div className='mt-2 space-y-3 pt-3'>
|
||||||
{comment.replies.map(renderReply)}
|
{replies.map((reply) => {
|
||||||
|
const isReplyEditing = editingReply?.id === reply.id
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={reply.id}
|
||||||
|
className='group relative rounded-lg py-2 transition-colors hover:bg-components-panel-on-panel-item-bg'
|
||||||
|
>
|
||||||
|
<div className='absolute right-1 top-1 hidden gap-1 group-hover:flex'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='flex h-6 w-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary'
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setActiveReplyMenuId(prev => prev === reply.id ? null : reply.id)
|
||||||
|
}}
|
||||||
|
aria-label='Reply actions'
|
||||||
|
>
|
||||||
|
<RiMoreFill className='h-4 w-4' />
|
||||||
|
</button>
|
||||||
|
{activeReplyMenuId === reply.id && (
|
||||||
|
<div className='absolute right-0 top-7 z-40 w-36 rounded-lg border border-components-panel-border bg-components-panel-bg shadow-lg'>
|
||||||
|
<button
|
||||||
|
className='flex w-full items-center justify-start px-3 py-2 text-left text-sm text-text-secondary hover:bg-state-base-hover'
|
||||||
|
onClick={() => handleStartEdit(reply)}
|
||||||
|
>
|
||||||
|
Edit reply
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className='text-negative flex w-full items-center justify-start px-3 py-2 text-left text-sm hover:bg-state-base-hover'
|
||||||
|
onClick={() => {
|
||||||
|
setActiveReplyMenuId(null)
|
||||||
|
onReplyDelete?.(reply.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete reply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isReplyEditing ? (
|
||||||
|
<div className='rounded-lg border border-components-chat-input-border bg-components-panel-bg-blur px-3 py-2 shadow-sm'>
|
||||||
|
<Textarea
|
||||||
|
minRows={1}
|
||||||
|
maxRows={4}
|
||||||
|
value={editingReply?.content ?? ''}
|
||||||
|
onChange={e => setEditingReply(prev => prev ? { ...prev, content: e.target.value } : prev)}
|
||||||
|
className='system-sm-regular w-full resize-none bg-transparent text-text-primary caret-primary-500 outline-none'
|
||||||
|
/>
|
||||||
|
<div className='mt-2 flex items-center justify-end gap-2'>
|
||||||
|
<Button variant='secondary' size='small' onClick={handleCancelEdit} disabled={loading}>Cancel</Button>
|
||||||
|
<Button variant='primary' size='small' disabled={loading || !(editingReply?.content?.trim())} onClick={handleSaveEdit}>Save</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ThreadMessage
|
||||||
|
authorName={reply.created_by_account?.name || 'User'}
|
||||||
|
avatarUrl={reply.created_by_account?.avatar_url || null}
|
||||||
|
createdAt={reply.created_at}
|
||||||
|
content={reply.content}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { useReactFlow } from 'reactflow'
|
|||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
import { ControlMode } from '../types'
|
import { ControlMode } from '../types'
|
||||||
import type { WorkflowCommentDetail, WorkflowCommentList } from '@/service/workflow-comment'
|
import type { WorkflowCommentDetail, WorkflowCommentList } from '@/service/workflow-comment'
|
||||||
import { createWorkflowComment, createWorkflowCommentReply, deleteWorkflowComment, fetchWorkflowComment, fetchWorkflowComments, resolveWorkflowComment } from '@/service/workflow-comment'
|
import { createWorkflowComment, createWorkflowCommentReply, deleteWorkflowComment, deleteWorkflowCommentReply, fetchWorkflowComment, fetchWorkflowComments, resolveWorkflowComment, updateWorkflowCommentReply } from '@/service/workflow-comment'
|
||||||
|
|
||||||
export const useWorkflowComment = () => {
|
export const useWorkflowComment = () => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@ -217,6 +217,42 @@ export const useWorkflowComment = () => {
|
|||||||
}
|
}
|
||||||
}, [appId, loadComments, refreshActiveComment, setActiveCommentLoading])
|
}, [appId, loadComments, refreshActiveComment, setActiveCommentLoading])
|
||||||
|
|
||||||
|
const handleCommentReplyUpdate = useCallback(async (commentId: string, replyId: string, content: string, mentionedUserIds: string[] = []) => {
|
||||||
|
if (!appId) return
|
||||||
|
const trimmed = content.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
|
||||||
|
setActiveCommentLoading(true)
|
||||||
|
try {
|
||||||
|
await updateWorkflowCommentReply(appId, commentId, replyId, { content: trimmed, mentioned_user_ids: mentionedUserIds })
|
||||||
|
await refreshActiveComment(commentId)
|
||||||
|
await loadComments()
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('Failed to update reply:', error)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setActiveCommentLoading(false)
|
||||||
|
}
|
||||||
|
}, [appId, loadComments, refreshActiveComment, setActiveCommentLoading])
|
||||||
|
|
||||||
|
const handleCommentReplyDelete = useCallback(async (commentId: string, replyId: string) => {
|
||||||
|
if (!appId) return
|
||||||
|
|
||||||
|
setActiveCommentLoading(true)
|
||||||
|
try {
|
||||||
|
await deleteWorkflowCommentReply(appId, commentId, replyId)
|
||||||
|
await refreshActiveComment(commentId)
|
||||||
|
await loadComments()
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('Failed to delete reply:', error)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setActiveCommentLoading(false)
|
||||||
|
}
|
||||||
|
}, [appId, loadComments, refreshActiveComment, setActiveCommentLoading])
|
||||||
|
|
||||||
const handleCommentNavigate = useCallback((direction: 'prev' | 'next') => {
|
const handleCommentNavigate = useCallback((direction: 'prev' | 'next') => {
|
||||||
const currentId = activeCommentIdRef.current
|
const currentId = activeCommentIdRef.current
|
||||||
if (!currentId) return
|
if (!currentId) return
|
||||||
@ -261,6 +297,9 @@ export const useWorkflowComment = () => {
|
|||||||
handleCommentDelete,
|
handleCommentDelete,
|
||||||
handleCommentNavigate,
|
handleCommentNavigate,
|
||||||
handleCommentReply,
|
handleCommentReply,
|
||||||
|
handleCommentReplyUpdate,
|
||||||
|
handleCommentReplyDelete,
|
||||||
|
refreshActiveComment,
|
||||||
handleCreateComment,
|
handleCreateComment,
|
||||||
loadComments,
|
loadComments,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -118,6 +118,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
|||||||
const reactflow = useReactFlow()
|
const reactflow = useReactFlow()
|
||||||
const [isMouseOverCanvas, setIsMouseOverCanvas] = useState(false)
|
const [isMouseOverCanvas, setIsMouseOverCanvas] = useState(false)
|
||||||
const [pendingDeleteCommentId, setPendingDeleteCommentId] = useState<string | null>(null)
|
const [pendingDeleteCommentId, setPendingDeleteCommentId] = useState<string | null>(null)
|
||||||
|
const [pendingDeleteReply, setPendingDeleteReply] = useState<{ commentId: string; replyId: string } | null>(null)
|
||||||
const [nodes, setNodes] = useNodesState(originalNodes)
|
const [nodes, setNodes] = useNodesState(originalNodes)
|
||||||
const [edges, setEdges] = useEdgesState(originalEdges)
|
const [edges, setEdges] = useEdgesState(originalEdges)
|
||||||
const controlMode = useStore(s => s.controlMode)
|
const controlMode = useStore(s => s.controlMode)
|
||||||
@ -175,6 +176,8 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
|||||||
handleCommentDelete,
|
handleCommentDelete,
|
||||||
handleCommentNavigate,
|
handleCommentNavigate,
|
||||||
handleCommentReply,
|
handleCommentReply,
|
||||||
|
handleCommentReplyUpdate,
|
||||||
|
handleCommentReplyDelete,
|
||||||
} = useWorkflowComment()
|
} = useWorkflowComment()
|
||||||
const mousePosition = useStore(s => s.mousePosition)
|
const mousePosition = useStore(s => s.mousePosition)
|
||||||
|
|
||||||
@ -358,6 +361,18 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{pendingDeleteReply && (
|
||||||
|
<Confirm
|
||||||
|
isShow
|
||||||
|
title='Delete this reply?'
|
||||||
|
content='This reply will be removed permanently.'
|
||||||
|
onCancel={() => setPendingDeleteReply(null)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
await handleCommentReplyDelete(pendingDeleteReply.commentId, pendingDeleteReply.replyId)
|
||||||
|
setPendingDeleteReply(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<LimitTips />
|
<LimitTips />
|
||||||
{controlMode === ControlMode.Comment && isMouseOverCanvas && (
|
{controlMode === ControlMode.Comment && isMouseOverCanvas && (
|
||||||
<CommentCursor mousePosition={mousePosition} />
|
<CommentCursor mousePosition={mousePosition} />
|
||||||
@ -386,6 +401,8 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
|||||||
onPrev={canGoPrev ? () => handleCommentNavigate('prev') : undefined}
|
onPrev={canGoPrev ? () => handleCommentNavigate('prev') : undefined}
|
||||||
onNext={canGoNext ? () => handleCommentNavigate('next') : undefined}
|
onNext={canGoNext ? () => handleCommentNavigate('next') : undefined}
|
||||||
onReply={(content, ids) => handleCommentReply(comment.id, content, ids ?? [])}
|
onReply={(content, ids) => handleCommentReply(comment.id, content, ids ?? [])}
|
||||||
|
onReplyEdit={(replyId, content, ids) => handleCommentReplyUpdate(comment.id, replyId, content, ids ?? [])}
|
||||||
|
onReplyDelete={replyId => setPendingDeleteReply({ commentId: comment.id, replyId })}
|
||||||
canGoPrev={canGoPrev}
|
canGoPrev={canGoPrev}
|
||||||
canGoNext={canGoNext}
|
canGoNext={canGoNext}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user