Browse Source

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

[web] Create aggregate change entries

GitOrigin-RevId: 685ac40739f3c39665d84bd402ada21e00db5146
Eric Mc Sween 3 years ago
parent
commit
d89b62e965

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

@@ -42,6 +42,8 @@
   "additional_licenses": "",
   "address_line_1": "",
   "address_second_line_optional": "",
+  "aggregate_changed": "",
+  "aggregate_to": "",
   "all_premium_features": "",
   "all_premium_features_including": "",
   "all_projects": "",

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

@@ -79,7 +79,18 @@ function CurrentFileContainer() {
               }
 
               if (entry.type === 'aggregate-change') {
-                return <AggregateChangeEntry key={id} />
+                return (
+                  <AggregateChangeEntry
+                    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 === 'comment' && !loadingThreads) {

+ 172 - 2
services/web/frontend/js/features/source-editor/components/review-panel/entries/aggregate-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 { ReviewPanelAggregateChangeEntry } from '../../../../../../../types/review-panel/entry'
+import {
+  ReviewPanelPermissions,
+  ReviewPanelUser,
+} from '../../../../../../../types/review-panel/review-panel'
+import { DocId } from '../../../../../../../types/project-settings'
 
-function AggregateChangeEntry() {
-  return <EntryContainer>Aggregate change entry</EntryContainer>
+type AggregateChangeEntryProps = {
+  docId: DocId
+  entry: ReviewPanelAggregateChangeEntry
+  permissions: ReviewPanelPermissions
+  user: ReviewPanelUser | undefined
+  contentLimit?: number
+  onMouseEnter?: () => void
+  onMouseLeave?: () => void
+  onIndicatorClick?: () => void
+}
+
+function AggregateChangeEntry({
+  docId,
+  entry,
+  permissions,
+  user,
+  contentLimit = 17,
+  onMouseEnter,
+  onMouseLeave,
+  onIndicatorClick,
+}: AggregateChangeEntryProps) {
+  const { t } = useTranslation()
+  const { acceptChanges, rejectChanges, handleLayoutChange, gotoEntry } =
+    useReviewPanelValueContext()
+  const [isDeletionCollapsed, setIsDeletionCollapsed] = useState(true)
+  const [isInsertionCollapsed, setIsInsertionCollapsed] = useState(true)
+
+  const replacedContent = entry.metadata.replaced_content
+  const content = entry.content
+  const deletionNeedsCollapsing = replacedContent.length > contentLimit
+  const insertionNeedsCollapsing = content.length > contentLimit
+
+  const deletionContent = isDeletionCollapsed
+    ? replacedContent.substring(0, contentLimit)
+    : replacedContent
+
+  const insertionContent = isInsertionCollapsed
+    ? content.substring(0, contentLimit)
+    : content
+
+  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 handleDeletionToggleCollapse = () => {
+    setIsDeletionCollapsed(value => !value)
+    handleLayoutChange()
+  }
+
+  const handleInsertionToggleCollapse = () => {
+    setIsInsertionCollapsed(value => !value)
+    handleLayoutChange()
+  }
+
+  return (
+    <EntryContainer
+      onClick={handleEntryClick}
+      onMouseEnter={onMouseEnter}
+      onMouseLeave={onMouseLeave}
+    >
+      <div
+        className="rp-entry-callout rp-entry-callout-aggregate"
+        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}
+      >
+        <Icon type="pencil" />
+      </div>
+      <div
+        className={classnames('rp-entry', 'rp-entry-aggregate', {
+          '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">
+            <Icon type="pencil" />
+          </div>
+          <div className="rp-entry-details">
+            <div className="rp-entry-description">
+              {t('aggregate_changed')}&nbsp;
+              <del className="rp-content-highlight">{deletionContent}</del>
+              {deletionNeedsCollapsing && (
+                <button
+                  className="rp-collapse-toggle btn-inline-link"
+                  onClick={handleDeletionToggleCollapse}
+                >
+                  {isDeletionCollapsed
+                    ? `… (${t('show_all')})`
+                    : ` (${t('show_less')})`}
+                </button>
+              )}
+              &nbsp;{t('aggregate_to')}&nbsp;
+              <ins className="rp-content-highlight">{insertionContent}</ins>
+              {insertionNeedsCollapsing && (
+                <button
+                  className="rp-collapse-toggle btn-inline-link"
+                  onClick={handleInsertionToggleCollapse}
+                >
+                  {isInsertionCollapsed
+                    ? `… (${t('show_all')})`
+                    : ` (${t('show_less')})`}
+                </button>
+              )}
+            </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 AggregateChangeEntry

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

@@ -40,7 +40,7 @@ function ChangeEntry({
   const { t } = useTranslation()
   const { acceptChanges, rejectChanges, handleLayoutChange, gotoEntry } =
     useReviewPanelValueContext()
-  const [isCollapsed, setIsCollapsed] = useState(false)
+  const [isCollapsed, setIsCollapsed] = useState(true)
 
   const content = isCollapsed
     ? entry.content.substring(0, contentLimit)

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

@@ -89,7 +89,15 @@ function OverviewFile({ docId, docPath }: OverviewFileProps) {
           }
 
           if (entry.type === 'aggregate-change') {
-            return <AggregateChangeEntry key={id} />
+            return (
+              <AggregateChangeEntry
+                key={id}
+                docId={docId}
+                entry={entry}
+                permissions={permissions}
+                user={users[entry.metadata.user_id]}
+              />
+            )
           }
 
           if (entry.type === 'comment') {

+ 16 - 2
services/web/test/frontend/features/review-panel/review-panel.spec.tsx

@@ -195,10 +195,16 @@ describe('<ReviewPanel />', function () {
 
   describe('change entries', function () {
     // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('renders inserted entries', function () {})
+    it.skip('renders inserted entries in current file mode', function () {})
 
     // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('renders deleted entries', function () {})
+    it.skip('renders deleted entries in current file mode', function () {})
+
+    // eslint-disable-next-line mocha/no-skipped-tests
+    it.skip('renders inserted entries in overview mode', function () {})
+
+    // eslint-disable-next-line mocha/no-skipped-tests
+    it.skip('renders deleted entries in overview mode', function () {})
 
     // eslint-disable-next-line mocha/no-skipped-tests
     it.skip('accepts change', function () {})
@@ -207,6 +213,14 @@ describe('<ReviewPanel />', function () {
     it.skip('rejects change', function () {})
   })
 
+  describe('aggregate change entries', function () {
+    // eslint-disable-next-line mocha/no-skipped-tests
+    it.skip('renders changed entries in current file mode', function () {})
+
+    // eslint-disable-next-line mocha/no-skipped-tests
+    it.skip('renders changed entries in overview mode', function () {})
+  })
+
   describe('overview mode', function () {
     // eslint-disable-next-line mocha/no-skipped-tests
     it.skip('shows list of files changed', function () {})

+ 25 - 21
services/web/types/review-panel/entry.ts

@@ -7,46 +7,50 @@ interface ReviewPanelEntryScreenPos {
 }
 
 interface ReviewPanelBaseEntry {
-  visible: boolean
-  offset: number
-}
-
-export interface ReviewPanelCommentEntry extends ReviewPanelBaseEntry {
-  type: 'comment'
-  content: string
-  entry_ids: ThreadId[]
   focused: boolean
+  offset: number
   screenPos: ReviewPanelEntryScreenPos
-  thread_id: ThreadId
-  replyContent?: string // angular specific
+  visible: boolean
 }
 
-export interface ReviewPanelInsertEntry extends ReviewPanelBaseEntry {
-  type: 'insert'
+interface ReviewPanelInsertOrDeleteEntry {
   content: string
   entry_ids: ThreadId[]
   metadata: {
     ts: Date
     user_id: UserId
   }
-  screenPos: ReviewPanelEntryScreenPos
-  focused?: boolean
 }
 
-export interface ReviewPanelDeleteEntry extends ReviewPanelBaseEntry {
+export interface ReviewPanelInsertEntry
+  extends ReviewPanelBaseEntry,
+    ReviewPanelInsertOrDeleteEntry {
+  type: 'insert'
+}
+
+export interface ReviewPanelDeleteEntry
+  extends ReviewPanelBaseEntry,
+    ReviewPanelInsertOrDeleteEntry {
   type: 'delete'
+}
+
+export interface ReviewPanelCommentEntry extends ReviewPanelBaseEntry {
+  type: 'comment'
+  content: string
+  entry_ids: ThreadId[]
+  thread_id: ThreadId
+  replyContent?: string // angular specific
+}
+
+export interface ReviewPanelAggregateChangeEntry extends ReviewPanelBaseEntry {
+  type: 'aggregate-change'
   content: string
   entry_ids: ThreadId[]
   metadata: {
+    replaced_content: string
     ts: Date
     user_id: UserId
   }
-  screenPos: ReviewPanelEntryScreenPos
-  focused?: boolean
-}
-
-interface ReviewPanelAggregateChangeEntry extends ReviewPanelBaseEntry {
-  type: 'aggregate-change'
 }
 
 interface ReviewPanelAddCommentEntry extends ReviewPanelBaseEntry {