Просмотр исходного кода

Merge pull request #13658 from overleaf/ii-review-panel-migration-change-entry

[web] Create change entries

GitOrigin-RevId: 4d49f8b02b9bfcfe470f6db21f5347080ff47562
Davinder Singh 3 лет назад
Родитель
Сommit
b1f246b9ba

+ 4 - 0
services/web/frontend/extracted-translations.json

@@ -13,6 +13,7 @@
   "about_to_enable_managed_users": "",
   "about_to_leave_projects": "",
   "about_to_trash_projects": "",
+  "accept": "",
   "accepted_invite": "",
   "access_denied": "",
   "account_has_been_link_to_institution_account": "",
@@ -790,6 +791,7 @@
   "refresh_page_after_starting_free_trial": "",
   "refreshing": "",
   "regards": "",
+  "reject": "",
   "relink_your_account": "",
   "remote_service_error": "",
   "remove": "",
@@ -1031,6 +1033,8 @@
   "track_changes_for_x": "",
   "track_changes_is_off": "",
   "track_changes_is_on": "",
+  "tracked_change_added": "",
+  "tracked_change_deleted": "",
   "trash": "",
   "trash_projects": "",
   "trashed": "",

+ 29 - 4
services/web/frontend/js/features/source-editor/components/review-panel/current-file-container.tsx

@@ -8,14 +8,25 @@ import AggregateChangeEntry from './entries/aggregate-change-entry'
 import CommentEntry from './entries/comment-entry'
 import AddCommentEntry from './entries/add-comment-entry'
 import BulkActionsEntry from './entries/bulk-actions-entry'
-import { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context'
+import {
+  useReviewPanelUpdaterFnsContext,
+  useReviewPanelValueContext,
+} from '../../context/review-panel/review-panel-context'
 import useCodeMirrorContentHeight from '../../hooks/use-codemirror-content-height'
 import { ReviewPanelEntry } from '../../../../../../types/review-panel/entry'
 import { ThreadId } from '../../../../../../types/review-panel/review-panel'
 
 function CurrentFileContainer() {
-  const { commentThreads, entries, openDocId, permissions, loadingThreads } =
-    useReviewPanelValueContext()
+  const {
+    commentThreads,
+    entries,
+    openDocId,
+    permissions,
+    loadingThreads,
+    users,
+    toggleReviewPanel,
+  } = useReviewPanelValueContext()
+  const { setEntryHover } = useReviewPanelUpdaterFnsContext()
   const contentHeight = useCodeMirrorContentHeight()
 
   console.log('Review panel got content height', contentHeight)
@@ -53,7 +64,18 @@ function CurrentFileContainer() {
               }
 
               if (entry.type === 'insert' || entry.type === 'delete') {
-                return <ChangeEntry key={id} />
+                return (
+                  <ChangeEntry
+                    key={id}
+                    docId={openDocId}
+                    entry={entry}
+                    permissions={permissions}
+                    user={users[entry.metadata.user_id]}
+                    onMouseEnter={setEntryHover.bind(null, true)}
+                    onMouseLeave={setEntryHover.bind(null, false)}
+                    onIndicatorClick={toggleReviewPanel}
+                  />
+                )
               }
 
               if (entry.type === 'aggregate-change') {
@@ -69,6 +91,9 @@ function CurrentFileContainer() {
                     entryId={id}
                     permissions={permissions}
                     threads={commentThreads}
+                    onMouseEnter={setEntryHover.bind(null, true)}
+                    onMouseLeave={setEntryHover.bind(null, false)}
+                    onIndicatorClick={toggleReviewPanel}
                   />
                 )
               }

+ 172 - 2
services/web/frontend/js/features/source-editor/components/review-panel/entries/change-entry.tsx

@@ -1,7 +1,177 @@
+import { useTranslation } from 'react-i18next'
+import { useState } from 'react'
 import EntryContainer from './entry-container'
+import EntryActions from './entry-actions'
+import Icon from '../../../../../shared/components/icon'
+import { useReviewPanelValueContext } from '../../../context/review-panel/review-panel-context'
+import { formatTime } from '../../../../utils/format-date'
+import classnames from 'classnames'
+import {
+  ReviewPanelDeleteEntry,
+  ReviewPanelInsertEntry,
+} from '../../../../../../../types/review-panel/entry'
+import {
+  ReviewPanelPermissions,
+  ReviewPanelUser,
+} from '../../../../../../../types/review-panel/review-panel'
+import { DocId } from '../../../../../../../types/project-settings'
 
-function ChangeEntry() {
-  return <EntryContainer>Change entry</EntryContainer>
+type ChangeEntryProps = {
+  docId: DocId
+  entry: ReviewPanelInsertEntry | ReviewPanelDeleteEntry
+  permissions: ReviewPanelPermissions
+  user: ReviewPanelUser | undefined
+  contentLimit?: number
+  onMouseEnter?: () => void
+  onMouseLeave?: () => void
+  onIndicatorClick?: () => void
+}
+
+function ChangeEntry({
+  docId,
+  entry,
+  permissions,
+  user,
+  contentLimit = 40,
+  onMouseEnter,
+  onMouseLeave,
+  onIndicatorClick,
+}: ChangeEntryProps) {
+  const { t } = useTranslation()
+  const { acceptChanges, rejectChanges, handleLayoutChange, gotoEntry } =
+    useReviewPanelValueContext()
+  const [isCollapsed, setIsCollapsed] = useState(false)
+
+  const content = isCollapsed
+    ? entry.content.substring(0, contentLimit)
+    : entry.content
+
+  const needsCollapsing = entry.content.length > contentLimit
+
+  const handleEntryClick = (e: React.MouseEvent<HTMLDivElement>) => {
+    const target = e.target as Element
+
+    for (const selector of [
+      '.rp-entry',
+      '.rp-entry-description',
+      '.rp-entry-body',
+      '.rp-entry-action-icon i',
+    ]) {
+      if (target.matches(selector)) {
+        gotoEntry(docId, entry.offset)
+        break
+      }
+    }
+  }
+
+  const handleToggleCollapse = () => {
+    setIsCollapsed(value => !value)
+    handleLayoutChange()
+  }
+
+  return (
+    <EntryContainer
+      onClick={handleEntryClick}
+      onMouseEnter={onMouseEnter}
+      onMouseLeave={onMouseLeave}
+    >
+      <div
+        className={classnames(
+          'rp-entry-callout',
+          `rp-entry-callout-${entry.type}`
+        )}
+        style={{
+          top: entry.screenPos
+            ? entry.screenPos.y + entry.screenPos.height - 1 + 'px'
+            : undefined,
+        }}
+      />
+      {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
+      <div
+        className={classnames('rp-entry-indicator', {
+          'rp-entry-indicator-focused': entry.focused,
+        })}
+        style={{
+          top: entry.screenPos ? entry.screenPos.y + 'px' : undefined,
+        }}
+        onClick={onIndicatorClick}
+      >
+        {entry.type === 'insert' ? (
+          <Icon type="pencil" />
+        ) : (
+          <i className="rp-icon-delete" />
+        )}
+      </div>
+      <div
+        className={classnames('rp-entry', `rp-entry-${entry.type}`, {
+          'rp-entry-focused': entry.focused,
+        })}
+        style={{
+          top: entry.screenPos ? entry.screenPos.y + 'px' : undefined,
+          visibility: entry.visible ? 'visible' : 'hidden',
+        }}
+      >
+        <div className="rp-entry-body">
+          <div className="rp-entry-action-icon">
+            {entry.type === 'insert' ? (
+              <Icon type="pencil" />
+            ) : (
+              <i className="rp-icon-delete" />
+            )}
+          </div>
+          <div className="rp-entry-details">
+            <div className="rp-entry-description">
+              <span>
+                {entry.type === 'insert' ? (
+                  <>
+                    {t('tracked_change_added')}&nbsp;
+                    <ins className="rp-content-highlight">{content}</ins>
+                  </>
+                ) : (
+                  <>
+                    {t('tracked_change_deleted')}&nbsp;
+                    <del className="rp-content-highlight">{content}</del>
+                  </>
+                )}
+                {needsCollapsing && (
+                  <button
+                    className="rp-collapse-toggle btn-inline-link"
+                    onClick={handleToggleCollapse}
+                  >
+                    {isCollapsed
+                      ? `… (${t('show_all')})`
+                      : ` (${t('show_less')})`}
+                  </button>
+                )}
+              </span>
+            </div>
+            <div className="rp-entry-metadata">
+              {formatTime(entry.metadata.ts, 'MMM d, y h:mm a')}
+              &nbsp;&bull;&nbsp;
+              {user && (
+                <span
+                  className="rp-entry-user"
+                  style={{ color: `hsl(${user.hue}, 70%, 40%)` }}
+                >
+                  {user.name ?? t('anonymous')}
+                </span>
+              )}
+            </div>
+          </div>
+        </div>
+        {permissions.write && (
+          <EntryActions>
+            <EntryActions.Button onClick={() => rejectChanges(entry.entry_ids)}>
+              <Icon type="times" /> {t('reject')}
+            </EntryActions.Button>
+            <EntryActions.Button onClick={() => acceptChanges(entry.entry_ids)}>
+              <Icon type="check" /> {t('accept')}
+            </EntryActions.Button>
+          </EntryActions>
+        )}
+      </div>
+    </EntryContainer>
+  )
 }
 
 export default ChangeEntry

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

@@ -7,10 +7,7 @@ import AutoExpandingTextArea, {
   resetHeight,
 } from '../../../../../shared/components/auto-expanding-text-area'
 import Icon from '../../../../../shared/components/icon'
-import {
-  useReviewPanelUpdaterFnsContext,
-  useReviewPanelValueContext,
-} from '../../../context/review-panel/review-panel-context'
+import { useReviewPanelValueContext } from '../../../context/review-panel/review-panel-context'
 import classnames from 'classnames'
 import { ReviewPanelCommentEntry } from '../../../../../../../types/review-panel/entry'
 import {
@@ -26,6 +23,9 @@ type CommentEntryProps = {
   entryId: ThreadId
   permissions: ReviewPanelPermissions
   threads: ReviewPanelCommentThreads
+  onMouseEnter?: () => void
+  onMouseLeave?: () => void
+  onIndicatorClick?: () => void
 }
 
 function CommentEntry({
@@ -34,17 +34,13 @@ function CommentEntry({
   entryId,
   permissions,
   threads,
+  onMouseEnter,
+  onMouseLeave,
+  onIndicatorClick,
 }: CommentEntryProps) {
   const { t } = useTranslation()
-  const {
-    gotoEntry,
-    toggleReviewPanel,
-    resolveComment,
-    submitReply,
-    handleLayoutChange,
-  } = useReviewPanelValueContext()
-  const { setEntryHover } = useReviewPanelUpdaterFnsContext()
-
+  const { gotoEntry, resolveComment, submitReply, handleLayoutChange } =
+    useReviewPanelValueContext()
   const [replyContent, setReplyContent] = useState('')
   const [animating, setAnimating] = useState(false)
   const [resolved, setResolved] = useState(false)
@@ -55,16 +51,18 @@ function CommentEntry({
 
   const handleEntryClick = (e: React.MouseEvent<HTMLDivElement>) => {
     const target = e.target as Element
-    if (
-      [
-        'rp-entry',
-        'rp-comment-loaded',
-        'rp-comment-content',
-        'rp-comment-reply',
-        'rp-entry-metadata',
-      ].some(className => [...target.classList].includes(className))
-    ) {
-      gotoEntry(docId, entry.offset)
+
+    for (const selector of [
+      '.rp-entry',
+      '.rp-comment-loaded',
+      '.rp-comment-content',
+      '.rp-comment-reply',
+      '.rp-entry-metadata',
+    ]) {
+      if (target.matches(selector)) {
+        gotoEntry(docId, entry.offset)
+        break
+      }
     }
   }
 
@@ -109,15 +107,16 @@ function CommentEntry({
   }
 
   return (
-    <EntryContainer>
+    <EntryContainer
+      onClick={handleEntryClick}
+      onMouseEnter={onMouseEnter}
+      onMouseLeave={onMouseLeave}
+    >
       {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
       <div
         className={classnames('rp-comment-wrapper', {
           'rp-comment-wrapper-resolving': animating,
         })}
-        onMouseEnter={() => setEntryHover(true)}
-        onMouseLeave={() => setEntryHover(false)}
-        onClick={handleEntryClick}
       >
         <div
           className="rp-entry-callout rp-entry-callout-comment"
@@ -135,7 +134,7 @@ function CommentEntry({
           style={{
             top: entry.screenPos ? `${entry.screenPos.y}px` : undefined,
           }}
-          onClick={toggleReviewPanel}
+          onClick={onIndicatorClick}
         >
           <Icon type="comment" />
         </div>

+ 3 - 5
services/web/frontend/js/features/source-editor/components/review-panel/entries/entry-container.tsx

@@ -1,9 +1,7 @@
-type EntryContainerProps = {
-  children: React.ReactNode
-}
+import classnames from 'classnames'
 
-function EntryContainer({ children }: EntryContainerProps) {
-  return <div className="rp-entry-wrapper">{children}</div>
+function EntryContainer({ className, ...rest }: React.ComponentProps<'div'>) {
+  return <div className={classnames('rp-entry-wrapper', className)} {...rest} />
 }
 
 export default EntryContainer

+ 10 - 2
services/web/frontend/js/features/source-editor/components/review-panel/overview-file.tsx

@@ -19,7 +19,7 @@ type OverviewFileProps = {
 }
 
 function OverviewFile({ docId, docPath }: OverviewFileProps) {
-  const { entries, collapsed, commentThreads, permissions } =
+  const { entries, collapsed, commentThreads, permissions, users } =
     useReviewPanelValueContext()
   const { setCollapsed } = useReviewPanelUpdaterFnsContext()
 
@@ -77,7 +77,15 @@ function OverviewFile({ docId, docPath }: OverviewFileProps) {
       <div className="rp-overview-file-entries" ref={entriesContainerRef}>
         {objectEntries.map(([id, entry]) => {
           if (entry.type === 'insert' || entry.type === 'delete') {
-            return <ChangeEntry key={id} />
+            return (
+              <ChangeEntry
+                key={id}
+                docId={docId}
+                entry={entry}
+                permissions={permissions}
+                user={users[entry.metadata.user_id]}
+              />
+            )
           }
 
           if (entry.type === 'aggregate-change') {

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

@@ -33,6 +33,7 @@ function useAngularReviewPanelState(): ReviewPanelState {
 
   const [permissions] =
     useScopeValue<ReviewPanel.Value<'permissions'>>('permissions')
+  const [users] = useScopeValue<ReviewPanel.Value<'users'>>('users', true)
   const [resolvedComments] = useScopeValue<
     ReviewPanel.Value<'resolvedComments'>
   >('reviewPanel.resolvedComments', true)
@@ -90,6 +91,10 @@ function useAngularReviewPanelState(): ReviewPanelState {
   const [refreshResolvedCommentsDropdown] = useScopeValue<
     ReviewPanel.Value<'refreshResolvedCommentsDropdown'>
   >('refreshResolvedCommentsDropdown')
+  const [acceptChanges] =
+    useScopeValue<ReviewPanel.Value<'acceptChanges'>>('acceptChanges')
+  const [rejectChanges] =
+    useScopeValue<ReviewPanel.Value<'rejectChanges'>>('rejectChanges')
 
   const handleSetSubview = useCallback(
     (subView: SubView) => {
@@ -126,6 +131,7 @@ function useAngularReviewPanelState(): ReviewPanelState {
       handleLayoutChange,
       loadingThreads,
       permissions,
+      users,
       resolveComment,
       resolvedComments,
       saveEdit,
@@ -147,6 +153,8 @@ function useAngularReviewPanelState(): ReviewPanelState {
       unresolveComment,
       deleteThread,
       refreshResolvedCommentsDropdown,
+      acceptChanges,
+      rejectChanges,
     }),
     [
       collapsed,
@@ -159,6 +167,7 @@ function useAngularReviewPanelState(): ReviewPanelState {
       handleLayoutChange,
       loadingThreads,
       permissions,
+      users,
       resolveComment,
       resolvedComments,
       saveEdit,
@@ -180,6 +189,8 @@ function useAngularReviewPanelState(): ReviewPanelState {
       unresolveComment,
       deleteThread,
       refreshResolvedCommentsDropdown,
+      acceptChanges,
+      rejectChanges,
     ]
   )
 

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

@@ -3,6 +3,7 @@ import {
   ReviewPanelCommentThreads,
   ReviewPanelEntries,
   ReviewPanelPermissions,
+  ReviewPanelUsers,
   SubView,
   ThreadId,
 } from '../../../../../../../types/review-panel/review-panel'
@@ -24,6 +25,7 @@ export interface ReviewPanelState {
     handleLayoutChange: () => void
     loadingThreads: boolean
     permissions: ReviewPanelPermissions
+    users: ReviewPanelUsers
     resolveComment: (docId: DocId, entryId: ThreadId) => void
     resolvedComments: ReviewPanelEntries
     saveEdit: (
@@ -55,6 +57,8 @@ export interface ReviewPanelState {
     unresolveComment: (threadId: ThreadId) => void
     deleteThread: (_entryId: unknown, docId: DocId, threadId: ThreadId) => void
     refreshResolvedCommentsDropdown: () => Promise<void>
+    acceptChanges: (entryIds: unknown) => void
+    rejectChanges: (entryIds: unknown) => void
   }
   updaterFns: {
     handleSetSubview: (subView: SubView) => void

+ 14 - 0
services/web/test/frontend/features/review-panel/review-panel.spec.tsx

@@ -193,6 +193,20 @@ describe('<ReviewPanel />', function () {
     it.skip('resolves comment', function () {})
   })
 
+  describe('change entries', function () {
+    // eslint-disable-next-line mocha/no-skipped-tests
+    it.skip('renders inserted entries', function () {})
+
+    // eslint-disable-next-line mocha/no-skipped-tests
+    it.skip('renders deleted entries', function () {})
+
+    // eslint-disable-next-line mocha/no-skipped-tests
+    it.skip('accepts change', function () {})
+
+    // eslint-disable-next-line mocha/no-skipped-tests
+    it.skip('rejects change', function () {})
+  })
+
   describe('overview mode', function () {
     // eslint-disable-next-line mocha/no-skipped-tests
     it.skip('shows list of files changed', function () {})

+ 20 - 4
services/web/types/review-panel/entry.ts

@@ -1,4 +1,4 @@
-import { ThreadId } from './review-panel'
+import { ThreadId, UserId } from './review-panel'
 
 interface ReviewPanelEntryScreenPos {
   y: number
@@ -14,19 +14,35 @@ interface ReviewPanelBaseEntry {
 export interface ReviewPanelCommentEntry extends ReviewPanelBaseEntry {
   type: 'comment'
   content: string
-  entry_ids: string[]
+  entry_ids: ThreadId[]
   focused: boolean
   screenPos: ReviewPanelEntryScreenPos
   thread_id: ThreadId
   replyContent?: string // angular specific
 }
 
-interface ReviewPanelInsertEntry extends ReviewPanelBaseEntry {
+export interface ReviewPanelInsertEntry extends ReviewPanelBaseEntry {
   type: 'insert'
+  content: string
+  entry_ids: ThreadId[]
+  metadata: {
+    ts: Date
+    user_id: UserId
+  }
+  screenPos: ReviewPanelEntryScreenPos
+  focused?: boolean
 }
 
-interface ReviewPanelDeleteEntry extends ReviewPanelBaseEntry {
+export interface ReviewPanelDeleteEntry extends ReviewPanelBaseEntry {
   type: 'delete'
+  content: string
+  entry_ids: ThreadId[]
+  metadata: {
+    ts: Date
+    user_id: UserId
+  }
+  screenPos: ReviewPanelEntryScreenPos
+  focused?: boolean
 }
 
 interface ReviewPanelAggregateChangeEntry extends ReviewPanelBaseEntry {

+ 1 - 0
services/web/types/review-panel/review-panel.ts

@@ -27,6 +27,7 @@ export interface ReviewPanelUser {
   isSelf: boolean
   name: string
 }
+export type ReviewPanelUsers = Record<UserId, ReviewPanelUser>
 
 export type CommentId = Brand<string, 'CommentId'>