From e5d6777211f68a4431f84bdb495b216f15578832 Mon Sep 17 00:00:00 2001 From: ilkin-overleaf <100852799+ilkin-overleaf@users.noreply.github.com> Date: Mon, 3 Jul 2023 11:55:39 +0300 Subject: [PATCH] Merge pull request #13628 from overleaf/ii-review-panel-migration-resolved-comments-entry [web] Add resolved comments entries functionality GitOrigin-RevId: f0a8365b00c0861be12347aeaf486f7c02faf8e5 --- .../review-panel/entries/comment-entry.tsx | 5 +- .../review-panel/entries/comment.tsx | 2 +- .../entries/resolved-comment-entry.tsx | 29 ++--- .../toolbar/resolved-comments-dropdown.tsx | 102 +++++++++++++----- .../toolbar/resolved-comments-scroller.tsx | 31 +----- .../hooks/use-angular-review-panel-state.ts | 24 ++++- .../review-panel/types/review-panel-state.ts | 6 ++ .../review-panel/review-panel.spec.tsx | 5 +- .../source-editor/helpers/mock-scope.ts | 3 + services/web/test/frontend/helpers/sleep.ts | 3 + services/web/types/helpers/date.ts | 3 + .../web/types/review-panel/comment-thread.ts | 31 ++++++ .../web/types/review-panel/review-panel.ts | 17 +-- 13 files changed, 168 insertions(+), 93 deletions(-) create mode 100644 services/web/test/frontend/helpers/sleep.ts create mode 100644 services/web/types/helpers/date.ts create mode 100644 services/web/types/review-panel/comment-thread.ts diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/entries/comment-entry.tsx b/services/web/frontend/js/features/source-editor/components/review-panel/entries/comment-entry.tsx index 4aed2535ef..a01cac81a6 100644 --- a/services/web/frontend/js/features/source-editor/components/review-panel/entries/comment-entry.tsx +++ b/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(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 } diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/entries/comment.tsx b/services/web/frontend/js/features/source-editor/components/review-panel/entries/comment.tsx index 5553f8672a..8a7b7305f4 100644 --- a/services/web/frontend/js/features/source-editor/components/review-panel/entries/comment.tsx +++ b/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' diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/entries/resolved-comment-entry.tsx b/services/web/frontend/js/features/source-editor/components/review-panel/entries/resolved-comment-entry.tsx index 32411a86c9..a66e372de1 100644 --- a/services/web/frontend/js/features/source-editor/components/review-panel/entries/resolved-comment-entry.tsx +++ b/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 ( diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/toolbar/resolved-comments-dropdown.tsx b/services/web/frontend/js/features/source-editor/components/review-panel/toolbar/resolved-comments-dropdown.tsx index 2f8fe9ebb2..2df2285d9e 100644 --- a/services/web/frontend/js/features/source-editor/components/review-panel/toolbar/resolved-comments-dropdown.tsx +++ b/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 (
@@ -29,7 +98,7 @@ function ResolvedCommentsDropdown() { >
) : ( )} diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/toolbar/resolved-comments-scroller.tsx b/services/web/frontend/js/features/source-editor/components/review-panel/toolbar/resolved-comments-scroller.tsx index 594adab066..39cc57002a 100644 --- a/services/web/frontend/js/features/source-editor/components/review-panel/toolbar/resolved-comments-scroller.tsx +++ b/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 ( diff --git a/services/web/frontend/js/features/source-editor/context/review-panel/hooks/use-angular-review-panel-state.ts b/services/web/frontend/js/features/source-editor/context/review-panel/hooks/use-angular-review-panel-state.ts index bebd736d2a..8ed973b340 100644 --- a/services/web/frontend/js/features/source-editor/context/review-panel/hooks/use-angular-review-panel-state.ts +++ b/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>('docs') const [entries] = useScopeValue>( - 'reviewPanel.entries' + 'reviewPanel.entries', + true ) const [loadingThreads] = useScopeValue>('loadingThreads') const [permissions] = useScopeValue>('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>('toggleReviewPanel') + const [unresolveComment] = + useScopeValue>('unresolveComment') + const [deleteThread] = + useScopeValue>('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, ] ) diff --git a/services/web/frontend/js/features/source-editor/context/review-panel/types/review-panel-state.ts b/services/web/frontend/js/features/source-editor/context/review-panel/types/review-panel-state.ts index 04f5700769..74aa493b67 100644 --- a/services/web/frontend/js/features/source-editor/context/review-panel/types/review-panel-state.ts +++ b/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 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 } updaterFns: { handleSetSubview: (subView: SubView) => void diff --git a/services/web/test/frontend/features/review-panel/review-panel.spec.tsx b/services/web/test/frontend/features/review-panel/review-panel.spec.tsx index fcaa331267..aad0cda4c7 100644 --- a/services/web/test/frontend/features/review-panel/review-panel.spec.tsx +++ b/services/web/test/frontend/features/review-panel/review-panel.spec.tsx @@ -42,7 +42,10 @@ describe('', 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 () {}) diff --git a/services/web/test/frontend/features/source-editor/helpers/mock-scope.ts b/services/web/test/frontend/features/source-editor/helpers/mock-scope.ts index b605ea050e..be672ed874 100644 --- a/services/web/test/frontend/features/source-editor/helpers/mock-scope.ts +++ b/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(), diff --git a/services/web/test/frontend/helpers/sleep.ts b/services/web/test/frontend/helpers/sleep.ts new file mode 100644 index 0000000000..0cca7a6e1b --- /dev/null +++ b/services/web/test/frontend/helpers/sleep.ts @@ -0,0 +1,3 @@ +export const sleep = (timeout: number) => { + return new Promise(resolve => setTimeout(resolve, timeout)) +} diff --git a/services/web/types/helpers/date.ts b/services/web/types/helpers/date.ts new file mode 100644 index 0000000000..772b6ab6a7 --- /dev/null +++ b/services/web/types/helpers/date.ts @@ -0,0 +1,3 @@ +import { Brand } from './brand' + +export type DateString = Brand diff --git a/services/web/types/review-panel/comment-thread.ts b/services/web/types/review-panel/comment-thread.ts new file mode 100644 index 0000000000..8b10fed3c7 --- /dev/null +++ b/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 + 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 diff --git a/services/web/types/review-panel/review-panel.ts b/services/web/types/review-panel/review-panel.ts index 858c47d8a6..afd4804bd8 100644 --- a/services/web/types/review-panel/review-panel.ts +++ b/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 -type ReviewPanelDocEntries = Record +export type ReviewPanelDocEntries = Record export type DocId = Brand export type ReviewPanelEntries = Record -type UserId = Brand +export type UserId = Brand -interface ReviewPanelUser { +export interface ReviewPanelUser { avatar_text: string email: string hue: number @@ -28,6 +29,7 @@ interface ReviewPanelUser { } export type CommentId = Brand + export interface ReviewPanelCommentThreadMessage { content: string id: CommentId @@ -36,15 +38,6 @@ export interface ReviewPanelCommentThreadMessage { user_id: UserId } -export interface ReviewPanelCommentThread { - messages: Array - // 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