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

Merge pull request #13438 from overleaf/ii-review-panel-toolbar-shell

[web] Create review panel toolbar shell

GitOrigin-RevId: 561fb18e1239c9b96b52944716a83cf3b8606677
ilkin-overleaf 3 лет назад
Родитель
Сommit
e514e97305
21 измененных файлов с 933 добавлено и 41 удалено
  1. 20 0
      package-lock.json
  2. 16 0
      services/web/frontend/extracted-translations.json
  3. 9 1
      services/web/frontend/js/features/source-editor/components/review-panel/container.tsx
  4. 28 2
      services/web/frontend/js/features/source-editor/components/review-panel/current-file-container.tsx
  5. 6 0
      services/web/frontend/js/features/source-editor/components/review-panel/nav.tsx
  6. 142 0
      services/web/frontend/js/features/source-editor/components/review-panel/toolbar/resolved-comment-entry.tsx
  7. 81 0
      services/web/frontend/js/features/source-editor/components/review-panel/toolbar/resolved-comments-dropdown.tsx
  8. 54 0
      services/web/frontend/js/features/source-editor/components/review-panel/toolbar/resolved-comments-scroller.tsx
  9. 184 0
      services/web/frontend/js/features/source-editor/components/review-panel/toolbar/toggle-menu.tsx
  10. 13 0
      services/web/frontend/js/features/source-editor/components/review-panel/toolbar/toolbar.tsx
  11. 33 0
      services/web/frontend/js/features/source-editor/components/review-panel/toolbar/track-changes-toggle.tsx
  12. 95 0
      services/web/frontend/js/features/source-editor/context/review-panel/hooks/use-angular-review-panel-state.ts
  13. 0 33
      services/web/frontend/js/features/source-editor/context/review-panel/hooks/use-angular-review-panel.ts
  14. 1 1
      services/web/frontend/js/features/source-editor/context/review-panel/review-panel-context.tsx
  15. 21 0
      services/web/frontend/js/features/source-editor/context/review-panel/types/review-panel-state.ts
  16. 82 4
      services/web/frontend/stylesheets/app/editor/review-panel.less
  17. 2 0
      services/web/locales/en.json
  18. 1 0
      services/web/package.json
  19. 121 0
      services/web/test/frontend/features/review-panel/review-panel.spec.tsx
  20. 18 0
      services/web/test/frontend/features/source-editor/helpers/mock-scope.ts
  21. 6 0
      services/web/types/review-panel.ts

+ 20 - 0
package-lock.json

@@ -12079,6 +12079,15 @@
         "@types/react": "*"
       }
     },
+    "node_modules/@types/react-linkify": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@types/react-linkify/-/react-linkify-1.0.0.tgz",
+      "integrity": "sha512-2NKXPQGaHNfh/dCqkVC55k1tAhQyNoNZa31J50nIneMVwHqUI00FAP+Lyp8e0BarPf84kn4GRVAhtWX9XJBzSQ==",
+      "dev": true,
+      "dependencies": {
+        "@types/react": "*"
+      }
+    },
     "node_modules/@types/react/node_modules/csstype": {
       "version": "3.0.10",
       "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.10.tgz",
@@ -41617,6 +41626,7 @@
         "@types/react-bootstrap": "^0.32.29",
         "@types/react-color": "^3.0.6",
         "@types/react-dom": "^17.0.13",
+        "@types/react-linkify": "^1.0.0",
         "@types/recurly__recurly-js": "^4.22.0",
         "@types/sinon-chai": "^3.2.8",
         "@types/uuid": "^8.3.4",
@@ -50211,6 +50221,7 @@
         "@types/react-bootstrap": "^0.32.29",
         "@types/react-color": "^3.0.6",
         "@types/react-dom": "^17.0.13",
+        "@types/react-linkify": "1.0.0",
         "@types/recurly__recurly-js": "^4.22.0",
         "@types/sinon-chai": "^3.2.8",
         "@types/uuid": "^8.3.4",
@@ -53922,6 +53933,15 @@
         "@types/react": "*"
       }
     },
+    "@types/react-linkify": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@types/react-linkify/-/react-linkify-1.0.0.tgz",
+      "integrity": "sha512-2NKXPQGaHNfh/dCqkVC55k1tAhQyNoNZa31J50nIneMVwHqUI00FAP+Lyp8e0BarPf84kn4GRVAhtWX9XJBzSQ==",
+      "dev": true,
+      "requires": {
+        "@types/react": "*"
+      }
+    },
     "@types/reactcss": {
       "version": "1.2.6",
       "resolved": "https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.6.tgz",

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

@@ -567,6 +567,7 @@
   "manage_sessions": "",
   "manage_subscription": "",
   "managers_management": "",
+  "mark_as_resolved": "",
   "math_display": "",
   "math_inline": "",
   "maximum_files_uploaded_together": "",
@@ -624,6 +625,7 @@
   "no_pdf_error_title": "",
   "no_preview_available": "",
   "no_projects": "",
+  "no_resolved_threads": "",
   "no_search_results": "",
   "no_symbols_found": "",
   "no_thanks_cancel_now": "",
@@ -739,6 +741,7 @@
   "publishing": "",
   "pull_github_changes_into_sharelatex": "",
   "push_sharelatex_changes_to_github": "",
+  "quoted_text_in": "",
   "raw_logs": "",
   "raw_logs_description": "",
   "reactivate_subscription": "",
@@ -777,6 +780,7 @@
   "removing": "",
   "rename": "",
   "rename_project": "",
+  "reopen": "",
   "replace_figure": "",
   "replace_from_another_project": "",
   "replace_from_computer": "",
@@ -787,6 +791,7 @@
   "resend": "",
   "resend_confirmation_email": "",
   "resending_confirmation_email": "",
+  "resolved_comments": "",
   "restore_file": "",
   "restoring": "",
   "reverse_x_sort_order": "",
@@ -865,12 +870,14 @@
   "share_with_your_collabs": "",
   "shared_with_you": "",
   "sharelatex_beta_program": "",
+  "show_all": "",
   "show_all_projects": "",
   "show_all_uppercase": "",
   "show_document_preamble": "",
   "show_hotkeys": "",
   "show_in_code": "",
   "show_in_pdf": "",
+  "show_less": "",
   "show_outline": "",
   "show_x_more": "",
   "show_x_more_projects": "",
@@ -927,6 +934,11 @@
   "tag_name_is_already_used": "",
   "tags": "",
   "take_short_survey": "",
+  "tc_everyone": "",
+  "tc_guests": "",
+  "tc_switch_everyone_tip": "",
+  "tc_switch_guests_tip": "",
+  "tc_switch_user_tip": "",
   "template_approved_by_publisher": "",
   "template_description": "",
   "template_title_taken_from_project_title": "",
@@ -990,6 +1002,10 @@
   "total_with_subtotal_and_tax": "",
   "total_words": "",
   "track_changes": "",
+  "track_changes_for_everyone": "",
+  "track_changes_for_x": "",
+  "track_changes_is_off": "",
+  "track_changes_is_on": "",
   "trash": "",
   "trash_projects": "",
   "trashed": "",

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

@@ -1,3 +1,5 @@
+import Toolbar from './toolbar/toolbar'
+import Nav from './nav'
 import classnames from 'classnames'
 
 const reviewPanelClasses = ['ol-cm-review-panel']
@@ -10,8 +12,14 @@ type ContainerProps = {
 
 function Container({ children, classNames, ...rest }: ContainerProps) {
   return (
-    <div className={classnames(...reviewPanelClasses, classNames)} {...rest}>
+    <div
+      className={classnames(...reviewPanelClasses, classNames)}
+      {...rest}
+      data-testid="review-panel"
+    >
+      <Toolbar />
       {children}
+      <Nav />
     </div>
   )
 }

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

@@ -3,8 +3,34 @@ import Container from './container'
 function CurrentFileContainer() {
   return (
     <Container>
-      <em>ReviewPanelCurrentFileContainer</em>
-      <div className="review-panel-tools">Tools</div>
+      Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
+      tempor incididunt ut labore et dolore magna aliqua. Et malesuada fames ac
+      turpis egestas integer eget aliquet nibh. Et leo duis ut diam quam nulla
+      porttitor massa id. Risus quis varius quam quisque id diam vel quam
+      elementum. Nibh venenatis cras sed felis. Sit amet commodo nulla facilisi
+      nullam vehicula ipsum a arcu. Dui ut ornare lectus sit amet est placerat
+      in. Aliquam ultrices sagittis orci a. Leo a diam sollicitudin tempor id eu
+      nisl nunc mi. Quis ipsum suspendisse ultrices gravida dictum fusce. Ut
+      etiam sit amet nisl purus in mollis nunc sed. Rhoncus est pellentesque
+      elit ullamcorper dignissim cras. Faucibus turpis in eu mi bibendum. Proin
+      libero nunc consequat interdum. Ac placerat vestibulum lectus mauris
+      ultrices eros in cursus turpis. Ac felis donec et odio. Nullam ac tortor
+      vitae purus faucibus. Consectetur lorem donec massa sapien faucibus et
+      molestie. Praesent elementum facilisis leo vel fringilla est ullamcorper
+      eget nulla. Adipiscing vitae proin sagittis nisl rhoncus mattis rhoncus
+      urna. Cursus metus aliquam eleifend mi in nulla posuere sollicitudin
+      aliquam. Eget nullam non nisi est sit amet facilisis magna. Donec
+      adipiscing tristique risus nec feugiat in fermentum posuere. Gravida
+      rutrum quisque non tellus orci ac auctor augue. Euismod in pellentesque
+      massa placerat duis ultricies lacus. Pellentesque diam volutpat commodo
+      sed egestas. Tempus iaculis urna id volutpat lacus laoreet. Lorem ipsum
+      dolor sit amet consectetur. Tincidunt id aliquet risus feugiat in ante
+      metus. Risus ultricies tristique nulla aliquet enim tortor at auctor urna.
+      Purus in mollis nunc sed. In ante metus dictum at. Magna eget est lorem
+      ipsum dolor sit. Fusce id velit ut tortor pretium viverra. Augue neque
+      gravida in fermentum et sollicitudin ac. Et malesuada fames ac turpis.
+      Felis bibendum ut tristique et egestas quis ipsum suspendisse ultrices.
+      Varius vel pharetra vel turpis nunc eget.
     </Container>
   )
 }

+ 6 - 0
services/web/frontend/js/features/source-editor/components/review-panel/nav.tsx

@@ -1 +1,7 @@
 export type SubView = 'cur_file' | 'overview'
+
+function Nav() {
+  return <div style={{ background: '#bbb', padding: '10px' }}>Nav</div>
+}
+
+export default Nav

+ 142 - 0
services/web/frontend/js/features/source-editor/components/review-panel/toolbar/resolved-comment-entry.tsx

@@ -0,0 +1,142 @@
+import { useTranslation } from 'react-i18next'
+import { useState } from 'react'
+import Linkify from 'react-linkify'
+import { formatTime } from '../../../../utils/format-date'
+import { useReviewPanelValueContext } from '../../../context/review-panel/review-panel-context'
+
+function LinkDecorator(
+  decoratedHref: string,
+  decoratedText: string,
+  key: number
+) {
+  return (
+    <a target="blank" rel="noreferrer noopener" href={decoratedHref} key={key}>
+      {decoratedText}
+    </a>
+  )
+}
+
+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
+  contentLimit?: number
+}
+
+function ResolvedCommentEntry({
+  thread,
+  contentLimit = 40,
+}: ResolvedCommentEntryProps) {
+  const { t } = useTranslation()
+  const { permissions } = useReviewPanelValueContext()
+  const [isCollapsed, setIsCollapsed] = useState(false)
+  const needsCollapsing = thread.content.length > contentLimit
+  const content = isCollapsed
+    ? thread.content.substring(0, contentLimit)
+    : thread.content
+
+  const handleUnresolve = () => {
+    // TODO unresolve comment
+  }
+
+  const handleDelete = () => {
+    // TODO delete thread
+  }
+
+  return (
+    <div className="rp-resolved-comment">
+      <div>
+        <div className="rp-resolved-comment-context">
+          {t('quoted_text_in')}
+          &nbsp;
+          <span className="rp-resolved-comment-context-file">
+            {thread.docName}
+          </span>
+          <p className="rp-resolved-comment-context-quote">
+            <span>{content}</span>
+          </p>
+          {needsCollapsing && (
+            <>
+              &nbsp;
+              <button
+                className="rp-collapse-toggle btn-inline-link"
+                onClick={() => setIsCollapsed(value => !value)}
+              >
+                {isCollapsed ? `… (${t('show_all')})` : ` (${t('show_less')})`}
+              </button>
+            </>
+          )}
+        </div>
+        {thread.messages.map((comment, index) => {
+          const showUser =
+            index === 0 ||
+            comment.user.id !== thread.messages[index - 1].user.id
+
+          return (
+            <div className="rp-comment" key={comment.id}>
+              <p className="rp-comment-content">
+                {showUser && (
+                  <span
+                    className="rp-entry-user"
+                    style={{ color: `hsl(${comment.user.hue}, 70%, 40%)` }}
+                  >
+                    {comment.user.name}:&nbsp;
+                  </span>
+                )}
+                <Linkify componentDecorator={LinkDecorator}>
+                  {comment.content}
+                </Linkify>
+              </p>
+              <div className="rp-entry-metadata">
+                {formatTime(comment.timestamp, 'MMM d, y h:mm a')}
+              </div>
+            </div>
+          )
+        })}
+        <div className="rp-comment rp-comment-resolver">
+          <p className="rp-comment-resolver-content">
+            <span
+              className="rp-entry-user"
+              style={{ color: `hsl(${thread.resolved_by_user.hue}, 70%, 40%)` }}
+            >
+              {thread.resolved_by_user.name}:&nbsp;
+            </span>
+            {t('mark_as_resolved')}.
+          </p>
+          <div className="rp-entry-metadata">
+            {formatTime(thread.resolved_at, 'MMM d, y h:mm a')}
+          </div>
+        </div>
+      </div>
+      {permissions.comment && permissions.write && (
+        <div className="rp-entry-actions">
+          <button className="rp-entry-button" onClick={handleUnresolve}>
+            {t('reopen')}
+          </button>
+          <button className="rp-entry-button" onClick={handleDelete}>
+            {t('delete')}
+          </button>
+        </div>
+      )}
+    </div>
+  )
+}
+
+export default ResolvedCommentEntry

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

@@ -0,0 +1,81 @@
+import { useTranslation } from 'react-i18next'
+import { useState } from 'react'
+import Icon from '../../../../../shared/components/icon'
+import Tooltip from '../../../../../shared/components/tooltip'
+import ResolvedCommentsScroller from './resolved-comments-scroller'
+import classnames from 'classnames'
+
+function ResolvedCommentsDropdown() {
+  const { t } = useTranslation()
+  const [isOpen, setIsOpen] = useState(false)
+  // TODO setIsLoading
+  // eslint-disable-next-line no-unused-vars
+  const [isLoading, setIsLoading] = useState(false)
+
+  return (
+    <div className="resolved-comments">
+      <div
+        aria-hidden="true"
+        className={classnames('resolved-comments-backdrop', {
+          'resolved-comments-backdrop-visible': isOpen,
+        })}
+        onClick={() => setIsOpen(false)}
+      />
+
+      <Tooltip
+        id="resolved-comments-toggle"
+        description={t('resolved_comments')}
+        overlayProps={{ container: document.body, placement: 'bottom' }}
+      >
+        <button
+          className="resolved-comments-toggle"
+          onClick={() => setIsOpen(value => !value)}
+          aria-label={t('resolved_comments')}
+        >
+          <Icon type="inbox" />
+        </button>
+      </Tooltip>
+
+      <div
+        className={classnames('resolved-comments-dropdown', {
+          'resolved-comments-dropdown-open': isOpen,
+        })}
+      >
+        {isLoading ? (
+          <div className="rp-loading">
+            <Icon type="spinner" spin />
+          </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',
+                },
+              },
+            ]}
+          />
+        )}
+      </div>
+    </div>
+  )
+}
+
+export default ResolvedCommentsDropdown

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

@@ -0,0 +1,54 @@
+import { useTranslation } from 'react-i18next'
+import { useMemo } from 'react'
+import ResolvedCommentEntry from './resolved-comment-entry'
+import moment from 'moment'
+
+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
+}
+
+function ResolvedCommentsScroller({
+  resolvedComments,
+}: 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()
+    )
+  }, [resolvedComments])
+
+  return (
+    <div className="resolved-comments-scroller">
+      {sortedResolvedComments.map(comment => (
+        <ResolvedCommentEntry key={comment.entryId} thread={comment} />
+      ))}
+      {!resolvedComments.length && (
+        <div className="rp-loading">{t('no_resolved_threads')}</div>
+      )}
+    </div>
+  )
+}
+
+export default ResolvedCommentsScroller

+ 184 - 0
services/web/frontend/js/features/source-editor/components/review-panel/toolbar/toggle-menu.tsx

@@ -0,0 +1,184 @@
+import { useEffect, useRef } from 'react'
+import { Trans, useTranslation } from 'react-i18next'
+import Tooltip from '../../../../../shared/components/tooltip'
+import Icon from '../../../../../shared/components/icon'
+import TrackChangesToggle from './track-changes-toggle'
+import { useProjectContext } from '../../../../../shared/context/project-context'
+import {
+  useReviewPanelUpdaterFnsContext,
+  useReviewPanelValueContext,
+} from '../../../context/review-panel/review-panel-context'
+import classnames from 'classnames'
+
+function ToggleMenu() {
+  const { t } = useTranslation()
+  const project = useProjectContext()
+  const { setShouldCollapse } = useReviewPanelUpdaterFnsContext()
+  const {
+    permissions,
+    wantTrackChanges,
+    shouldCollapse,
+    toggleTrackChangesForEveryone,
+    toggleTrackChangesForUser,
+    toggleTrackChangesForGuests,
+    trackChangesState,
+    trackChangesOnForEveryone,
+    trackChangesOnForGuests,
+    trackChangesForGuestsAvailable,
+    formattedProjectMembers,
+  } = useReviewPanelValueContext()
+
+  const containerRef = useRef<HTMLUListElement | null>(null)
+
+  useEffect(() => {
+    if (containerRef.current) {
+      const neededHeight = containerRef.current.scrollHeight
+
+      if (neededHeight > 0) {
+        const height = shouldCollapse ? 0 : neededHeight
+        containerRef.current.style.height = `${height}px`
+      } else {
+        if (shouldCollapse) {
+          containerRef.current.style.height = '0'
+        }
+      }
+    }
+  }, [shouldCollapse])
+
+  return (
+    <>
+      <span className="review-panel-toolbar-label">
+        {wantTrackChanges && (
+          <span className="review-panel-toolbar-icon-on">
+            <Icon type="circle" />
+          </span>
+        )}
+
+        <button
+          className="review-panel-toolbar-collapse-button"
+          onClick={() => setShouldCollapse(value => !value)}
+        >
+          {wantTrackChanges ? (
+            // eslint-disable-next-line react/jsx-key
+            <Trans i18nKey="track_changes_is_on" components={[<strong />]} />
+          ) : (
+            // eslint-disable-next-line react/jsx-key
+            <Trans i18nKey="track_changes_is_off" components={[<strong />]} />
+          )}
+          <span
+            className={classnames('rp-tc-state-collapse', {
+              'rp-tc-state-collapse-on': shouldCollapse,
+            })}
+          >
+            <Icon type="angle-down" />
+          </span>
+        </button>
+      </span>
+
+      <ul
+        className="rp-tc-state"
+        ref={containerRef}
+        data-testid="review-panel-track-changes-menu"
+      >
+        <li className="rp-tc-state-item rp-tc-state-item-everyone">
+          <Tooltip
+            description={t('tc_switch_everyone_tip')}
+            id="track-changes-switch-everyone"
+            overlayProps={{
+              container: document.body,
+              placement: 'left',
+              delay: 1000,
+            }}
+          >
+            <span className="rp-tc-state-item-name">{t('tc_everyone')}</span>
+          </Tooltip>
+
+          <TrackChangesToggle
+            id="track-changes-everyone"
+            description={t('track_changes_for_everyone')}
+            handleToggle={() =>
+              toggleTrackChangesForEveryone(!trackChangesOnForEveryone)
+            }
+            value={trackChangesOnForEveryone}
+            disabled={!project.features.trackChanges || !permissions.write}
+          />
+        </li>
+        {Object.values(formattedProjectMembers).map(member => (
+          <li className="rp-tc-state-item" key={member.id}>
+            <Tooltip
+              description={t('tc_switch_user_tip')}
+              id="track-changes-switch-user"
+              overlayProps={{
+                container: document.body,
+                placement: 'left',
+                delay: 1000,
+              }}
+            >
+              <span
+                className={classnames('rp-tc-state-item-name', {
+                  'rp-tc-state-item-name-disabled': trackChangesOnForEveryone,
+                })}
+              >
+                {member.name}
+              </span>
+            </Tooltip>
+
+            <TrackChangesToggle
+              id={`track-changes-user-toggle-${member.id}`}
+              description={t('track_changes_for_x', { name: member.name })}
+              handleToggle={() =>
+                toggleTrackChangesForUser(
+                  !trackChangesState[member.id].value,
+                  member.id
+                )
+              }
+              value={trackChangesState[member.id].value}
+              disabled={
+                trackChangesOnForEveryone ||
+                !project.features.trackChanges ||
+                !permissions.write
+              }
+            />
+          </li>
+        ))}
+        <li className="rp-tc-state-separator" />
+        <li className="rp-tc-state-item">
+          <Tooltip
+            description={t('tc_switch_guests_tip')}
+            id="track-changes-switch-guests"
+            overlayProps={{
+              container: document.body,
+              placement: 'left',
+              delay: 1000,
+            }}
+          >
+            <span
+              className={classnames('rp-tc-state-item-name', {
+                'rp-tc-state-item-name-disabled': trackChangesOnForEveryone,
+              })}
+            >
+              {t('tc_guests')}
+            </span>
+          </Tooltip>
+
+          <TrackChangesToggle
+            id="track-changes-guests-toggle"
+            description="Track changes for guests"
+            handleToggle={() =>
+              toggleTrackChangesForGuests(!trackChangesOnForGuests)
+            }
+            value={trackChangesOnForGuests}
+            disabled={
+              trackChangesOnForEveryone ||
+              !project.features.trackChanges ||
+              !permissions.write ||
+              !trackChangesForGuestsAvailable
+            }
+          />
+        </li>
+      </ul>
+    </>
+  )
+}
+
+export default ToggleMenu

+ 13 - 0
services/web/frontend/js/features/source-editor/components/review-panel/toolbar/toolbar.tsx

@@ -0,0 +1,13 @@
+import ResolvedCommentsDropdown from './resolved-comments-dropdown'
+import ToggleMenu from './toggle-menu'
+
+function Toolbar() {
+  return (
+    <div className="review-panel-toolbar">
+      <ResolvedCommentsDropdown />
+      <ToggleMenu />
+    </div>
+  )
+}
+
+export default Toolbar

+ 33 - 0
services/web/frontend/js/features/source-editor/components/review-panel/toolbar/track-changes-toggle.tsx

@@ -0,0 +1,33 @@
+type TrackChangesToggleProps = {
+  id: string
+  description: string
+  disabled: boolean
+  handleToggle: () => void
+  value: boolean
+}
+
+function TrackChangesToggle({
+  id,
+  description,
+  disabled,
+  handleToggle,
+  value,
+}: TrackChangesToggleProps) {
+  return (
+    <div className="input-switch">
+      <input
+        id={`input-switch-${id}`}
+        disabled={disabled}
+        type="checkbox"
+        className="input-switch-hidden-input"
+        onChange={handleToggle}
+        checked={value}
+      />
+      <label htmlFor={`input-switch-${id}`} className="input-switch-btn">
+        <span className="sr-only">{description}</span>
+      </label>
+    </div>
+  )
+}
+
+export default TrackChangesToggle

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

@@ -0,0 +1,95 @@
+import { useMemo } from 'react'
+import useScopeValue from '../../../../../shared/hooks/use-scope-value'
+import { ReviewPanelState } from '../types/review-panel-state'
+import * as ReviewPanel from '../types/review-panel-state'
+
+function useAngularReviewPanelState(): ReviewPanelState {
+  const [subView, setSubView] = useScopeValue<ReviewPanel.Value<'subView'>>(
+    'reviewPanel.subView'
+  )
+  const [collapsed, setCollapsed] = useScopeValue<
+    ReviewPanel.Value<'collapsed'>
+  >('reviewPanel.overview.docsCollapsedState')
+  const [permissions] =
+    useScopeValue<ReviewPanel.Value<'permissions'>>('permissions')
+
+  const [wantTrackChanges] = useScopeValue<
+    ReviewPanel.Value<'wantTrackChanges'>
+  >('editor.wantTrackChanges')
+  const [shouldCollapse, setShouldCollapse] = useScopeValue<
+    ReviewPanel.Value<'shouldCollapse'>
+  >('reviewPanel.fullTCStateCollapsed')
+
+  const [toggleTrackChangesForEveryone] = useScopeValue<
+    ReviewPanel.Value<'toggleTrackChangesForEveryone'>
+  >('toggleTrackChangesForEveryone')
+  const [toggleTrackChangesForUser] = useScopeValue<
+    ReviewPanel.Value<'toggleTrackChangesForUser'>
+  >('toggleTrackChangesForUser')
+  const [toggleTrackChangesForGuests] = useScopeValue<
+    ReviewPanel.Value<'toggleTrackChangesForGuests'>
+  >('toggleTrackChangesForGuests')
+
+  const [trackChangesState] = useScopeValue<
+    ReviewPanel.Value<'trackChangesState'>
+  >('reviewPanel.trackChangesState')
+  const [trackChangesOnForEveryone] = useScopeValue<
+    ReviewPanel.Value<'trackChangesOnForEveryone'>
+  >('reviewPanel.trackChangesOnForEveryone')
+  const [trackChangesOnForGuests] = useScopeValue<
+    ReviewPanel.Value<'trackChangesOnForGuests'>
+  >('reviewPanel.trackChangesOnForGuests')
+  const [trackChangesForGuestsAvailable] = useScopeValue<
+    ReviewPanel.Value<'trackChangesForGuestsAvailable'>
+  >('reviewPanel.trackChangesForGuestsAvailable')
+
+  const [formattedProjectMembers] = useScopeValue<
+    ReviewPanel.Value<'formattedProjectMembers'>
+  >('reviewPanel.formattedProjectMembers')
+
+  const values = useMemo<ReviewPanelState['values']>(
+    () => ({
+      collapsed,
+      permissions,
+      subView,
+      shouldCollapse,
+      wantTrackChanges,
+      toggleTrackChangesForEveryone,
+      toggleTrackChangesForUser,
+      toggleTrackChangesForGuests,
+      trackChangesState,
+      trackChangesOnForEveryone,
+      trackChangesOnForGuests,
+      trackChangesForGuestsAvailable,
+      formattedProjectMembers,
+    }),
+    [
+      collapsed,
+      permissions,
+      subView,
+      shouldCollapse,
+      wantTrackChanges,
+      toggleTrackChangesForEveryone,
+      toggleTrackChangesForUser,
+      toggleTrackChangesForGuests,
+      trackChangesState,
+      trackChangesOnForEveryone,
+      trackChangesOnForGuests,
+      trackChangesForGuestsAvailable,
+      formattedProjectMembers,
+    ]
+  )
+
+  const updaterFns = useMemo<ReviewPanelState['updaterFns']>(
+    () => ({
+      setSubView,
+      setCollapsed,
+      setShouldCollapse,
+    }),
+    [setSubView, setCollapsed, setShouldCollapse]
+  )
+
+  return { values, updaterFns }
+}
+
+export default useAngularReviewPanelState

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

@@ -1,33 +0,0 @@
-import { useMemo } from 'react'
-import useScopeValue from '../../../../../shared/hooks/use-scope-value'
-import { ReviewPanelState } from '../types/review-panel-state'
-import * as ReviewPanel from '../types/review-panel-state'
-
-function useAngularReviewPanelState(): ReviewPanelState {
-  const [subView, setSubView] = useScopeValue<ReviewPanel.Value<'subView'>>(
-    'reviewPanel.subView'
-  )
-  const [collapsed, setCollapsed] = useScopeValue<
-    ReviewPanel.Value<'collapsed'>
-  >('reviewPanel.overview.docsCollapsedState')
-
-  const values = useMemo<ReviewPanelState['values']>(
-    () => ({
-      subView,
-      collapsed,
-    }),
-    [subView, collapsed]
-  )
-
-  const updaterFns = useMemo<ReviewPanelState['updaterFns']>(
-    () => ({
-      setSubView,
-      setCollapsed,
-    }),
-    [setSubView, setCollapsed]
-  )
-
-  return { values, updaterFns }
-}
-
-export default useAngularReviewPanelState

+ 1 - 1
services/web/frontend/js/features/source-editor/context/review-panel/review-panel-context.tsx

@@ -1,5 +1,5 @@
 import { createContext, useContext } from 'react'
-import useAngularReviewPanelState from './hooks/use-angular-review-panel'
+import useAngularReviewPanelState from './hooks/use-angular-review-panel-state'
 import { ReviewPanelState } from './types/review-panel-state'
 
 const ReviewPanelValueContext = createContext<

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

@@ -1,9 +1,27 @@
 import { SubView } from '../../../components/review-panel/nav'
+import { ReviewPanelPermissions } from '../../../../../../../types/review-panel'
 
 export interface ReviewPanelState {
   values: {
     collapsed: Record<string, boolean>
     subView: SubView
+    permissions: ReviewPanelPermissions
+    shouldCollapse: boolean
+    wantTrackChanges: boolean
+    toggleTrackChangesForEveryone: (isOn: boolean) => unknown
+    toggleTrackChangesForUser: (isOn: boolean, memberId: string) => unknown
+    toggleTrackChangesForGuests: (isOn: boolean) => unknown
+    trackChangesState: Record<string, { value: boolean; syncState: string }>
+    trackChangesOnForEveryone: boolean
+    trackChangesOnForGuests: boolean
+    trackChangesForGuestsAvailable: boolean
+    formattedProjectMembers: Record<
+      string,
+      {
+        id: string
+        name: string
+      }
+    >
   }
   updaterFns: {
     setCollapsed: React.Dispatch<
@@ -12,6 +30,9 @@ export interface ReviewPanelState {
     setSubView: React.Dispatch<
       React.SetStateAction<ReviewPanelState['values']['subView']>
     >
+    setShouldCollapse: React.Dispatch<
+      React.SetStateAction<ReviewPanelState['values']['shouldCollapse']>
+    >
   }
 }
 

+ 82 - 4
services/web/frontend/stylesheets/app/editor/review-panel.less

@@ -66,6 +66,7 @@
 
 #review-panel {
   display: block;
+
   .rp-size-expanded & {
     display: flex;
     flex-direction: column;
@@ -73,6 +74,7 @@
     overflow: visible;
     border-left-width: 1px;
   }
+
   .rp-size-mini & {
     width: @review-off-width;
     z-index: 6;
@@ -98,6 +100,7 @@
 
 .review-panel-toolbar {
   display: none;
+
   .rp-size-expanded & {
     display: flex;
     align-items: center;
@@ -117,22 +120,27 @@
   flex-basis: 32px;
   flex-shrink: 0;
 }
+
 .review-panel-toolbar-label {
   cursor: pointer;
   text-align: right;
   flex-grow: 1;
 }
+
 .review-panel-toolbar-icon-on {
   margin-right: 5px;
   color: @ol-green;
 }
+
 .review-panel-toolbar-label-disabled {
   cursor: auto;
   margin-right: 5px;
 }
+
 .review-panel-toolbar-spinner {
   margin-left: 5px;
 }
+
 .rp-tc-state {
   position: absolute;
   top: 100%;
@@ -146,24 +154,30 @@
   background-color: @rp-bg-dim-blue;
   text-align: left;
 }
+
 .rp-tc-state-collapse {
   .rp-collapse-arrow;
   margin-left: 5px;
 }
+
 .rp-tc-state-item {
   display: flex;
   align-items: center;
   padding: 3px 0;
+
   &:last-of-type {
     padding-bottom: 5px;
   }
 }
+
 .rp-tc-state-separator {
   border-bottom: 1px solid @rp-border-grey;
 }
+
 .rp-tc-state-item-everyone {
   border-bottom: 1px solid @rp-border-grey;
 }
+
 .rp-tc-state-item-name {
   flex-grow: 1;
   overflow: hidden;
@@ -171,6 +185,7 @@
   white-space: nowrap;
   font-weight: @rp-semibold-weight;
 }
+
 .rp-tc-state-item-name-disabled {
   opacity: 0.35;
 }
@@ -216,9 +231,11 @@
   .rp-size-mini & {
     display: block;
   }
+
   .rp-size-mini &-add-comment {
     display: none;
   }
+
   position: absolute;
   left: 2px;
   right: 2px;
@@ -228,6 +245,7 @@
   color: #fff;
   cursor: pointer;
   transition: top @rp-entry-animation-speed, left 0.1s, right 0.1s;
+
   .no-animate & {
     transition: none;
   }
@@ -257,6 +275,7 @@
     position: absolute;
     width: @review-panel-width;
   }
+
   .rp-state-current-file-mini & {
     display: none;
     left: @review-off-width + @rp-entry-arrow-width;
@@ -272,11 +291,12 @@
       left: -(2 * @rp-entry-arrow-width + 2);
       z-index: -1;
     }
+
     &::after {
       .triangle(
         left,
         @rp-entry-arrow-width,
-        @rp-entry-arrow-width * 1.5,
+          @rp-entry-arrow-width * 1.5,
         inherit
       );
       top: (@review-off-width / 2) - @rp-entry-arrow-width;
@@ -284,6 +304,7 @@
       content: '';
     }
   }
+
   .rp-state-current-file-mini.rp-layout-left & {
     left: auto;
     right: @review-off-width + @rp-entry-arrow-width;
@@ -295,17 +316,19 @@
       left: -(@review-off-width + @rp-entry-arrow-width);
       right: -(2 * @rp-entry-arrow-width + 2);
     }
+
     &::after {
       .triangle(
         right,
         @rp-entry-arrow-width,
-        @rp-entry-arrow-width * 1.5,
+          @rp-entry-arrow-width * 1.5,
         inherit
       );
       right: -(@rp-entry-ribbon-width + @rp-entry-arrow-width);
       left: auto;
     }
   }
+
   .rp-state-current-file-expanded & {
     visibility: hidden;
     left: 5px;
@@ -325,15 +348,18 @@
         right: 5px;
       }
     }
+
     &-bulk-actions {
       right: auto;
     }
   }
+
   .rp-state-overview & {
     border-radius: 0;
     border-bottom: solid 1px @rp-border-grey;
     cursor: pointer;
   }
+
   .resolved-comments-dropdown & {
     position: static;
     margin-bottom: 5px;
@@ -343,6 +369,7 @@
   border-radius: 3px;
   background-color: #fff;
   transition: top @rp-entry-animation-speed, left 0.1s, right 0.1s;
+
   .no-animate & {
     transition: none;
   }
@@ -395,11 +422,13 @@
     border-left-width: 0;
   }
 }
+
 .rp-entry-body {
   display: flex;
   align-items: center;
   padding: 4px 5px;
 }
+
 .rp-entry-action-icon {
   font-size: @rp-icon-large-size;
   padding: 0 3px;
@@ -426,10 +455,12 @@
 .rp-entry-metadata {
   font-size: @rp-small-font-size;
 }
+
 .rp-entry-user {
   font-weight: @rp-semibold-weight;
   font-style: normal;
 }
+
 .rp-comment-actions {
   a {
     color: @rp-type-blue;
@@ -453,6 +484,7 @@
     display: none;
   }
 }
+
 .rp-entry-button {
   .rp-button();
   flex: 1 1 50%;
@@ -468,6 +500,7 @@
     &:first-child {
       border-bottom-left-radius: 3px;
     }
+
     &:last-child {
       border-bottom-right-radius: 0;
     }
@@ -494,6 +527,7 @@
     }
   }
 }
+
 .rp-comment-content {
   margin: 0;
   color: @rp-type-darkgrey;
@@ -504,6 +538,7 @@
 .rp-comment-resolver {
   color: @rp-type-blue;
 }
+
 .rp-comment-resolver-content {
   font-style: italic;
   margin: 0;
@@ -533,10 +568,12 @@
 
 .rp-bulk-actions-btn {
   border-radius: 0;
+
   &:first-child {
     border-top-left-radius: 3px;
     border-bottom-left-radius: 3px;
   }
+
   &:last-child {
     border-top-right-radius: 3px;
     border-bottom-right-radius: 3px;
@@ -570,6 +607,7 @@
   font-size: 0.8em;
   text-decoration: line-through;
   font-weight: @rp-semibold-weight;
+
   &::before {
     content: 'Ab';
   }
@@ -581,10 +619,12 @@
   background-color: #fff;
   margin-bottom: 5px;
 }
+
 .rp-resolved-comment-context {
   background-color: lighten(@rp-yellow, 35%);
   padding: 4px 5px;
 }
+
 .rp-resolved-comment-context-file {
   font-weight: @rp-semibold-weight;
 }
@@ -597,10 +637,12 @@
 
 .rp-entry-callout {
   transition: top @rp-entry-animation-speed, height @rp-entry-animation-speed;
+
   .rp-state-current-file & {
     position: absolute;
     border-top: 1px solid grey;
     border-right: 1px dashed grey;
+
     &::after {
       content: '';
       position: absolute;
@@ -610,14 +652,18 @@
       border-bottom: 1px solid grey;
     }
   }
+
   .rp-state-current-file-expanded & {
     width: 3px;
+
     &::after {
       width: 3px;
     }
   }
+
   .rp-state-current-file-mini & {
     width: 1px;
+
     &::after {
       width: 1px;
     }
@@ -630,6 +676,7 @@
   .rp-state-current-file &-inverted {
     border-top: none;
     border-bottom: 1px solid grey;
+
     &::after {
       top: 0px;
       bottom: -1px;
@@ -640,6 +687,7 @@
 
   .rp-state-current-file &-insert {
     border-color: @rp-green;
+
     &::after {
       border-color: @rp-green;
     }
@@ -647,6 +695,7 @@
 
   .rp-state-current-file &-delete {
     border-color: @rp-red;
+
     &::after {
       border-color: @rp-red;
     }
@@ -654,6 +703,7 @@
 
   .rp-state-current-file &-comment {
     border-color: @rp-yellow;
+
     &::after {
       border-color: @rp-yellow;
     }
@@ -679,10 +729,12 @@
   font-weight: normal;
   font-size: 0.9em;
 }
+
 .rp-overview-file-header-collapse {
   .rp-collapse-arrow;
   float: left;
 }
+
 .rp-overview-file-entries {
   overflow: hidden;
 }
@@ -704,6 +756,7 @@
 .rp-nav {
   display: none;
   flex-shrink: 0;
+
   .rp-size-expanded & {
     display: flex;
   }
@@ -716,6 +769,7 @@
     position: absolute;
     bottom: 0;
   }
+
   width: 100%;
   font-size: @rp-icon-large-size;
   text-align: center;
@@ -723,6 +777,7 @@
   border-top: solid 1px @rp-border-grey;
   z-index: 2;
 }
+
 .rp-nav-item {
   display: block;
   color: lighten(@rp-type-blue, 25%);
@@ -741,6 +796,7 @@
     border-top: solid 3px @rp-highlight-blue;
   }
 }
+
 .rp-nav-label {
   display: block;
   font-size: @rp-base-font-size;
@@ -749,8 +805,10 @@
 #editor {
   .rp-size-mini & {
     right: @review-off-width;
+
     .ace-editor-body {
       overflow: visible;
+
       .ace_scrollbar-v {
         right: -@review-off-width;
       }
@@ -766,6 +824,7 @@
   .rp-state-current-file-expanded & {
     .ace-editor-body {
       overflow: visible;
+
       .ace_scrollbar-v {
         right: -@review-panel-width;
       }
@@ -785,6 +844,7 @@
 
 .rp-unsupported-msg-wrapper {
   display: none;
+
   .rp-size-expanded.rp-unsupported & {
     display: block;
   }
@@ -812,18 +872,22 @@
   .track-changes-marker-callout {
     border-radius: 0;
     position: absolute;
+
     .rp-state-overview &,
     .rp-loading-threads & {
       display: none;
     }
   }
+
   .track-changes-added-marker-callout {
     border-bottom: 1px dashed @rp-green;
     z-index: 1;
   }
+
   .track-changes-comment-marker-callout {
     border-bottom: 1px dashed @rp-yellow;
   }
+
   .track-changes-deleted-marker-callout {
     border-bottom: 1px dashed @rp-red;
   }
@@ -831,9 +895,11 @@
   .track-changes-marker {
     border-radius: 0;
     position: absolute;
+
     .rp-loading-threads & {
       display: none;
     }
+
     z-index: 6; // Appear above text selection
   }
 
@@ -841,10 +907,12 @@
     background-color: @rp-yellow;
     opacity: 0.3;
   }
+
   .track-changes-added-marker {
     background-color: @rp-green;
     opacity: 0.3;
   }
+
   .track-changes-deleted-marker {
     border-left: 2px dotted @rp-red;
     margin-left: -1px;
@@ -854,6 +922,7 @@
     .track-changes-comment-marker {
       background-color: @rp-yellow-on-dark;
     }
+
     .track-changes-added-marker {
       background-color: @rp-green-on-dark;
     }
@@ -975,8 +1044,8 @@ button when (@is-overleaf-light = true) {
     content: '';
     .triangle(
       top,
-      @rp-entry-arrow-width * 3,
-      @rp-entry-arrow-width * 1.5,
+        @rp-entry-arrow-width * 3,
+        @rp-entry-arrow-width * 1.5,
       @rp-bg-blue
     );
     top: -@rp-entry-ribbon-width * 2;
@@ -988,6 +1057,7 @@ button when (@is-overleaf-light = true) {
     display: flex;
   }
 }
+
 .resolved-comments-scroller {
   flex: 0 0 auto; // Can't use 100% in the flex-basis key here, IE won't account for padding.
   width: 100%; // We need to set the width explicitly, as flex-basis won't work.
@@ -1021,11 +1091,13 @@ button when (@is-overleaf-light = true) {
   .rp-size-mini & {
     right: @review-off-width;
   }
+
   .rp-size-expanded &,
   .rp-unsupported & {
     display: none;
   }
 }
+
 .rp-track-changes-indicator {
   display: block;
   padding: 5px 10px;
@@ -1103,6 +1175,7 @@ button when (@is-overleaf-light = true) {
     margin-top: -0.5em;
   }
 }
+
 // Helper class for elements which aren't treated as flex-items by IE10, e.g:
 // * inline items;
 // * unknown elements (elements which aren't standard DOM elements, such as custom element directives)
@@ -1220,4 +1293,9 @@ button when (@is-overleaf-light = true) {
       }
     }
   }
+
+  .rp-tc-state {
+    height: 0;
+    transition: height 150ms;
+  }
 }

+ 2 - 0
services/web/locales/en.json

@@ -1638,6 +1638,8 @@
   "tr": "Turkish",
   "track_any_change_in_real_time": "Track any change, in real-time",
   "track_changes": "Track changes",
+  "track_changes_for_everyone": "Track changes for everyone",
+  "track_changes_for_x": "Track changes for __name__",
   "track_changes_is_off": "Track changes is <strong>off</strong>",
   "track_changes_is_on": "Track changes is <strong>on</strong>",
   "tracked_change_added": "Added",

+ 1 - 0
services/web/package.json

@@ -272,6 +272,7 @@
     "@types/react-bootstrap": "^0.32.29",
     "@types/react-color": "^3.0.6",
     "@types/react-dom": "^17.0.13",
+    "@types/react-linkify": "^1.0.0",
     "@types/recurly__recurly-js": "^4.22.0",
     "@types/sinon-chai": "^3.2.8",
     "@types/uuid": "^8.3.4",

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

@@ -0,0 +1,121 @@
+import CodeMirrorEditor from '../../../../frontend/js/features/source-editor/components/codemirror-editor'
+import { EditorProviders } from '../../helpers/editor-providers'
+import { mockScope } from '../source-editor/helpers/mock-scope'
+
+type ContainerProps = {
+  children: React.ReactNode
+  className?: string
+}
+
+function Container(props: ContainerProps) {
+  return <div style={{ width: 785, height: 785 }} {...props} />
+}
+
+describe('<ReviewPanel />', function () {
+  beforeEach(function () {
+    window.metaAttributesCache.set('ol-isReviewPanelReact', true)
+    window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
+
+    cy.interceptEvents()
+    cy.interceptSpelling()
+
+    const scope = mockScope('')
+    scope.editor.showVisual = true
+
+    // Making a shallow copy, otherwise cannot spy on an object declared outside the test file
+    const newScope = { ...scope }
+    cy.spy(newScope, 'toggleTrackChangesForEveryone').as(
+      'toggleTrackChangesForEveryone'
+    )
+
+    cy.mount(
+      <Container className="rp-size-expanded">
+        <EditorProviders scope={newScope}>
+          <CodeMirrorEditor />
+        </EditorProviders>
+      </Container>
+    )
+
+    cy.findByTestId('review-panel').as('review-panel')
+  })
+
+  describe('toolbar', function () {
+    describe('resolved comments dropdown', function () {
+      it('renders dropdown button', function () {
+        cy.findByRole('button', { name: /resolved comments/i })
+      })
+
+      // eslint-disable-next-line mocha/no-skipped-tests
+      it.skip('opens dropdown', function () {})
+
+      // eslint-disable-next-line mocha/no-skipped-tests
+      it.skip('renders list of resolved comments', function () {})
+
+      // eslint-disable-next-line mocha/no-skipped-tests
+      it.skip('reopens resolved comment', function () {})
+
+      // eslint-disable-next-line mocha/no-skipped-tests
+      it.skip('deletes resolved comment', function () {})
+    })
+
+    describe('track changes toggle menu', function () {
+      it('renders track changes toolbar', function () {
+        cy.get('@review-panel').within(() => {
+          cy.findByRole('button', { name: /track changes is (on|off)$/i })
+        })
+      })
+
+      it('opens/closes toggle menu', function () {
+        cy.get('@review-panel').within(() => {
+          cy.findByTestId('review-panel-track-changes-menu').as('menu')
+          cy.get('@menu').should('have.css', 'height', '1px')
+          cy.findByRole('button', { name: /track changes is/i }).click()
+          // verify the menu is expanded
+          cy.get('@menu')
+            .then($el => {
+              const height = window
+                .getComputedStyle($el[0])
+                .getPropertyValue('height')
+              return parseFloat(height)
+            })
+            .should('be.gt', 1)
+          cy.findByRole('button', { name: /track changes is/i }).click()
+          cy.get('@menu').should('have.css', 'height', '1px')
+        })
+      })
+
+      it('toggles the "everyone" track changes switch', function () {
+        cy.get('@review-panel').within(() => {
+          cy.findByRole('button', { name: /track changes is off/i }).click()
+          cy.findByLabelText(/track changes for everyone/i).click({
+            force: true,
+          })
+          cy.get('@toggleTrackChangesForEveryone').should('be.calledOnce')
+        })
+      })
+
+      it('renders track changes with "on" state', function () {
+        const scope = mockScope('')
+        scope.editor.showVisual = true
+        scope.editor.wantTrackChanges = true
+
+        cy.mount(
+          <Container className="rp-size-expanded">
+            <EditorProviders scope={scope}>
+              <CodeMirrorEditor />
+            </EditorProviders>
+          </Container>
+        )
+
+        cy.findByTestId('review-panel').within(() => {
+          cy.findByRole('button', { name: /track changes is on/i }).click()
+        })
+      })
+
+      it('renders a disabled guests switch', function () {
+        cy.findByRole('button', { name: /track changes is off/i }).click()
+        cy.findByLabelText(/track changes for guests/i).should('be.disabled')
+      })
+    })
+  })
+})

+ 18 - 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'
+
 export const rootFolderId = '012345678901234567890123'
 export const figuresFolderId = '123456789012345678901234'
 export const figureId = '234567890123456789012345'
@@ -55,6 +56,7 @@ export const mockScope = (content?: string) => {
       open_doc_name: 'test.tex',
       open_doc_id: docId,
       showVisual: false,
+      wantTrackChanges: false,
     },
     pdf: {
       logEntryAnnotations: {},
@@ -64,7 +66,23 @@ export const mockScope = (content?: string) => {
       name: 'Test Project',
       spellCheckLanguage: 'en',
       rootFolder: [] as Folder[],
+      features: {
+        trackChanges: true,
+      },
+    },
+    permissions: {
+      comment: true,
+      write: true,
+    },
+    reviewPanel: {
+      subView: 'cur_file',
+      formattedProjectMembers: {},
+      fullTCStateCollapsed: true,
+    },
+    ui: {
+      reviewPanelOpen: true,
     },
+    toggleTrackChangesForEveryone() {},
     onlineUserCursorHighlights: {},
     permissionsLevel: 'owner',
     $on: cy.stub(),

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

@@ -0,0 +1,6 @@
+export interface ReviewPanelPermissions {
+  read: boolean
+  write: boolean
+  admin: boolean
+  comment: boolean
+}