mirror of
https://github.com/overleaf/overleaf.git
synced 2025-03-22 02:04:31 +00:00
Merge pull request #19985 from overleaf/dp-resolved-threads
Implement redesigned resolved threads popover GitOrigin-RevId: 4e462eb26a2f2f3194fca89c39d5f9d08ea2e33c
This commit is contained in:
parent
93ba9fa28a
commit
9416e69647
14 changed files with 497 additions and 191 deletions
|
@ -467,6 +467,7 @@
|
|||
"from_another_project": "",
|
||||
"from_enforcement_date": "",
|
||||
"from_external_url": "",
|
||||
"from_filename": "",
|
||||
"from_project_files": "",
|
||||
"from_provider": "",
|
||||
"from_url": "",
|
||||
|
@ -808,6 +809,7 @@
|
|||
"managers_management": "",
|
||||
"managing_your_subscription": "",
|
||||
"mark_as_resolved": "",
|
||||
"marked_as_resolved": "",
|
||||
"math_display": "",
|
||||
"math_inline": "",
|
||||
"maximum_files_uploaded_together": "",
|
||||
|
@ -885,6 +887,7 @@
|
|||
"no_pdf_error_title": "",
|
||||
"no_preview_available": "",
|
||||
"no_projects": "",
|
||||
"no_resolved_comments": "",
|
||||
"no_resolved_threads": "",
|
||||
"no_search_results": "",
|
||||
"no_selection_select_file": "",
|
||||
|
@ -1050,6 +1053,7 @@
|
|||
"publishing": "",
|
||||
"pull_github_changes_into_sharelatex": "",
|
||||
"push_sharelatex_changes_to_github": "",
|
||||
"quoted_text": "",
|
||||
"quoted_text_in": "",
|
||||
"raw_logs": "",
|
||||
"raw_logs_description": "",
|
||||
|
|
|
@ -0,0 +1,97 @@
|
|||
import { memo, useCallback, useState } from 'react'
|
||||
import { Change, CommentOperation } from '../../../../../types/change'
|
||||
import { ReviewPanelMessage } from './review-panel-message'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
useThreadsActionsContext,
|
||||
useThreadsContext,
|
||||
} from '../context/threads-context'
|
||||
import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
|
||||
import ReviewPanelResolvedMessage from './review-panel-resolved-message'
|
||||
import { ReviewPanelResolvedCommentThread } from '../../../../../types/review-panel/comment-thread'
|
||||
|
||||
export const ReviewPanelCommentContent = memo<{
|
||||
comment: Change<CommentOperation>
|
||||
isResolved: boolean
|
||||
}>(({ comment, isResolved }) => {
|
||||
const { t } = useTranslation()
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<Error>()
|
||||
const [content, setContent] = useState('')
|
||||
const threads = useThreadsContext()
|
||||
const { resolveThread, addMessage } = useThreadsActionsContext()
|
||||
|
||||
const handleSubmitReply = useCallback(() => {
|
||||
setSubmitting(true)
|
||||
addMessage(comment.op.t, content)
|
||||
.then(() => {
|
||||
setContent('')
|
||||
})
|
||||
.catch(error => {
|
||||
setError(error)
|
||||
})
|
||||
.finally(() => {
|
||||
setSubmitting(false)
|
||||
})
|
||||
}, [addMessage, comment.op.t, content])
|
||||
|
||||
const handleCommentReplyKeyPress = (
|
||||
e: React.KeyboardEvent<HTMLTextAreaElement>
|
||||
) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault()
|
||||
handleSubmitReply()
|
||||
}
|
||||
}
|
||||
|
||||
const thread = threads?.[comment.op.t]
|
||||
if (!thread) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="review-panel-entry-content">
|
||||
{thread.messages.map((message, i) => {
|
||||
const isReply = i !== 0
|
||||
|
||||
return (
|
||||
<div key={message.id} className="review-panel-comment-wrapper">
|
||||
{isReply && <div className="review-panel-comment-reply-divider" />}
|
||||
<ReviewPanelMessage
|
||||
message={message}
|
||||
threadId={comment.op.t}
|
||||
isReply={isReply}
|
||||
hasReplies={!isReply && thread.messages.length > 1}
|
||||
onResolve={() => resolveThread(comment.op.t)}
|
||||
isThreadResolved={isResolved}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{isResolved && (
|
||||
<div className="review-panel-comment-wrapper">
|
||||
<div className="review-panel-comment-reply-divider" />
|
||||
<ReviewPanelResolvedMessage
|
||||
thread={thread as ReviewPanelResolvedCommentThread}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isResolved && (
|
||||
<AutoExpandingTextArea
|
||||
name="content"
|
||||
className="review-panel-comment-input"
|
||||
onChange={e => setContent(e.target.value)}
|
||||
onKeyDown={handleCommentReplyKeyPress}
|
||||
placeholder={t('reply')}
|
||||
value={content}
|
||||
disabled={submitting}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && <div>{error.message}</div>}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
ReviewPanelCommentContent.displayName = 'ReviewPanelCommentContent'
|
|
@ -1,49 +1,16 @@
|
|||
import { memo, useCallback, useState } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { Change, CommentOperation } from '../../../../../types/change'
|
||||
import { ReviewPanelMessage } from './review-panel-message'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
useThreadsActionsContext,
|
||||
useThreadsContext,
|
||||
} from '../context/threads-context'
|
||||
import { useThreadsContext } from '../context/threads-context'
|
||||
import classnames from 'classnames'
|
||||
import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { ReviewPanelEntry } from './review-panel-entry'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { ReviewPanelCommentContent } from './review-panel-comment-content'
|
||||
|
||||
export const ReviewPanelComment = memo<{
|
||||
comment: Change<CommentOperation>
|
||||
top?: number
|
||||
}>(({ comment, top }) => {
|
||||
const { t } = useTranslation()
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<Error>()
|
||||
const [content, setContent] = useState('')
|
||||
const threads = useThreadsContext()
|
||||
const { resolveThread, addMessage } = useThreadsActionsContext()
|
||||
|
||||
const handleSubmitReply = useCallback(() => {
|
||||
setSubmitting(true)
|
||||
addMessage(comment.op.t, content)
|
||||
.then(() => {
|
||||
setContent('')
|
||||
})
|
||||
.catch(error => {
|
||||
setError(error)
|
||||
})
|
||||
.finally(() => {
|
||||
setSubmitting(false)
|
||||
})
|
||||
}, [addMessage, comment.op.t, content])
|
||||
|
||||
const handleCommentReplyKeyPress = (
|
||||
e: React.KeyboardEvent<HTMLTextAreaElement>
|
||||
) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault()
|
||||
handleSubmitReply()
|
||||
}
|
||||
}
|
||||
|
||||
const thread = threads?.[comment.op.t]
|
||||
if (!thread || thread.resolved) {
|
||||
|
@ -62,40 +29,7 @@ export const ReviewPanelComment = memo<{
|
|||
<div className="review-panel-entry-indicator">
|
||||
<MaterialIcon type="edit" className="review-panel-entry-icon" />
|
||||
</div>
|
||||
|
||||
<div className="review-panel-entry-content">
|
||||
{thread.messages.map((message, i) => {
|
||||
const isReply = i !== 0
|
||||
|
||||
return (
|
||||
<div key={message.id} className="review-panel-comment-wrapper">
|
||||
{isReply && (
|
||||
<div className="review-panel-comment-reply-divider" />
|
||||
)}
|
||||
|
||||
<ReviewPanelMessage
|
||||
message={message}
|
||||
threadId={comment.op.t}
|
||||
isReply={isReply}
|
||||
hasReplies={!isReply && thread.messages.length > 1}
|
||||
onResolve={() => resolveThread(comment.op.t)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<AutoExpandingTextArea
|
||||
name="content"
|
||||
className="review-panel-comment-input"
|
||||
onChange={e => setContent(e.target.value)}
|
||||
onKeyDown={handleCommentReplyKeyPress}
|
||||
placeholder={t('reply')}
|
||||
value={content}
|
||||
disabled={submitting}
|
||||
/>
|
||||
|
||||
{error && <div>{error.message}</div>}
|
||||
</div>
|
||||
<ReviewPanelCommentContent comment={comment} isResolved={false} />
|
||||
</ReviewPanelEntry>
|
||||
)
|
||||
})
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { FC, memo, useState } from 'react'
|
||||
import { ReviewPanelResolvedThreads } from './review-panel-resolved-threads'
|
||||
import { ReviewPanelResolvedThreadsButton } from './review-panel-resolved-threads-button'
|
||||
import { ReviewPanelTrackChangesMenu } from './review-panel-track-changes-menu'
|
||||
import ReviewPanelTrackChangesMenuButton from './review-panel-track-changes-menu-button'
|
||||
import { Button } from 'react-bootstrap'
|
||||
|
@ -28,7 +28,7 @@ const ReviewPanelHeader: FC<{
|
|||
</Button>
|
||||
</div>
|
||||
<div className="review-panel-tools">
|
||||
<ReviewPanelResolvedThreads />
|
||||
<ReviewPanelResolvedThreadsButton />
|
||||
<ReviewPanelTrackChangesMenuButton
|
||||
menuExpanded={trackChangesMenuExpanded}
|
||||
setMenuExpanded={setTrackChangesMenuExpanded}
|
||||
|
|
|
@ -21,7 +21,15 @@ export const ReviewPanelMessage: FC<{
|
|||
hasReplies: boolean
|
||||
isReply: boolean
|
||||
onResolve: () => void
|
||||
}> = ({ message, threadId, isReply, hasReplies, onResolve }) => {
|
||||
isThreadResolved: boolean
|
||||
}> = ({
|
||||
message,
|
||||
threadId,
|
||||
isReply,
|
||||
hasReplies,
|
||||
onResolve,
|
||||
isThreadResolved,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
@ -93,7 +101,7 @@ export const ReviewPanelMessage: FC<{
|
|||
</div>
|
||||
|
||||
<div className="review-panel-entry-actions">
|
||||
{!isReply && (
|
||||
{!isReply && !isThreadResolved && (
|
||||
<Tooltip
|
||||
id="resolve-thread"
|
||||
overlayProps={{ placement: 'bottom' }}
|
||||
|
@ -109,10 +117,12 @@ export const ReviewPanelMessage: FC<{
|
|||
</Tooltip>
|
||||
)}
|
||||
|
||||
<ReviewPanelCommentOptions
|
||||
onEdit={handleEditOption}
|
||||
onDelete={showDeleteModal}
|
||||
/>
|
||||
{!isThreadResolved && (
|
||||
<ReviewPanelCommentOptions
|
||||
onEdit={handleEditOption}
|
||||
onDelete={showDeleteModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ExpandableContent className="review-panel-comment-body">
|
||||
|
|
|
@ -1,39 +1,17 @@
|
|||
import { FC, useEffect, useMemo, useState } from 'react'
|
||||
import { FC, useMemo } from 'react'
|
||||
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||
import { Ranges, useRangesContext } from '../context/ranges-context'
|
||||
import { getJSON } from '@/infrastructure/fetch-json'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReviewPanelOverviewFile } from './review-panel-overview-file'
|
||||
import ReviewPanelEmptyState from './review-panel-empty-state'
|
||||
import useProjectRanges from '../hooks/use-project-ranges'
|
||||
|
||||
export const ReviewPanelOverview: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { _id: projectId } = useProjectContext()
|
||||
const { docs } = useFileTreeData()
|
||||
const [error, setError] = useState<Error>()
|
||||
const [projectRanges, setProjectRanges] = useState<Map<string, Ranges>>()
|
||||
const docRanges = useRangesContext()
|
||||
|
||||
useEffect(() => {
|
||||
getJSON<{ id: string; ranges: Ranges }[]>(`/project/${projectId}/ranges`)
|
||||
.then(data =>
|
||||
setProjectRanges(
|
||||
new Map(
|
||||
data.map(item => [
|
||||
item.id,
|
||||
{
|
||||
docId: item.id,
|
||||
changes: item.ranges.changes ?? [],
|
||||
comments: item.ranges.comments ?? [],
|
||||
total: 0, // TODO
|
||||
},
|
||||
])
|
||||
)
|
||||
)
|
||||
)
|
||||
.catch(error => setError(error))
|
||||
}, [projectId])
|
||||
const { projectRanges, error } = useProjectRanges()
|
||||
|
||||
const rangesForDocs = useMemo(() => {
|
||||
if (docs && docRanges && projectRanges) {
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
import { FC } from 'react'
|
||||
import { formatTimeBasedOnYear } from '@/features/utils/format-date'
|
||||
import { buildName } from '../utils/build-name'
|
||||
import { ReviewPanelResolvedCommentThread } from '../../../../../types/review-panel/comment-thread'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const ReviewPanelResolvedMessage: FC<{
|
||||
thread: ReviewPanelResolvedCommentThread
|
||||
}> = ({ thread }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="review-panel-comment">
|
||||
<div className="review-panel-entry-header">
|
||||
<div>
|
||||
<div className="review-panel-entry-user">
|
||||
{buildName(thread.resolved_by_user)}
|
||||
</div>
|
||||
<div className="review-panel-entry-time">
|
||||
{formatTimeBasedOnYear(thread.resolved_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="review-panel-comment-body review-panel-resolved-message">
|
||||
<i>{t('marked_as_resolved')}</i>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReviewPanelResolvedMessage
|
|
@ -0,0 +1,55 @@
|
|||
import React, { FC } from 'react'
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import { ThreadId } from '../../../../../types/review-panel/review-panel'
|
||||
import { useThreadsActionsContext } from '../context/threads-context'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { ExpandableContent } from './review-panel-expandable-content'
|
||||
import { ReviewPanelCommentContent } from './review-panel-comment-content'
|
||||
import { Change, CommentOperation } from '../../../../../types/change'
|
||||
|
||||
export const ReviewPanelResolvedThread: FC<{
|
||||
id: string
|
||||
comment: Change<CommentOperation>
|
||||
docName: string
|
||||
}> = ({ id, comment, docName }) => {
|
||||
const { t } = useTranslation()
|
||||
const { reopenThread, deleteThread } = useThreadsActionsContext()
|
||||
|
||||
return (
|
||||
<div className="review-panel-resolved-comment" key={id}>
|
||||
<div className="review-panel-resolved-comment-header">
|
||||
<div>
|
||||
<Trans
|
||||
i18nKey="from_filename"
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<span className="review-panel-resolved-comment-filename" />,
|
||||
]}
|
||||
values={{ filename: docName }}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
/>
|
||||
</div>
|
||||
<div className="review-panel-resolved-comment-buttons">
|
||||
<Button onClick={() => reopenThread(id as ThreadId)}>
|
||||
<MaterialIcon type="refresh" accessibilityLabel={t('reopen')} />
|
||||
</Button>
|
||||
<Button onClick={() => deleteThread(id as ThreadId)}>
|
||||
<MaterialIcon type="delete" accessibilityLabel={t('delete')} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="review-panel-resolved-comment-quoted-text">
|
||||
<div className="review-panel-resolved-comment-quoted-text-label">
|
||||
{t('quoted_text')}
|
||||
</div>
|
||||
<ExpandableContent className="review-panel-resolved-comment-quoted-text-quote">
|
||||
{comment?.op.c}
|
||||
</ExpandableContent>
|
||||
</div>
|
||||
|
||||
<ReviewPanelCommentContent comment={comment} isResolved />
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import React, { FC, useRef, useState } from 'react'
|
||||
import Icon from '@/shared/components/icon'
|
||||
import { ReviewPanelResolvedThreadsMenu } from './review-panel-resolved-threads-menu'
|
||||
import { Overlay, Popover } from 'react-bootstrap'
|
||||
|
||||
export const ReviewPanelResolvedThreadsButton: FC = () => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="resolved-comments-toggle"
|
||||
ref={buttonRef}
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
<Icon type="inbox" fw />
|
||||
</button>
|
||||
{expanded && (
|
||||
<Overlay
|
||||
show
|
||||
onHide={() => setExpanded(false)}
|
||||
animation={false}
|
||||
container={this}
|
||||
containerPadding={0}
|
||||
placement="bottom"
|
||||
rootClose
|
||||
target={buttonRef.current ?? undefined}
|
||||
>
|
||||
<Popover
|
||||
id="popover-resolved-threads"
|
||||
className="review-panel-resolved-comments review-panel-new"
|
||||
>
|
||||
<ReviewPanelResolvedThreadsMenu />
|
||||
</Popover>
|
||||
</Overlay>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,103 @@
|
|||
import React, { FC, useMemo } from 'react'
|
||||
import { useThreadsContext } from '../context/threads-context'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReviewPanelResolvedThread } from './review-panel-resolved-thread'
|
||||
import useProjectRanges from '../hooks/use-project-ranges'
|
||||
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||
import Icon from '@/shared/components/icon'
|
||||
import { Change, CommentOperation } from '../../../../../types/change'
|
||||
|
||||
export const ReviewPanelResolvedThreadsMenu: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const threads = useThreadsContext()
|
||||
const { docs } = useFileTreeData()
|
||||
|
||||
const { projectRanges, loading } = useProjectRanges()
|
||||
|
||||
const docNameForThread = useMemo(() => {
|
||||
const docNameForThread = new Map<string, string>()
|
||||
|
||||
for (const [docId, ranges] of projectRanges?.entries() ?? []) {
|
||||
const docName = docs?.find(doc => doc.doc.id === docId)?.doc.name
|
||||
if (docName !== undefined) {
|
||||
for (const comment of ranges.comments) {
|
||||
const threadId = comment.op.t
|
||||
docNameForThread.set(threadId, docName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return docNameForThread
|
||||
}, [docs, projectRanges])
|
||||
|
||||
const allComments = useMemo(() => {
|
||||
const allComments = new Map<string, Change<CommentOperation>>()
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
for (const [_, ranges] of projectRanges?.entries() ?? []) {
|
||||
for (const comment of ranges.comments) {
|
||||
allComments.set(comment.op.t, comment)
|
||||
}
|
||||
}
|
||||
|
||||
return allComments
|
||||
}, [projectRanges])
|
||||
|
||||
const resolvedThreads = useMemo(() => {
|
||||
if (!threads) {
|
||||
return []
|
||||
}
|
||||
|
||||
const resolvedThreads = []
|
||||
for (const [id, thread] of Object.entries(threads)) {
|
||||
if (thread.resolved) {
|
||||
resolvedThreads.push({ thread, id })
|
||||
}
|
||||
}
|
||||
return resolvedThreads
|
||||
}, [threads])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="review-panel-resolved-comments-loading">
|
||||
<Icon type="spinner" spin />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!resolvedThreads.length) {
|
||||
return (
|
||||
<div className="review-panel-resolved-comments-empty">
|
||||
{t('no_resolved_comments')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="review-panel-resolved-comments-header">
|
||||
<div className="review-panel-resolved-comments-label">
|
||||
{t('resolved_comments')}
|
||||
</div>
|
||||
<div className="review-panel-resolved-comments-count">
|
||||
{resolvedThreads.length}
|
||||
</div>
|
||||
</div>
|
||||
{resolvedThreads.map(thread => {
|
||||
const comment = allComments.get(thread.id)
|
||||
if (!comment) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ReviewPanelResolvedThread
|
||||
key={thread.id}
|
||||
id={thread.id}
|
||||
comment={comment}
|
||||
docName={docNameForThread.get(thread.id) ?? t('unknown')}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -1,87 +0,0 @@
|
|||
import React, { FC, useMemo, useRef, useState } from 'react'
|
||||
import { Overlay, Popover } from 'react-bootstrap'
|
||||
import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-editor'
|
||||
import {
|
||||
useThreadsActionsContext,
|
||||
useThreadsContext,
|
||||
} from '../context/threads-context'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ThreadId } from '../../../../../types/review-panel/review-panel'
|
||||
import Icon from '@/shared/components/icon'
|
||||
|
||||
export const ReviewPanelResolvedThreads: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const view = useCodeMirrorViewContext()
|
||||
const threads = useThreadsContext()
|
||||
const { reopenThread, deleteThread } = useThreadsActionsContext()
|
||||
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const resolvedThreads = useMemo(() => {
|
||||
if (!threads) {
|
||||
return []
|
||||
}
|
||||
|
||||
const resolvedThreads = []
|
||||
for (const [id, thread] of Object.entries(threads)) {
|
||||
if (thread.resolved) {
|
||||
resolvedThreads.push({ ...thread, id })
|
||||
}
|
||||
}
|
||||
return resolvedThreads
|
||||
}, [threads])
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="resolved-comments-toggle"
|
||||
ref={buttonRef}
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
<Icon type="inbox" fw />
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<Overlay
|
||||
show
|
||||
onHide={() => setExpanded(false)}
|
||||
animation={false}
|
||||
container={view.scrollDOM}
|
||||
containerPadding={0}
|
||||
placement="bottom"
|
||||
rootClose
|
||||
target={buttonRef.current ?? undefined}
|
||||
>
|
||||
<Popover id="popover-resolved-threads">
|
||||
{resolvedThreads.length ? (
|
||||
<div className="resolved-comments">
|
||||
{resolvedThreads.map(thread => (
|
||||
<div key={thread.id}>
|
||||
<div>{thread.resolved_at}</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => reopenThread(thread.id as ThreadId)}
|
||||
>
|
||||
{t('reopen')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteThread(thread.id as ThreadId)}
|
||||
>
|
||||
{t('delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div>{t('no_resolved_threads')}</div>
|
||||
)}
|
||||
</Popover>
|
||||
</Overlay>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Ranges } from '../context/ranges-context'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { getJSON } from '@/infrastructure/fetch-json'
|
||||
|
||||
export default function useProjectRanges() {
|
||||
const { _id: projectId } = useProjectContext()
|
||||
const [error, setError] = useState<Error>()
|
||||
const [projectRanges, setProjectRanges] = useState<Map<string, Ranges>>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
getJSON<{ id: string; ranges: Ranges }[]>(`/project/${projectId}/ranges`)
|
||||
.then(data => {
|
||||
setProjectRanges(
|
||||
new Map(
|
||||
data.map(item => [
|
||||
item.id,
|
||||
{
|
||||
docId: item.id,
|
||||
changes: item.ranges.changes ?? [],
|
||||
comments: item.ranges.comments ?? [],
|
||||
total: 0, // TODO
|
||||
},
|
||||
])
|
||||
)
|
||||
)
|
||||
})
|
||||
.catch(error => setError(error))
|
||||
.finally(() => setLoading(false))
|
||||
}, [projectId])
|
||||
|
||||
return { projectRanges, error, loading }
|
||||
}
|
|
@ -186,6 +186,107 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.review-panel-resolved-comments {
|
||||
width: 280px;
|
||||
|
||||
.popover-content {
|
||||
background-color: @neutral-10;
|
||||
overflow-y: auto;
|
||||
max-height: calc(100vh - 180px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.review-panel-resolved-comments-loading {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.review-panel-resolved-comments-empty {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.review-panel-resolved-comments-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.review-panel-resolved-comments-label {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.review-panel-resolved-comments-count {
|
||||
background-color: @neutral-20;
|
||||
border-radius: @border-radius-base-new;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.review-panel-resolved-comment {
|
||||
background-color: white;
|
||||
border-radius: @border-radius-base-new;
|
||||
padding: @spacing-04;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: @spacing-04;
|
||||
}
|
||||
|
||||
.review-panel-resolved-comment-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: @content-secondary;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.review-panel-resolved-comment-filename {
|
||||
color: @content-primary;
|
||||
}
|
||||
|
||||
.review-panel-resolved-comment-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: @spacing-03;
|
||||
|
||||
.btn {
|
||||
background-color: transparent;
|
||||
color: @content-primary;
|
||||
padding: @spacing-01;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: @neutral-20;
|
||||
}
|
||||
}
|
||||
|
||||
.material-symbols {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.review-panel-resolved-comment-quoted-text {
|
||||
background-color: @neutral-20;
|
||||
border-radius: @border-radius-base-new;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.review-panel-resolved-comment-quoted-text-label {
|
||||
color: @content-secondary;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.review-panel-resolved-comment-quoted-text-quote {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.review-panel-comment-wrapper {
|
||||
display: flex;
|
||||
gap: @spacing-04;
|
||||
|
@ -201,6 +302,7 @@
|
|||
color: @content-primary;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.review-panel-content-expandable {
|
||||
display: -webkit-box;
|
||||
text-overflow: ellipsis;
|
||||
|
|
|
@ -703,6 +703,7 @@
|
|||
"from_another_project": "From another project",
|
||||
"from_enforcement_date": "From __enforcementDate__ any additional editors on this project will be made viewers.",
|
||||
"from_external_url": "From external URL",
|
||||
"from_filename": "From <0>__filename__</0>",
|
||||
"from_project_files": "From project files",
|
||||
"from_provider": "From __provider__",
|
||||
"from_url": "From URL",
|
||||
|
@ -1187,6 +1188,7 @@
|
|||
"managing_your_subscription": "Managing your subscription",
|
||||
"march": "March",
|
||||
"mark_as_resolved": "Mark as resolved",
|
||||
"marked_as_resolved": "Marked as resolved",
|
||||
"math_display": "Math Display",
|
||||
"math_inline": "Math Inline",
|
||||
"max_collab_per_project": "Max. collaborators per project",
|
||||
|
@ -1298,6 +1300,7 @@
|
|||
"no_planned_maintenance": "There is currently no planned maintenance",
|
||||
"no_preview_available": "Sorry, no preview is available.",
|
||||
"no_projects": "No projects",
|
||||
"no_resolved_comments": "No resolved comments",
|
||||
"no_resolved_threads": "No resolved threads",
|
||||
"no_search_results": "No Search Results",
|
||||
"no_selection_select_file": "Currently, no file is selected. Please select a file from the file tree.",
|
||||
|
@ -1549,6 +1552,7 @@
|
|||
"purchase_now": "Purchase Now",
|
||||
"purchase_now_lowercase": "Purchase now",
|
||||
"push_sharelatex_changes_to_github": "Push __appName__ changes to GitHub",
|
||||
"quoted_text": "Quoted text",
|
||||
"quoted_text_in": "Quoted text in",
|
||||
"raw_logs": "Raw logs",
|
||||
"raw_logs_description": "Raw logs from the LaTeX compiler",
|
||||
|
|
Loading…
Reference in a new issue