Sfoglia il codice sorgente

Merge pull request #13628 from overleaf/ii-review-panel-migration-resolved-comments-entry

[web] Add resolved comments entries functionality

GitOrigin-RevId: f0a8365b00c0861be12347aeaf486f7c02faf8e5
ilkin-overleaf 3 anni fa
parent
commit
e5d6777211

+ 4 - 1
services/web/frontend/js/features/source-editor/components/review-panel/entries/comment-entry.tsx

@@ -40,6 +40,7 @@ function CommentEntry({ docId, entry, entryId, threads }: CommentEntryProps) {
 
   const [replyContent, setReplyContent] = useState('')
   const [animating, setAnimating] = useState(false)
+  const [resolved, setResolved] = useState(false)
   const entryDivRef = useRef<HTMLDivElement | null>(null)
 
   const thread =
@@ -68,6 +69,8 @@ function CommentEntry({ docId, entry, entryId, threads }: CommentEntryProps) {
     }
 
     setTimeout(() => {
+      setAnimating(false)
+      setResolved(true)
       resolveComment(docId, entryId)
     }, 350)
   }
@@ -94,7 +97,7 @@ function CommentEntry({ docId, entry, entryId, threads }: CommentEntryProps) {
     }
   }
 
-  if (!thread) {
+  if (!thread || resolved) {
     return null
   }
 

+ 1 - 1
services/web/frontend/js/features/source-editor/components/review-panel/entries/comment.tsx

@@ -3,8 +3,8 @@ import { useState } from 'react'
 import AutoExpandingTextArea from '../../../../../shared/components/auto-expanding-text-area'
 import { formatTime } from '../../../../utils/format-date'
 import { useReviewPanelValueContext } from '../../../context/review-panel/review-panel-context'
+import { ReviewPanelCommentThread } from '../../../../../../../types/review-panel/comment-thread'
 import {
-  ReviewPanelCommentThread,
   ReviewPanelCommentThreadMessage,
   ThreadId,
 } from '../../../../../../../types/review-panel/review-panel'

+ 6 - 23
services/web/frontend/js/features/source-editor/components/review-panel/entries/resolved-comment-entry.tsx

@@ -3,6 +3,7 @@ import { useState } from 'react'
 import Linkify from 'react-linkify'
 import { formatTime } from '../../../../utils/format-date'
 import { useReviewPanelValueContext } from '../../../context/review-panel/review-panel-context'
+import { FilteredResolvedComments } from '../toolbar/resolved-comments-dropdown'
 
 function LinkDecorator(
   decoratedHref: string,
@@ -17,26 +18,7 @@ function LinkDecorator(
 }
 
 type ResolvedCommentEntryProps = {
-  thread: {
-    resolved_at: number
-    entryId: string
-    docName: string
-    content: string
-    messages: Array<{
-      id: string
-      user: {
-        id: string
-        hue: string
-        name: string
-      }
-      content: string
-      timestamp: string
-    }>
-    resolved_by_user: {
-      name: string
-      hue: string
-    }
-  } // TODO extract type
+  thread: FilteredResolvedComments
   contentLimit?: number
 }
 
@@ -45,7 +27,8 @@ function ResolvedCommentEntry({
   contentLimit = 40,
 }: ResolvedCommentEntryProps) {
   const { t } = useTranslation()
-  const { permissions } = useReviewPanelValueContext()
+  const { permissions, unresolveComment, deleteThread } =
+    useReviewPanelValueContext()
   const [isCollapsed, setIsCollapsed] = useState(false)
   const needsCollapsing = thread.content.length > contentLimit
   const content = isCollapsed
@@ -53,11 +36,11 @@ function ResolvedCommentEntry({
     : thread.content
 
   const handleUnresolve = () => {
-    // TODO unresolve comment
+    unresolveComment(thread.threadId)
   }
 
   const handleDelete = () => {
-    // TODO delete thread
+    deleteThread(undefined, thread.docId, thread.threadId)
   }
 
   return (

+ 74 - 28
services/web/frontend/js/features/source-editor/components/review-panel/toolbar/resolved-comments-dropdown.tsx

@@ -1,16 +1,85 @@
 import { useTranslation } from 'react-i18next'
-import { useState } from 'react'
+import { useState, useMemo, useCallback } from 'react'
 import Icon from '../../../../../shared/components/icon'
 import Tooltip from '../../../../../shared/components/tooltip'
 import ResolvedCommentsScroller from './resolved-comments-scroller'
 import classnames from 'classnames'
+import { useReviewPanelValueContext } from '../../../context/review-panel/review-panel-context'
+import {
+  DocId,
+  ReviewPanelDocEntries,
+  ThreadId,
+} from '../../../../../../../types/review-panel/review-panel'
+import { ReviewPanelResolvedCommentThread } from '../../../../../../../types/review-panel/comment-thread'
+
+export interface FilteredResolvedComments
+  extends ReviewPanelResolvedCommentThread {
+  content: string
+  threadId: ThreadId
+  entryId: string
+  docId: DocId
+  docName: string | null
+}
 
 function ResolvedCommentsDropdown() {
   const { t } = useTranslation()
   const [isOpen, setIsOpen] = useState(false)
-  // TODO setIsLoading
-  // eslint-disable-next-line no-unused-vars
   const [isLoading, setIsLoading] = useState(false)
+  const {
+    docs,
+    commentThreads,
+    resolvedComments,
+    refreshResolvedCommentsDropdown,
+  } = useReviewPanelValueContext()
+
+  const handleResolvedCommentsClick = () => {
+    setIsOpen(isOpen => {
+      if (!isOpen) {
+        setIsLoading(true)
+        refreshResolvedCommentsDropdown().finally(() => setIsLoading(false))
+      }
+
+      return !isOpen
+    })
+  }
+
+  const getDocNameById = useCallback(
+    (docId: DocId) => {
+      return docs?.find(doc => doc.doc.id === docId)?.doc.name || null
+    },
+    [docs]
+  )
+
+  const filteredResolvedComments = useMemo(() => {
+    const comments: FilteredResolvedComments[] = []
+
+    for (const [docId, docEntries] of Object.entries(resolvedComments) as Array<
+      [DocId, ReviewPanelDocEntries]
+    >) {
+      for (const [entryId, entry] of Object.entries(docEntries)) {
+        if (entry.type === 'comment') {
+          const threadId = entry.thread_id
+          const thread =
+            threadId in commentThreads
+              ? commentThreads[entry.thread_id]
+              : undefined
+
+          if (thread?.resolved) {
+            comments.push({
+              ...thread,
+              content: entry.content,
+              threadId,
+              entryId,
+              docId,
+              docName: getDocNameById(docId),
+            })
+          }
+        }
+      }
+    }
+
+    return comments
+  }, [commentThreads, getDocNameById, resolvedComments])
 
   return (
     <div className="resolved-comments">
@@ -29,7 +98,7 @@ function ResolvedCommentsDropdown() {
       >
         <button
           className="resolved-comments-toggle"
-          onClick={() => setIsOpen(value => !value)}
+          onClick={handleResolvedCommentsClick}
           aria-label={t('resolved_comments')}
         >
           <Icon type="inbox" />
@@ -47,30 +116,7 @@ function ResolvedCommentsDropdown() {
           </div>
         ) : (
           <ResolvedCommentsScroller
-            resolvedComments={[
-              {
-                resolved_at: 12345,
-                entryId: '123',
-                docName: 'demo name',
-                content: 'demo content',
-                messages: [
-                  {
-                    id: '123',
-                    user: {
-                      id: '123',
-                      hue: 'abcde',
-                      name: 'demo name',
-                    },
-                    content: 'demo content',
-                    timestamp: '12345',
-                  },
-                ],
-                resolved_by_user: {
-                  name: 'demo',
-                  hue: 'abcde',
-                },
-              },
-            ]}
+            resolvedComments={filteredResolvedComments}
           />
         )}
       </div>

+ 5 - 26
services/web/frontend/js/features/source-editor/components/review-panel/toolbar/resolved-comments-scroller.tsx

@@ -1,29 +1,10 @@
 import { useTranslation } from 'react-i18next'
 import { useMemo } from 'react'
 import ResolvedCommentEntry from '../entries/resolved-comment-entry'
-import moment from 'moment'
+import { FilteredResolvedComments } from './resolved-comments-dropdown'
 
 type ResolvedCommentsScrollerProps = {
-  resolvedComments: Array<{
-    resolved_at: number
-    entryId: string
-    docName: string
-    content: string
-    messages: Array<{
-      id: string
-      user: {
-        id: string
-        hue: string
-        name: string
-      }
-      content: string
-      timestamp: string
-    }>
-    resolved_by_user: {
-      name: string
-      hue: string
-    }
-  }> // TODO extract type
+  resolvedComments: FilteredResolvedComments[]
 }
 
 function ResolvedCommentsScroller({
@@ -31,12 +12,10 @@ function ResolvedCommentsScroller({
 }: ResolvedCommentsScrollerProps) {
   const { t } = useTranslation()
 
-  // TODO remove momentjs
   const sortedResolvedComments = useMemo(() => {
-    return [...resolvedComments].sort(
-      (a, b) =>
-        moment(b.resolved_at).valueOf() - moment(a.resolved_at).valueOf()
-    )
+    return [...resolvedComments].sort((a, b) => {
+      return Date.parse(b.resolved_at) - Date.parse(a.resolved_at)
+    })
   }, [resolvedComments])
 
   return (

+ 23 - 1
services/web/frontend/js/features/source-editor/context/review-panel/hooks/use-angular-review-panel-state.ts

@@ -20,14 +20,19 @@ function useAngularReviewPanelState(): ReviewPanelState {
     'reviewPanel.commentThreads',
     true
   )
+  const [docs] = useScopeValue<ReviewPanel.Value<'docs'>>('docs')
   const [entries] = useScopeValue<ReviewPanel.Value<'entries'>>(
-    'reviewPanel.entries'
+    'reviewPanel.entries',
+    true
   )
   const [loadingThreads] =
     useScopeValue<ReviewPanel.Value<'loadingThreads'>>('loadingThreads')
 
   const [permissions] =
     useScopeValue<ReviewPanel.Value<'permissions'>>('permissions')
+  const [resolvedComments] = useScopeValue<
+    ReviewPanel.Value<'resolvedComments'>
+  >('reviewPanel.resolvedComments', true)
 
   const [wantTrackChanges] = useScopeValue<
     ReviewPanel.Value<'wantTrackChanges'>
@@ -75,6 +80,13 @@ function useAngularReviewPanelState(): ReviewPanelState {
 
   const [toggleReviewPanel] =
     useScopeValue<ReviewPanel.Value<'toggleReviewPanel'>>('toggleReviewPanel')
+  const [unresolveComment] =
+    useScopeValue<ReviewPanel.Value<'unresolveComment'>>('unresolveComment')
+  const [deleteThread] =
+    useScopeValue<ReviewPanel.Value<'deleteThread'>>('deleteThread')
+  const [refreshResolvedCommentsDropdown] = useScopeValue<
+    ReviewPanel.Value<'refreshResolvedCommentsDropdown'>
+  >('refreshResolvedCommentsDropdown')
 
   const handleSetSubview = useCallback(
     (subView: SubView) => {
@@ -104,6 +116,7 @@ function useAngularReviewPanelState(): ReviewPanelState {
       collapsed,
       commentThreads,
       deleteComment,
+      docs,
       entries,
       entryHover,
       gotoEntry,
@@ -111,6 +124,7 @@ function useAngularReviewPanelState(): ReviewPanelState {
       loadingThreads,
       permissions,
       resolveComment,
+      resolvedComments,
       saveEdit,
       shouldCollapse,
       submitReply,
@@ -126,11 +140,15 @@ function useAngularReviewPanelState(): ReviewPanelState {
       trackChangesForGuestsAvailable,
       formattedProjectMembers,
       toggleReviewPanel,
+      unresolveComment,
+      deleteThread,
+      refreshResolvedCommentsDropdown,
     }),
     [
       collapsed,
       commentThreads,
       deleteComment,
+      docs,
       entries,
       entryHover,
       gotoEntry,
@@ -138,6 +156,7 @@ function useAngularReviewPanelState(): ReviewPanelState {
       loadingThreads,
       permissions,
       resolveComment,
+      resolvedComments,
       saveEdit,
       shouldCollapse,
       submitReply,
@@ -153,6 +172,9 @@ function useAngularReviewPanelState(): ReviewPanelState {
       trackChangesForGuestsAvailable,
       formattedProjectMembers,
       toggleReviewPanel,
+      unresolveComment,
+      deleteThread,
+      refreshResolvedCommentsDropdown,
     ]
   )
 

+ 6 - 0
services/web/frontend/js/features/source-editor/context/review-panel/types/review-panel-state.ts

@@ -8,12 +8,14 @@ import {
   ThreadId,
 } from '../../../../../../../types/review-panel/review-panel'
 import { ReviewPanelCommentEntry } from '../../../../../../../types/review-panel/entry'
+import { MainDocument } from '../../../../../../../types/project-settings'
 
 export interface ReviewPanelState {
   values: {
     collapsed: Record<string, boolean>
     commentThreads: ReviewPanelCommentThreads
     deleteComment: (threadId: ThreadId, commentId: CommentId) => void
+    docs: MainDocument[] | undefined
     entries: ReviewPanelEntries
     entryHover: boolean
     gotoEntry: (docId: DocId, entryOffset: number) => void
@@ -21,6 +23,7 @@ export interface ReviewPanelState {
     loadingThreads: boolean
     permissions: ReviewPanelPermissions
     resolveComment: (docId: DocId, entryId: ThreadId) => void
+    resolvedComments: ReviewPanelEntries
     saveEdit: (
       threadId: ThreadId,
       commentId: CommentId,
@@ -46,6 +49,9 @@ export interface ReviewPanelState {
       }
     >
     toggleReviewPanel: () => void
+    unresolveComment: (threadId: ThreadId) => void
+    deleteThread: (_entryId: unknown, docId: DocId, threadId: ThreadId) => void
+    refreshResolvedCommentsDropdown: () => Promise<void>
   }
   updaterFns: {
     handleSetSubview: (subView: SubView) => void

+ 4 - 1
services/web/test/frontend/features/review-panel/review-panel.spec.tsx

@@ -42,7 +42,10 @@ describe('<ReviewPanel />', function () {
       })
 
       // eslint-disable-next-line mocha/no-skipped-tests
-      it.skip('opens dropdown', function () {})
+      it.skip('opens dropdown', function () {
+        cy.findByRole('button', { name: /resolved comments/i }).click()
+        // TODO dropdown opens/closes
+      })
 
       // eslint-disable-next-line mocha/no-skipped-tests
       it.skip('renders list of resolved comments', function () {})

+ 3 - 0
services/web/test/frontend/features/source-editor/helpers/mock-scope.ts

@@ -1,5 +1,6 @@
 import { docId, mockDoc } from './mock-doc'
 import { Folder } from '../../../../../types/folder'
+import { sleep } from '../../../helpers/sleep'
 
 export const rootFolderId = '012345678901234567890123'
 export const figuresFolderId = '123456789012345678901234'
@@ -79,12 +80,14 @@ export const mockScope = (content?: string) => {
       formattedProjectMembers: {},
       fullTCStateCollapsed: true,
       entries: {},
+      resolvedComments: {},
     },
     ui: {
       reviewPanelOpen: true,
     },
     toggleReviewPanel: cy.stub(),
     toggleTrackChangesForEveryone: cy.stub(),
+    refreshResolvedCommentsDropdown: cy.stub(() => sleep(1000)),
     onlineUserCursorHighlights: {},
     permissionsLevel: 'owner',
     $on: cy.stub(),

+ 3 - 0
services/web/test/frontend/helpers/sleep.ts

@@ -0,0 +1,3 @@
+export const sleep = (timeout: number) => {
+  return new Promise(resolve => setTimeout(resolve, timeout))
+}

+ 3 - 0
services/web/types/helpers/date.ts

@@ -0,0 +1,3 @@
+import { Brand } from './brand'
+
+export type DateString = Brand<string, 'DateString'>

+ 31 - 0
services/web/types/review-panel/comment-thread.ts

@@ -0,0 +1,31 @@
+import {
+  ReviewPanelCommentThreadMessage,
+  ReviewPanelUser,
+  UserId,
+} from './review-panel'
+import { DateString } from '../helpers/date'
+
+interface ReviewPanelCommentThreadBase {
+  messages: Array<ReviewPanelCommentThreadMessage>
+  submitting?: boolean // angular specific (to be made into a local state)
+}
+
+interface ReviewPanelUnresolvedCommentThread
+  extends ReviewPanelCommentThreadBase {
+  resolved?: never
+  resolved_at?: never
+  resolved_by_user_id?: never
+  resolved_by_user?: never
+}
+
+export interface ReviewPanelResolvedCommentThread
+  extends ReviewPanelCommentThreadBase {
+  resolved: boolean
+  resolved_at: DateString
+  resolved_by_user_id: UserId
+  resolved_by_user: ReviewPanelUser
+}
+
+export type ReviewPanelCommentThread =
+  | ReviewPanelUnresolvedCommentThread
+  | ReviewPanelResolvedCommentThread

+ 5 - 12
services/web/types/review-panel/review-panel.ts

@@ -1,5 +1,6 @@
 import { Brand } from '../helpers/brand'
 import { ReviewPanelEntry } from './entry'
+import { ReviewPanelCommentThread } from './comment-thread'
 
 export type SubView = 'cur_file' | 'overview'
 
@@ -11,14 +12,14 @@ export interface ReviewPanelPermissions {
 }
 
 export type ThreadId = Brand<string, 'ThreadId'>
-type ReviewPanelDocEntries = Record<ThreadId, ReviewPanelEntry>
+export type ReviewPanelDocEntries = Record<ThreadId, ReviewPanelEntry>
 
 export type DocId = Brand<string, 'DocId'>
 export type ReviewPanelEntries = Record<DocId, ReviewPanelDocEntries>
 
-type UserId = Brand<string, 'UserId'>
+export type UserId = Brand<string, 'UserId'>
 
-interface ReviewPanelUser {
+export interface ReviewPanelUser {
   avatar_text: string
   email: string
   hue: number
@@ -28,6 +29,7 @@ interface ReviewPanelUser {
 }
 
 export type CommentId = Brand<string, 'CommentId'>
+
 export interface ReviewPanelCommentThreadMessage {
   content: string
   id: CommentId
@@ -36,15 +38,6 @@ export interface ReviewPanelCommentThreadMessage {
   user_id: UserId
 }
 
-export interface ReviewPanelCommentThread {
-  messages: Array<ReviewPanelCommentThreadMessage>
-  // resolved: boolean
-  // resolved_at: number
-  // resolved_by_user_id: string
-  // resolved_by_user: ReviewPanelUser
-  submitting?: boolean // angular specific (to be made into a local state)
-}
-
 export type ReviewPanelCommentThreads = Record<
   ThreadId,
   ReviewPanelCommentThread