diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index bda62ab545..e004b43fb5 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-comment-content.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-comment-content.tsx new file mode 100644 index 0000000000..8c29b79fac --- /dev/null +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-comment-content.tsx @@ -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 + isResolved: boolean +}>(({ comment, isResolved }) => { + const { t } = useTranslation() + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState() + 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 + ) => { + if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { + e.preventDefault() + handleSubmitReply() + } + } + + const thread = threads?.[comment.op.t] + if (!thread) { + return null + } + + return ( +
+ {thread.messages.map((message, i) => { + const isReply = i !== 0 + + return ( +
+ {isReply &&
} + 1} + onResolve={() => resolveThread(comment.op.t)} + isThreadResolved={isResolved} + /> +
+ ) + })} + + {isResolved && ( +
+
+ +
+ )} + + {!isResolved && ( + setContent(e.target.value)} + onKeyDown={handleCommentReplyKeyPress} + placeholder={t('reply')} + value={content} + disabled={submitting} + /> + )} + + {error &&
{error.message}
} +
+ ) +}) +ReviewPanelCommentContent.displayName = 'ReviewPanelCommentContent' diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-comment.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-comment.tsx index 7d8b04da52..316abd25b5 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-comment.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-comment.tsx @@ -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 top?: number }>(({ comment, top }) => { - const { t } = useTranslation() - const [submitting, setSubmitting] = useState(false) - const [error, setError] = useState() - 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 - ) => { - 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<{
- -
- {thread.messages.map((message, i) => { - const isReply = i !== 0 - - return ( -
- {isReply && ( -
- )} - - 1} - onResolve={() => resolveThread(comment.op.t)} - /> -
- ) - })} - - setContent(e.target.value)} - onKeyDown={handleCommentReplyKeyPress} - placeholder={t('reply')} - value={content} - disabled={submitting} - /> - - {error &&
{error.message}
} -
+ ) }) diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-header.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-header.tsx index 23a287b4eb..b282e34d3c 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-header.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-header.tsx @@ -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<{
- + 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<{
- {!isReply && ( + {!isReply && !isThreadResolved && ( )} - + {!isThreadResolved && ( + + )}
diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-overview.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-overview.tsx index 9adcd04201..32c4077335 100644 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-overview.tsx +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-overview.tsx @@ -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() - const [projectRanges, setProjectRanges] = useState>() 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) { diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-message.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-message.tsx new file mode 100644 index 0000000000..895051a623 --- /dev/null +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-message.tsx @@ -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 ( +
+
+
+
+ {buildName(thread.resolved_by_user)} +
+
+ {formatTimeBasedOnYear(thread.resolved_at)} +
+
+
+
+ {t('marked_as_resolved')} +
+
+ ) +} + +export default ReviewPanelResolvedMessage diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-thread.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-thread.tsx new file mode 100644 index 0000000000..eb7e0ce5b8 --- /dev/null +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-thread.tsx @@ -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 + docName: string +}> = ({ id, comment, docName }) => { + const { t } = useTranslation() + const { reopenThread, deleteThread } = useThreadsActionsContext() + + return ( +
+
+
+ , + ]} + values={{ filename: docName }} + shouldUnescape + tOptions={{ interpolation: { escapeValue: true } }} + /> +
+
+ + +
+
+
+
+ {t('quoted_text')} +
+ + {comment?.op.c} + +
+ + +
+ ) +} diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-threads-button.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-threads-button.tsx new file mode 100644 index 0000000000..6cd83a7910 --- /dev/null +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-threads-button.tsx @@ -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(null) + + return ( + <> + + {expanded && ( + setExpanded(false)} + animation={false} + container={this} + containerPadding={0} + placement="bottom" + rootClose + target={buttonRef.current ?? undefined} + > + + + + + )} + + ) +} diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-threads-menu.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-threads-menu.tsx new file mode 100644 index 0000000000..5fcee53c37 --- /dev/null +++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-threads-menu.tsx @@ -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() + + 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>() + + // 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 ( +
+ +
+ ) + } + + if (!resolvedThreads.length) { + return ( +
+ {t('no_resolved_comments')} +
+ ) + } + + return ( + <> +
+
+ {t('resolved_comments')} +
+
+ {resolvedThreads.length} +
+
+ {resolvedThreads.map(thread => { + const comment = allComments.get(thread.id) + if (!comment) { + return null + } + + return ( + + ) + })} + + ) +} diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-threads.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-threads.tsx deleted file mode 100644 index 79877fbe7c..0000000000 --- a/services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-threads.tsx +++ /dev/null @@ -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(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 ( - <> - - - {expanded && ( - setExpanded(false)} - animation={false} - container={view.scrollDOM} - containerPadding={0} - placement="bottom" - rootClose - target={buttonRef.current ?? undefined} - > - - {resolvedThreads.length ? ( -
- {resolvedThreads.map(thread => ( -
-
{thread.resolved_at}
-
- - -
-
- ))} -
- ) : ( -
{t('no_resolved_threads')}
- )} -
-
- )} - - ) -} diff --git a/services/web/frontend/js/features/review-panel-new/hooks/use-project-ranges.ts b/services/web/frontend/js/features/review-panel-new/hooks/use-project-ranges.ts new file mode 100644 index 0000000000..b90d12b0bc --- /dev/null +++ b/services/web/frontend/js/features/review-panel-new/hooks/use-project-ranges.ts @@ -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() + const [projectRanges, setProjectRanges] = useState>() + 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 } +} diff --git a/services/web/frontend/stylesheets/app/editor/review-panel-new.less b/services/web/frontend/stylesheets/app/editor/review-panel-new.less index 14a19d8e6a..4653fcaf06 100644 --- a/services/web/frontend/stylesheets/app/editor/review-panel-new.less +++ b/services/web/frontend/stylesheets/app/editor/review-panel-new.less @@ -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; diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 7872dc30f9..71dfe959b6 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -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__", "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",