Merge pull request #19985 from overleaf/dp-resolved-threads

Implement redesigned resolved threads popover

GitOrigin-RevId: 4e462eb26a2f2f3194fca89c39d5f9d08ea2e33c
This commit is contained in:
David 2024-08-19 14:16:56 +01:00 committed by Copybot
parent 93ba9fa28a
commit 9416e69647
14 changed files with 497 additions and 191 deletions

View file

@ -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": "",

View file

@ -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'

View file

@ -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>
)
})

View file

@ -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}

View file

@ -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">

View file

@ -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) {

View file

@ -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

View file

@ -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>
)
}

View file

@ -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>
)}
</>
)
}

View file

@ -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')}
/>
)
})}
</>
)
}

View file

@ -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>
)}
</>
)
}

View file

@ -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 }
}

View file

@ -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;

View file

@ -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",