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

Merge pull request #12773 from overleaf/ii-history-react-changes-list-dropdown

[web] Actions dropdown history migration

GitOrigin-RevId: 6b7055501c5eb1529b1794db92bb9f5f3faa6648
ilkin-overleaf 3 лет назад
Родитель
Сommit
0895e33235
27 измененных файлов с 787 добавлено и 185 удалено
  1. 7 0
      services/web/frontend/extracted-translations.json
  2. 108 0
      services/web/frontend/js/features/history/components/change-list/add-label-modal.tsx
  3. 43 0
      services/web/frontend/js/features/history/components/change-list/dropdown/actions-dropdown.tsx
  4. 40 0
      services/web/frontend/js/features/history/components/change-list/dropdown/history-version-dropdown.tsx
  5. 37 0
      services/web/frontend/js/features/history/components/change-list/dropdown/label-dropdown.tsx
  6. 30 0
      services/web/frontend/js/features/history/components/change-list/dropdown/menu-item/add-label.tsx
  7. 63 0
      services/web/frontend/js/features/history/components/change-list/dropdown/menu-item/compare.tsx
  8. 33 0
      services/web/frontend/js/features/history/components/change-list/dropdown/menu-item/download.tsx
  9. 47 0
      services/web/frontend/js/features/history/components/change-list/history-version-details.tsx
  10. 44 75
      services/web/frontend/js/features/history/components/change-list/history-version.tsx
  11. 62 33
      services/web/frontend/js/features/history/components/change-list/labels-list.tsx
  12. 24 0
      services/web/frontend/js/features/history/components/change-list/modal-error.tsx
  13. 17 39
      services/web/frontend/js/features/history/components/change-list/tag-tooltip.tsx
  14. 12 2
      services/web/frontend/js/features/history/components/change-list/toggle-switch.tsx
  15. 14 6
      services/web/frontend/js/features/history/context/history-context.tsx
  16. 4 1
      services/web/frontend/js/features/history/context/types/history-context-value.ts
  17. 46 0
      services/web/frontend/js/features/history/hooks/use-add-or-remove-labels.ts
  18. 21 1
      services/web/frontend/js/features/history/services/api.ts
  19. 6 4
      services/web/frontend/js/features/history/services/types/label.ts
  20. 7 6
      services/web/frontend/js/features/history/utils/history-details.ts
  21. 2 0
      services/web/frontend/js/features/history/utils/label.ts
  22. 45 0
      services/web/frontend/js/shared/components/dropdown/dropdown-toggle-with-tooltip.tsx
  23. 9 4
      services/web/frontend/stories/history/toggle-switch.stories.tsx
  24. 51 5
      services/web/frontend/stylesheets/app/editor/history-react.less
  25. 1 1
      services/web/frontend/stylesheets/components/badge.less
  26. 3 0
      services/web/locales/en.json
  27. 11 8
      services/web/test/frontend/features/history/components/change-list.spec.tsx

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

@@ -354,15 +354,21 @@
   "hide_document_preamble": "",
   "hide_outline": "",
   "history": "",
+  "history_add_label": "",
+  "history_adding_label": "",
   "history_are_you_sure_delete_label": "",
+  "history_compare_with_this_version": "",
   "history_delete_label": "",
   "history_deleting_label": "",
+  "history_download_this_version": "",
   "history_entry_origin_dropbox": "",
   "history_entry_origin_git": "",
   "history_entry_origin_github": "",
   "history_entry_origin_upload": "",
   "history_label_created_by": "",
   "history_label_project_current_state": "",
+  "history_label_this_version": "",
+  "history_new_label_name": "",
   "history_view_a11y_description": "",
   "history_view_all": "",
   "history_view_labels": "",
@@ -512,6 +518,7 @@
   "menu": "",
   "month": "",
   "more": "",
+  "more_actions": "",
   "n_items": "",
   "n_items_plural": "",
   "n_more_updates_above": "",

+ 108 - 0
services/web/frontend/js/features/history/components/change-list/add-label-modal.tsx

@@ -0,0 +1,108 @@
+import { useTranslation } from 'react-i18next'
+import { useState } from 'react'
+import { Modal, FormGroup, FormControl } from 'react-bootstrap'
+import ModalError from './modal-error'
+import AccessibleModal from '../../../../shared/components/accessible-modal'
+import useAsync from '../../../../shared/hooks/use-async'
+import useAbortController from '../../../../shared/hooks/use-abort-controller'
+import useAddOrRemoveLabels from '../../hooks/use-add-or-remove-labels'
+import { useHistoryContext } from '../../context/history-context'
+import { addLabel } from '../../services/api'
+import { Label } from '../../services/types/label'
+
+type AddLabelModalProps = {
+  show: boolean
+  setShow: React.Dispatch<React.SetStateAction<boolean>>
+  version: number
+}
+
+function AddLabelModal({ show, setShow, version }: AddLabelModalProps) {
+  const { t } = useTranslation()
+  const [comment, setComment] = useState('')
+  const {
+    isLoading,
+    isSuccess,
+    isError,
+    error,
+    data: label,
+    reset,
+    runAsync,
+  } = useAsync<Label>()
+  const { projectId } = useHistoryContext()
+  const { signal } = useAbortController()
+  const { addUpdateLabel } = useAddOrRemoveLabels()
+
+  const handleModalExited = () => {
+    setComment('')
+
+    if (!isSuccess || !label) return
+
+    addUpdateLabel(label)
+
+    reset()
+
+    // TODO
+    // _handleHistoryUIStateChange()
+  }
+
+  const handleAddLabel = () => {
+    runAsync(addLabel(projectId, { comment, version }, signal))
+      .then(() => setShow(false))
+      .catch(console.error)
+  }
+
+  const responseError = error as unknown as {
+    response: Response
+    data?: {
+      message?: string
+    }
+  }
+
+  return (
+    <AccessibleModal
+      show={show}
+      onExited={handleModalExited}
+      onHide={() => setShow(false)}
+      id="add-history-label"
+    >
+      <Modal.Header>
+        <Modal.Title>{t('history_add_label')}</Modal.Title>
+      </Modal.Header>
+      <Modal.Body>
+        {isError && <ModalError error={responseError} />}
+        <FormGroup>
+          <FormControl
+            type="text"
+            placeholder={t('history_new_label_name')}
+            required
+            value={comment}
+            onChange={(e: React.ChangeEvent<HTMLInputElement & FormControl>) =>
+              setComment(e.target.value)
+            }
+            autoFocus // eslint-disable-line jsx-a11y/no-autofocus
+          />
+        </FormGroup>
+      </Modal.Body>
+      <Modal.Footer>
+        <button
+          type="button"
+          className="btn btn-secondary"
+          disabled={isLoading}
+          onClick={() => setShow(false)}
+        >
+          {t('cancel')}
+        </button>
+        <button
+          type="button"
+          className="btn btn-primary"
+          disabled={isLoading || !comment.length}
+          onClick={handleAddLabel}
+        >
+          {isLoading ? t('history_adding_label') : t('history_add_label')}
+        </button>
+      </Modal.Footer>
+    </AccessibleModal>
+  )
+}
+
+export default AddLabelModal

+ 43 - 0
services/web/frontend/js/features/history/components/change-list/dropdown/actions-dropdown.tsx

@@ -0,0 +1,43 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Dropdown } from 'react-bootstrap'
+import DropdownToggleWithTooltip from '../../../../../shared/components/dropdown/dropdown-toggle-with-tooltip'
+import Icon from '../../../../../shared/components/icon'
+
+type DropdownMenuProps = {
+  id: string
+  children: React.ReactNode
+}
+
+function ActionsDropdown({ id, children }: DropdownMenuProps) {
+  const { t } = useTranslation()
+  const [isOpened, setIsOpened] = useState(false)
+
+  return (
+    <>
+      <Dropdown
+        id={`history-version-dropdown-${id}`}
+        pullRight
+        open={isOpened}
+        onToggle={open => setIsOpened(open)}
+      >
+        <DropdownToggleWithTooltip
+          bsRole="toggle"
+          className="history-version-dropdown-menu-btn"
+          tooltipProps={{
+            id,
+            description: t('more_actions'),
+            overlayProps: { placement: 'bottom', trigger: ['hover'] },
+          }}
+        >
+          <Icon type="ellipsis-v" accessibilityLabel={t('more_actions')} />
+        </DropdownToggleWithTooltip>
+        <Dropdown.Menu className="history-version-dropdown-menu">
+          {children}
+        </Dropdown.Menu>
+      </Dropdown>
+    </>
+  )
+}
+
+export default ActionsDropdown

+ 40 - 0
services/web/frontend/js/features/history/components/change-list/dropdown/history-version-dropdown.tsx

@@ -0,0 +1,40 @@
+import ActionsDropdown from './actions-dropdown'
+import AddLabel from './menu-item/add-label'
+import Download from './menu-item/download'
+import Compare from './menu-item/compare'
+import { UpdateRange } from '../../../services/types/update'
+
+type HistoryVersionDropdownProps = {
+  id: string
+  projectId: string
+  isComparing: boolean
+  isSelected: boolean
+  updateMetaEndTimestamp: number
+} & Pick<UpdateRange, 'fromV' | 'toV'>
+
+function HistoryVersionDropdown({
+  id,
+  projectId,
+  isComparing,
+  isSelected,
+  fromV,
+  toV,
+  updateMetaEndTimestamp,
+}: HistoryVersionDropdownProps) {
+  return (
+    <ActionsDropdown id={id}>
+      <AddLabel projectId={projectId} version={toV} />
+      <Download projectId={projectId} version={toV} />
+      {!isComparing && !isSelected && (
+        <Compare
+          projectId={projectId}
+          fromV={fromV}
+          toV={toV}
+          updateMetaEndTimestamp={updateMetaEndTimestamp}
+        />
+      )}
+    </ActionsDropdown>
+  )
+}
+
+export default HistoryVersionDropdown

+ 37 - 0
services/web/frontend/js/features/history/components/change-list/dropdown/label-dropdown.tsx

@@ -0,0 +1,37 @@
+import ActionsDropdown from './actions-dropdown'
+import Download from './menu-item/download'
+import Compare from './menu-item/compare'
+
+type LabelDropdownProps = {
+  id: string
+  projectId: string
+  isComparing: boolean
+  isSelected: boolean
+  version: number
+  updateMetaEndTimestamp: number
+}
+
+function LabelDropdown({
+  id,
+  projectId,
+  isComparing,
+  isSelected,
+  version,
+  updateMetaEndTimestamp,
+}: LabelDropdownProps) {
+  return (
+    <ActionsDropdown id={id}>
+      <Download projectId={projectId} version={version} />
+      {!isComparing && !isSelected && (
+        <Compare
+          projectId={projectId}
+          fromV={version}
+          toV={version}
+          updateMetaEndTimestamp={updateMetaEndTimestamp}
+        />
+      )}
+    </ActionsDropdown>
+  )
+}
+
+export default LabelDropdown

+ 30 - 0
services/web/frontend/js/features/history/components/change-list/dropdown/menu-item/add-label.tsx

@@ -0,0 +1,30 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { MenuItem } from 'react-bootstrap'
+import Icon from '../../../../../../shared/components/icon'
+import AddLabelModal from '../../add-label-modal'
+
+type DownloadProps = {
+  projectId: string
+  version: number
+}
+
+function AddLabel({ version, projectId, ...props }: DownloadProps) {
+  const { t } = useTranslation()
+  const [showModal, setShowModal] = useState(false)
+
+  return (
+    <>
+      <MenuItem onClick={() => setShowModal(true)} {...props}>
+        <Icon type="tag" fw /> {t('history_label_this_version')}
+      </MenuItem>
+      <AddLabelModal
+        show={showModal}
+        setShow={setShowModal}
+        version={version}
+      />
+    </>
+  )
+}
+
+export default AddLabel

+ 63 - 0
services/web/frontend/js/features/history/components/change-list/dropdown/menu-item/compare.tsx

@@ -0,0 +1,63 @@
+import { useTranslation } from 'react-i18next'
+import { MenuItem, MenuItemProps } from 'react-bootstrap'
+import Icon from '../../../../../../shared/components/icon'
+import { useHistoryContext } from '../../../../context/history-context'
+import { UpdateRange } from '../../../../services/types/update'
+
+type CompareProps = {
+  projectId: string
+  updateMetaEndTimestamp: number
+} & Pick<UpdateRange, 'fromV' | 'toV'>
+
+function Compare({
+  projectId,
+  fromV,
+  toV,
+  updateMetaEndTimestamp,
+  ...props
+}: CompareProps) {
+  const { t } = useTranslation()
+  const { selection, setSelection } = useHistoryContext()
+
+  function compare() {
+    const { updateRange } = selection
+    if (!updateRange) {
+      return
+    }
+    const fromVersion = Math.min(fromV, updateRange.fromV)
+    const toVersion = Math.max(toV, updateRange.toV)
+    const fromVTimestamp = Math.min(
+      updateMetaEndTimestamp,
+      updateRange.fromVTimestamp
+    )
+    const toVTimestamp = Math.max(
+      updateMetaEndTimestamp,
+      updateRange.toVTimestamp
+    )
+
+    setSelection({
+      updateRange: {
+        fromV: fromVersion,
+        toV: toVersion,
+        fromVTimestamp,
+        toVTimestamp,
+      },
+      comparing: true,
+      files: [],
+      pathname: null,
+    })
+  }
+
+  const handleCompareVersion = (e: React.MouseEvent<MenuItemProps>) => {
+    e.stopPropagation()
+    compare()
+  }
+
+  return (
+    <MenuItem onClick={handleCompareVersion} {...props}>
+      <Icon type="exchange" fw /> {t('history_compare_with_this_version')}
+    </MenuItem>
+  )
+}
+
+export default Compare

+ 33 - 0
services/web/frontend/js/features/history/components/change-list/dropdown/menu-item/download.tsx

@@ -0,0 +1,33 @@
+import { useTranslation } from 'react-i18next'
+import { MenuItem, MenuItemProps } from 'react-bootstrap'
+import Icon from '../../../../../../shared/components/icon'
+import * as location from '../../../../../../shared/components/location'
+
+type DownloadProps = {
+  projectId: string
+  version: number
+}
+
+function Download({ version, projectId, ...props }: DownloadProps) {
+  const { t } = useTranslation()
+
+  const handleDownloadVersion = (e: React.MouseEvent<MenuItemProps>) => {
+    e.preventDefault()
+    const event = e as typeof e & { target: HTMLAnchorElement }
+    location.assign(event.target.href)
+  }
+
+  return (
+    <MenuItem
+      href={`/project/${projectId}/version/${version}/zip`}
+      target="_blank"
+      rel="noreferrer"
+      onClick={handleDownloadVersion}
+      {...props}
+    >
+      <Icon type="cloud-download" fw /> {t('history_download_this_version')}
+    </MenuItem>
+  )
+}
+
+export default Download

+ 47 - 0
services/web/frontend/js/features/history/components/change-list/history-version-details.tsx

@@ -0,0 +1,47 @@
+import { useHistoryContext } from '../../context/history-context'
+import classnames from 'classnames'
+import { UpdateRange } from '../../services/types/update'
+
+type HistoryVersionDetailsProps = {
+  children: React.ReactNode
+  selected: boolean
+} & UpdateRange
+
+function HistoryVersionDetails({
+  children,
+  selected,
+  fromV,
+  toV,
+  fromVTimestamp,
+  toVTimestamp,
+}: HistoryVersionDetailsProps) {
+  const { setSelection } = useHistoryContext()
+
+  const handleSelect = (e: React.MouseEvent<HTMLDivElement>) => {
+    const target = e.target as HTMLElement
+    if (!target.closest('.dropdown') && e.currentTarget.contains(target)) {
+      setSelection({
+        updateRange: { fromV, toV, fromVTimestamp, toVTimestamp },
+        comparing: false,
+        files: [],
+        pathname: null,
+      })
+    }
+  }
+
+  return (
+    // TODO: Sort out accessibility for this
+    // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
+    <div
+      className={classnames('history-version-details', {
+        'history-version-selected': selected,
+      })}
+      data-testid="history-version-details"
+      onClick={handleSelect}
+    >
+      {children}
+    </div>
+  )
+}
+
+export default HistoryVersionDetails

+ 44 - 75
services/web/frontend/js/features/history/components/change-list/history-version.tsx

@@ -1,14 +1,15 @@
+import HistoryVersionDetails from './history-version-details'
 import TagTooltip from './tag-tooltip'
 import Changes from './changes'
 import MetadataUsersList from './metadata-users-list'
 import Origin from './origin'
+import HistoryVersionDropdown from './dropdown/history-version-dropdown'
 import { useUserContext } from '../../../../shared/context/user-context'
+import { useHistoryContext } from '../../context/history-context'
+import { isUpdateSelected } from '../../utils/history-details'
 import { relativeDate, formatTime } from '../../../utils/format-date'
 import { orderBy } from 'lodash'
 import { LoadedUpdate } from '../../services/types/update'
-import { useHistoryContext } from '../../context/history-context'
-import classNames from 'classnames'
-import { updateIsSelected } from '../../utils/history-details'
 
 type HistoryEntryProps = {
   update: LoadedUpdate
@@ -16,94 +17,62 @@ type HistoryEntryProps = {
 
 function HistoryVersion({ update }: HistoryEntryProps) {
   const { id: currentUserId } = useUserContext()
-  const orderedLabels = orderBy(update.labels, ['created_at'], ['desc'])
+  const { projectId, selection } = useHistoryContext()
 
-  const { selection, setSelection } = useHistoryContext()
-
-  const selected = updateIsSelected({
+  const orderedLabels = orderBy(update.labels, ['created_at'], ['desc'])
+  const selected = isUpdateSelected({
     fromV: update.fromV,
     toV: update.toV,
     selection,
   })
 
-  function compare() {
-    const { updateRange } = selection
-    if (!updateRange) {
-      return
-    }
-    const fromV = Math.min(update.fromV, updateRange.fromV)
-    const toV = Math.max(update.toV, updateRange.toV)
-    const fromVTimestamp = Math.min(
-      update.meta.end_ts,
-      updateRange.fromVTimestamp
-    )
-    const toVTimestamp = Math.max(update.meta.end_ts, updateRange.toVTimestamp)
-
-    setSelection({
-      updateRange: { fromV, toV, fromVTimestamp, toVTimestamp },
-      comparing: true,
-      files: [],
-      pathname: null,
-    })
-  }
-
   return (
-    <div className={classNames({ 'history-version-selected': selected })}>
+    <div>
       {update.meta.first_in_day && (
         <time className="history-version-day">
           {relativeDate(update.meta.end_ts)}
         </time>
       )}
-      {/* TODO: Sort out accessibility for this */}
-      {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
-      <div
-        className="history-version-details"
-        data-testid="history-version-details"
-        onClick={() =>
-          setSelection({
-            updateRange: {
-              fromV: update.fromV,
-              toV: update.toV,
-              fromVTimestamp: update.meta.end_ts,
-              toVTimestamp: update.meta.end_ts,
-            },
-            comparing: false,
-            files: [],
-            pathname: null,
-          })
-        }
+      <HistoryVersionDetails
+        fromV={update.fromV}
+        toV={update.toV}
+        fromVTimestamp={update.meta.end_ts}
+        toVTimestamp={update.meta.end_ts}
+        selected={selected}
       >
-        <time className="history-version-metadata-time">
-          <b>{formatTime(update.meta.end_ts, 'Do MMMM, h:mm a')}</b>
-        </time>
-        {orderedLabels.map(label => (
-          <TagTooltip
-            key={label.id}
-            showTooltip
+        <div className="history-version-main-details">
+          <time className="history-version-metadata-time">
+            <b>{formatTime(update.meta.end_ts, 'Do MMMM, h:mm a')}</b>
+          </time>
+          {orderedLabels.map(label => (
+            <TagTooltip
+              key={label.id}
+              showTooltip
+              currentUserId={currentUserId}
+              label={label}
+            />
+          ))}
+          <Changes
+            pathnames={update.pathnames}
+            projectOps={update.project_ops}
+          />
+          <MetadataUsersList
+            users={update.meta.users}
+            origin={update.meta.origin}
             currentUserId={currentUserId}
-            label={label}
           />
-        ))}
-        <Changes pathnames={update.pathnames} projectOps={update.project_ops} />
-        <MetadataUsersList
-          users={update.meta.users}
-          origin={update.meta.origin}
-          currentUserId={currentUserId}
+          <Origin origin={update.meta.origin} />
+        </div>
+        <HistoryVersionDropdown
+          id={`${update.fromV}_${update.toV}`}
+          projectId={projectId}
+          isComparing={selection.comparing}
+          isSelected={selected}
+          fromV={update.fromV}
+          toV={update.toV}
+          updateMetaEndTimestamp={update.meta.end_ts}
         />
-        <Origin origin={update.meta.origin} />
-        {selection.comparing ? null : (
-          <div>
-            <button
-              onClick={event => {
-                event.stopPropagation()
-                compare()
-              }}
-            >
-              Compare
-            </button>
-          </div>
-        )}
-      </div>
+      </HistoryVersionDetails>
     </div>
   )
 }

+ 62 - 33
services/web/frontend/js/features/history/components/change-list/labels-list.tsx

@@ -1,9 +1,11 @@
 import { useTranslation } from 'react-i18next'
-import { Fragment } from 'react'
+import HistoryVersionDetails from './history-version-details'
 import TagTooltip from './tag-tooltip'
 import UserNameWithColoredBadge from './user-name-with-colored-badge'
+import LabelDropdown from './dropdown/label-dropdown'
 import { useHistoryContext } from '../../context/history-context'
 import { useUserContext } from '../../../../shared/context/user-context'
+import { isUpdateSelected } from '../../utils/history-details'
 import { isPseudoLabel } from '../../utils/label'
 import { formatTime } from '../../../utils/format-date'
 import { groupBy, orderBy } from 'lodash'
@@ -11,7 +13,7 @@ import { LoadedLabel } from '../../services/types/label'
 
 function LabelsList() {
   const { t } = useTranslation()
-  const { labels } = useHistoryContext()
+  const { updatesInfo, labels, projectId, selection } = useHistoryContext()
   const { id: currentUserId } = useUserContext()
 
   let versionWithLabels: { version: number; labels: LoadedLabel[] }[] = []
@@ -26,40 +28,67 @@ function LabelsList() {
 
   return (
     <>
-      {versionWithLabels.map(({ version, labels }) => (
-        <div
-          key={version}
-          className="history-version-details"
-          data-testid="history-version-details"
-        >
-          {labels.map(label => (
-            <Fragment key={label.id}>
-              <TagTooltip
-                showTooltip={false}
-                currentUserId={currentUserId}
-                label={label}
-              />
-              <time className="history-version-metadata-time">
-                {formatTime(label.created_at, 'Do MMMM, h:mm a')}
-              </time>
-              {!isPseudoLabel(label) && (
-                <div className="history-version-saved-by">
-                  <span className="history-version-saved-by-label">
-                    {t('saved_by')}
-                  </span>
-                  <UserNameWithColoredBadge
-                    user={{
-                      id: label.user_id,
-                      displayName: label.user_display_name,
-                    }}
+      {versionWithLabels.map(({ version, labels }) => {
+        const selected = isUpdateSelected({
+          fromV: version,
+          toV: version,
+          selection,
+        })
+
+        const update = updatesInfo.updates.find(update => {
+          return update.labels.some(label => label.version === version)
+        })
+
+        if (!update) return null
+
+        return (
+          <HistoryVersionDetails
+            key={version}
+            fromV={version}
+            toV={version}
+            fromVTimestamp={update.meta.end_ts}
+            toVTimestamp={update.meta.end_ts}
+            selected={selected}
+          >
+            <div className="history-version-main-details">
+              {labels.map(label => (
+                <div key={label.id} className="history-version-label">
+                  <TagTooltip
+                    showTooltip={false}
                     currentUserId={currentUserId}
+                    label={label}
                   />
+                  <time className="history-version-metadata-time">
+                    {formatTime(label.created_at, 'Do MMMM, h:mm a')}
+                  </time>
+                  {!isPseudoLabel(label) && (
+                    <div className="history-version-saved-by">
+                      <span className="history-version-saved-by-label">
+                        {t('saved_by')}
+                      </span>
+                      <UserNameWithColoredBadge
+                        user={{
+                          id: label.user_id,
+                          displayName: label.user_display_name,
+                        }}
+                        currentUserId={currentUserId}
+                      />
+                    </div>
+                  )}
                 </div>
-              )}
-            </Fragment>
-          ))}
-        </div>
-      ))}
+              ))}
+            </div>
+            <LabelDropdown
+              id={version.toString()}
+              projectId={projectId}
+              version={version}
+              updateMetaEndTimestamp={update.meta.end_ts}
+              isComparing={selection.comparing}
+              isSelected={selected}
+            />
+          </HistoryVersionDetails>
+        )
+      })}
     </>
   )
 }

+ 24 - 0
services/web/frontend/js/features/history/components/change-list/modal-error.tsx

@@ -0,0 +1,24 @@
+import { useTranslation } from 'react-i18next'
+import { Alert } from 'react-bootstrap'
+
+// Using this workaround due to inconsistent and improper error responses from the server
+type ModalErrorProps = {
+  error: {
+    response: Response
+    data?: {
+      message?: string
+    }
+  }
+}
+
+function ModalError({ error }: ModalErrorProps) {
+  const { t } = useTranslation()
+
+  if (error.response.status === 400 && error?.data?.message) {
+    return <Alert bsStyle="danger">{error.data.message}</Alert>
+  }
+
+  return <Alert bsStyle="danger">{t('generic_something_went_wrong')}</Alert>
+}
+
+export default ModalError

+ 17 - 39
services/web/frontend/js/features/history/components/change-list/tag-tooltip.tsx

@@ -1,14 +1,17 @@
 import { useState } from 'react'
 import { useTranslation } from 'react-i18next'
-import { Alert, Modal } from 'react-bootstrap'
-import { useHistoryContext } from '../../context/history-context'
+import { Modal } from 'react-bootstrap'
 import Icon from '../../../../shared/components/icon'
 import Tooltip from '../../../../shared/components/tooltip'
 import Badge from '../../../../shared/components/badge'
 import AccessibleModal from '../../../../shared/components/accessible-modal'
+import ModalError from './modal-error'
+import useAbortController from '../../../../shared/hooks/use-abort-controller'
 import useAsync from '../../../../shared/hooks/use-async'
-import { deleteJSON } from '../../../../infrastructure/fetch-json'
-import { isLabel, isPseudoLabel, loadLabels } from '../../utils/label'
+import useAddOrRemoveLabels from '../../hooks/use-add-or-remove-labels'
+import { useHistoryContext } from '../../context/history-context'
+import { deleteLabel } from '../../services/api'
+import { isPseudoLabel } from '../../utils/label'
 import { formatDate } from '../../../../utils/dates'
 import { LoadedLabel } from '../../services/types/label'
 
@@ -20,9 +23,10 @@ type TagProps = {
 function Tag({ label, currentUserId, ...props }: TagProps) {
   const { t } = useTranslation()
   const [showDeleteModal, setShowDeleteModal] = useState(false)
-  const { projectId, updatesInfo, setUpdatesInfo, labels, setLabels } =
-    useHistoryContext()
-  const { isLoading, isSuccess, isError, error, runAsync } = useAsync()
+  const { projectId } = useHistoryContext()
+  const { signal } = useAbortController()
+  const { removeUpdateLabel } = useAddOrRemoveLabels()
+  const { isLoading, isSuccess, isError, error, reset, runAsync } = useAsync()
   const isPseudoCurrentStateLabel = isPseudoLabel(label)
   const isOwnedByCurrentUser = !isPseudoCurrentStateLabel
     ? label.user_id === currentUserId
@@ -36,35 +40,18 @@ function Tag({ label, currentUserId, ...props }: TagProps) {
   const handleModalExited = () => {
     if (!isSuccess) return
 
-    const tempUpdates = [...updatesInfo.updates]
-    for (const [i, update] of tempUpdates.entries()) {
-      if (update.toV === label.version) {
-        tempUpdates[i] = {
-          ...update,
-          labels: update.labels.filter(({ id }) => id !== label.id),
-        }
-        break
-      }
-    }
-
-    setUpdatesInfo({ ...updatesInfo, updates: tempUpdates })
-
-    if (labels) {
-      const nonPseudoLabels = labels.filter(isLabel)
-      const filteredLabels = nonPseudoLabels.filter(({ id }) => id !== label.id)
-      setLabels(loadLabels(filteredLabels, tempUpdates[0].toV))
+    if (!isPseudoCurrentStateLabel) {
+      removeUpdateLabel(label)
     }
 
-    setShowDeleteModal(false)
+    reset()
 
     // TODO _handleHistoryUIStateChange
   }
 
   const localDeleteHandler = () => {
-    runAsync(deleteJSON(`/project/${projectId}/labels/${label.id}`))
-      .then(() => {
-        setShowDeleteModal(false)
-      })
+    runAsync(deleteLabel(projectId, label.id, signal))
+      .then(() => setShowDeleteModal(false))
       .catch(console.error)
   }
 
@@ -103,16 +90,7 @@ function Tag({ label, currentUserId, ...props }: TagProps) {
             <Modal.Title>{t('history_delete_label')}</Modal.Title>
           </Modal.Header>
           <Modal.Body>
-            {isError ? (
-              responseError.response.status === 400 &&
-              responseError?.data?.message ? (
-                <Alert bsStyle="danger">{responseError.data.message}</Alert>
-              ) : (
-                <Alert bsStyle="danger">
-                  {t('generic_something_went_wrong')}
-                </Alert>
-              )
-            ) : null}
+            {isError && <ModalError error={responseError} />}
             <p>
               {t('history_are_you_sure_delete_label')}&nbsp;
               <strong>"{label.comment}"</strong>?

+ 12 - 2
services/web/frontend/js/features/history/components/change-list/toggle-switch.tsx

@@ -1,4 +1,5 @@
 import { useTranslation } from 'react-i18next'
+import { useHistoryContext } from '../../context/history-context'
 
 type ToggleSwitchProps = {
   labelsOnly: boolean
@@ -9,6 +10,15 @@ type ToggleSwitchProps = {
 
 function ToggleSwitch({ labelsOnly, setLabelsOnly }: ToggleSwitchProps) {
   const { t } = useTranslation()
+  const { resetSelection, selection } = useHistoryContext()
+
+  const handleChange = (isLabelsOnly: boolean) => {
+    if (selection.comparing) {
+      resetSelection()
+    }
+
+    setLabelsOnly(isLabelsOnly)
+  }
 
   return (
     <fieldset className="toggle-switch">
@@ -17,7 +27,7 @@ function ToggleSwitch({ labelsOnly, setLabelsOnly }: ToggleSwitchProps) {
         type="radio"
         name="labels-only-toggle-switch"
         checked={!labelsOnly}
-        onChange={() => setLabelsOnly(false)}
+        onChange={() => handleChange(false)}
         className="toggle-switch-input"
         id="toggle-switch-all-history"
       />
@@ -31,7 +41,7 @@ function ToggleSwitch({ labelsOnly, setLabelsOnly }: ToggleSwitchProps) {
         type="radio"
         name="labels-only-toggle-switch"
         checked={labelsOnly}
-        onChange={() => setLabelsOnly(true)}
+        onChange={() => handleChange(true)}
         className="toggle-switch-input"
         id="toggle-switch-labels"
       />

+ 14 - 6
services/web/frontend/js/features/history/context/history-context.tsx

@@ -57,6 +57,13 @@ function limitUpdates(
   })
 }
 
+const selectionInitialState: Selection = {
+  updateRange: null,
+  comparing: false,
+  files: [],
+  pathname: null,
+}
+
 function useHistory() {
   const { view } = useLayoutContext()
   const user = useUserContext()
@@ -65,12 +72,7 @@ function useHistory() {
   const projectId = project._id
   const projectOwnerId = project.owner?._id
 
-  const [selection, setSelection] = useState<Selection>({
-    updateRange: null,
-    comparing: false,
-    files: [],
-    pathname: null,
-  })
+  const [selection, setSelection] = useState<Selection>(selectionInitialState)
 
   const [updatesInfo, setUpdatesInfo] = useState<
     HistoryContextValue['updatesInfo']
@@ -183,6 +185,10 @@ function useHistory() {
     updatesInfo,
   ])
 
+  const resetSelection = useCallback(() => {
+    setSelection(selectionInitialState)
+  }, [])
+
   // Initial load when the History tab is active
   const initialFetch = useRef(false)
   useEffect(() => {
@@ -250,6 +256,7 @@ function useHistory() {
       selection,
       setSelection,
       fetchNextBatchOfUpdates,
+      resetSelection,
     }),
     [
       error,
@@ -263,6 +270,7 @@ function useHistory() {
       selection,
       setSelection,
       fetchNextBatchOfUpdates,
+      resetSelection,
     ]
   )
 

+ 4 - 1
services/web/frontend/js/features/history/context/types/history-context-value.ts

@@ -20,6 +20,9 @@ export type HistoryContextValue = {
   setLabels: React.Dispatch<React.SetStateAction<HistoryContextValue['labels']>>
   projectId: string
   selection: Selection
-  setSelection: (selection: Selection) => void
+  setSelection: React.Dispatch<
+    React.SetStateAction<HistoryContextValue['selection']>
+  >
   fetchNextBatchOfUpdates: () => void
+  resetSelection: () => void
 }

+ 46 - 0
services/web/frontend/js/features/history/hooks/use-add-or-remove-labels.ts

@@ -0,0 +1,46 @@
+import { useHistoryContext } from '../context/history-context'
+import { isLabel, loadLabels } from '../utils/label'
+import { Label } from '../services/types/label'
+
+function useAddOrRemoveLabels() {
+  const { updatesInfo, setUpdatesInfo, labels, setLabels } = useHistoryContext()
+
+  const addOrRemoveLabel = (
+    label: Label,
+    labelsHandler: (label: Label[]) => Label[]
+  ) => {
+    const tempUpdates = [...updatesInfo.updates]
+    for (const [i, update] of tempUpdates.entries()) {
+      if (update.toV === label.version) {
+        tempUpdates[i] = {
+          ...update,
+          labels: labelsHandler(update.labels),
+        }
+        break
+      }
+    }
+
+    setUpdatesInfo({ ...updatesInfo, updates: tempUpdates })
+
+    if (labels) {
+      const nonPseudoLabels = labels.filter(isLabel)
+      const processedNonPseudoLabels = labelsHandler(nonPseudoLabels)
+      setLabels(loadLabels(processedNonPseudoLabels, tempUpdates[0].toV))
+    }
+  }
+
+  const addUpdateLabel = (label: Label) => {
+    const labelHandler = (labels: Label[]) => labels.concat(label)
+    addOrRemoveLabel(label, labelHandler)
+  }
+
+  const removeUpdateLabel = (label: Label) => {
+    const labelHandler = (labels: Label[]) =>
+      labels.filter(({ id }) => id !== label.id)
+    addOrRemoveLabel(label, labelHandler)
+  }
+
+  return { addUpdateLabel, removeUpdateLabel }
+}
+
+export default useAddOrRemoveLabels

+ 21 - 1
services/web/frontend/js/features/history/services/api.ts

@@ -1,4 +1,8 @@
-import { getJSON } from '../../../infrastructure/fetch-json'
+import {
+  deleteJSON,
+  getJSON,
+  postJSON,
+} from '../../../infrastructure/fetch-json'
 import { FileDiff } from './types/file'
 import { FetchUpdatesResponse } from './types/update'
 import { Label } from './types/label'
@@ -25,6 +29,22 @@ export function fetchLabels(projectId: string) {
   return getJSON<Label[]>(labelsURL)
 }
 
+export function addLabel(
+  projectId: string,
+  body: { comment: string; version: number },
+  signal?: AbortSignal
+) {
+  return postJSON(`/project/${projectId}/labels`, { body, signal })
+}
+
+export function deleteLabel(
+  projectId: string,
+  labelId: string,
+  signal?: AbortSignal
+) {
+  return deleteJSON(`/project/${projectId}/labels/${labelId}`, { signal })
+}
+
 export function diffFiles(projectId: string, fromV: number, toV: number) {
   const queryParams: Record<string, string> = {
     from: fromV.toString(),

+ 6 - 4
services/web/frontend/js/features/history/services/types/label.ts

@@ -1,22 +1,24 @@
 import { Nullable } from '../../../../../../types/utils'
 
-interface UpdateLabel {
+interface LabelBase {
   id: string
+  created_at: string
+}
+
+interface UpdateLabel extends LabelBase {
   comment: string
   version: number
   user_id: string
-  created_at: string
 }
 
 export interface Label extends UpdateLabel {
   user_display_name: string
 }
 
-export interface PseudoCurrentStateLabel {
+export interface PseudoCurrentStateLabel extends LabelBase {
   id: '1'
   isPseudoCurrentStateLabel: true
   version: Nullable<number>
-  created_at: string
 }
 
 export type LoadedLabel = Label | PseudoCurrentStateLabel

+ 7 - 6
services/web/frontend/js/features/history/utils/history-details.ts

@@ -43,14 +43,15 @@ type UpdateIsSelectedArg = {
   selection: Selection
 }
 
-export const updateIsSelected = ({
+export const isUpdateSelected = ({
   fromV,
   toV,
   selection,
 }: UpdateIsSelectedArg) => {
-  return (
-    selection.updateRange &&
-    fromV >= selection.updateRange.fromV &&
-    toV <= selection.updateRange.toV
-  )
+  if (selection.updateRange) {
+    return (
+      fromV >= selection.updateRange.fromV && toV <= selection.updateRange.toV
+    )
+  }
+  return false
 }

+ 2 - 0
services/web/frontend/js/features/history/utils/label.ts

@@ -62,3 +62,5 @@ export const loadLabels = (
 
   return labelsWithPseudoLabelIfNeeded
 }
+
+export const updateLabels = () => {}

+ 45 - 0
services/web/frontend/js/shared/components/dropdown/dropdown-toggle-with-tooltip.tsx

@@ -0,0 +1,45 @@
+import { forwardRef } from 'react'
+import Tooltip from '../tooltip'
+import classnames from 'classnames'
+import { DropdownProps } from 'react-bootstrap'
+import { MergeAndOverride } from '../../../../../types/utils'
+
+type CustomToggleProps = MergeAndOverride<
+  Pick<DropdownProps, 'bsClass'>,
+  {
+    children: React.ReactNode
+    bsRole: 'toggle'
+    className?: string
+    tooltipProps: Omit<React.ComponentProps<typeof Tooltip>, 'children'>
+  }
+>
+
+const DropdownToggleWithTooltip = forwardRef<
+  HTMLButtonElement,
+  CustomToggleProps
+>(function (props, ref) {
+  const {
+    tooltipProps,
+    children,
+    bsClass,
+    className,
+    bsRole: _bsRole,
+    ...rest
+  } = props
+
+  return (
+    <Tooltip {...tooltipProps}>
+      <button
+        type="button"
+        ref={ref}
+        className={classnames(bsClass, 'btn', className)}
+        {...rest}
+      >
+        {children}
+      </button>
+    </Tooltip>
+  )
+})
+DropdownToggleWithTooltip.displayName = 'DropdownToggleWithTooltip'
+
+export default DropdownToggleWithTooltip

+ 9 - 4
services/web/frontend/stories/history/toggle-switch.stories.tsx

@@ -1,14 +1,18 @@
 import { useState } from 'react'
 import ToggleSwitchComponent from '../../js/features/history/components/change-list/toggle-switch'
+import { ScopeDecorator } from '../decorators/scope'
+import { HistoryProvider } from '../../js/features/history/context/history-context'
 
 export const LabelsOnlyToggleSwitch = () => {
   const [labelsOnly, setLabelsOnly] = useState(false)
 
   return (
-    <ToggleSwitchComponent
-      labelsOnly={labelsOnly}
-      setLabelsOnly={setLabelsOnly}
-    />
+    <HistoryProvider>
+      <ToggleSwitchComponent
+        labelsOnly={labelsOnly}
+        setLabelsOnly={setLabelsOnly}
+      />
+    </HistoryProvider>
   )
 }
 
@@ -28,6 +32,7 @@ export default {
     },
   },
   decorators: [
+    ScopeDecorator,
     (Story: React.ComponentType) => (
       <div className="history-react">
         <div className="change-list">

+ 51 - 5
services/web/frontend/stylesheets/app/editor/history-react.less

@@ -83,6 +83,7 @@ history-root {
 
   .history-version-day {
     position: sticky;
+    z-index: 1;
     top: 0;
     display: block;
     padding-top: 12px;
@@ -92,6 +93,8 @@ history-root {
   }
 
   .history-version-details {
+    display: flex;
+    align-items: flex-start;
     padding-top: 8px;
     padding-bottom: 8px;
 
@@ -169,13 +172,9 @@ history-root {
     margin-right: 10px;
   }
 
-  .history-version-saved-by {
+  .history-version-label {
     margin-bottom: 4px;
 
-    &-label {
-      margin-right: 8px;
-    }
-
     &:last-child {
       margin-bottom: initial;
     }
@@ -198,6 +197,40 @@ history-root {
     background-color: @gray-lightest;
   }
 
+  .history-version-saved-by {
+    &-label {
+      margin-right: 8px;
+    }
+  }
+
+  .dropdown.open {
+    .history-version-dropdown-menu-btn {
+      background-color: rgba(@neutral-90, 0.08);
+      box-shadow: initial;
+    }
+  }
+
+  .history-version-dropdown-menu-btn {
+    @size: 30px;
+    padding: 0;
+    width: @size;
+    height: @size;
+    font-size: @font-size-small;
+    color: @neutral-90;
+    background-color: transparent;
+
+    &:hover,
+    &:active {
+      z-index: initial;
+      background-color: rgba(@neutral-90, 0.08);
+      box-shadow: initial;
+    }
+  }
+
+  .history-version-main-details {
+    flex: 1;
+  }
+
   .history-loading-panel {
     padding-top: 10rem;
     font-family: @font-family-serif;
@@ -242,6 +275,19 @@ history-root {
   }
 }
 
+.history-version-dropdown-menu {
+  [role='menuitem'] {
+    padding: 10px 12px;
+    color: @neutral-90;
+
+    &:hover,
+    &:focus {
+      color: @neutral-90;
+      background-color: @neutral-10;
+    }
+  }
+}
+
 .document-diff-container {
   height: 100%;
   display: flex;

+ 1 - 1
services/web/frontend/stylesheets/components/badge.less

@@ -24,7 +24,7 @@
     width: @size;
     margin-left: 4px;
     margin-right: -@padding;
-    font-size: @size;
+    font-size: 20px;
 
     &:hover {
       background-color: @neutral-40;

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

@@ -642,8 +642,10 @@
   "history_add_label": "Add label",
   "history_adding_label": "Adding label",
   "history_are_you_sure_delete_label": "Are you sure you want to delete the following label",
+  "history_compare_with_this_version": "Compare with this version",
   "history_delete_label": "Delete label",
   "history_deleting_label": "Deleting label",
+  "history_download_this_version": "Download this version",
   "history_entry_origin_dropbox": "via Dropbox",
   "history_entry_origin_git": "via Git",
   "history_entry_origin_github": "via GitHub",
@@ -911,6 +913,7 @@
   "month": "month",
   "monthly": "Monthly",
   "more": "More",
+  "more_actions": "More actions",
   "more_info": "More Info",
   "more_project_collaborators": "<0>More</0> project <0>collaborators</0>",
   "more_than_one_kind_of_snippet_was_requested": "The link to open this content on Overleaf included some invalid parameters. If this keeps happening for links on a particular site, please report this to them.",

+ 11 - 8
services/web/test/frontend/features/history/components/change-list.spec.tsx

@@ -6,14 +6,15 @@ import { HistoryProvider } from '../../../../../frontend/js/features/history/con
 import { updates } from '../fixtures/updates'
 import { labels } from '../fixtures/labels'
 
-const mountChangeList = (scope: Record<string, unknown> = {}) => {
+const mountWithEditorProviders = (
+  component: React.ReactNode,
+  scope: Record<string, unknown> = {}
+) => {
   cy.mount(
     <EditorProviders scope={scope}>
       <HistoryProvider>
         <div style={{ display: 'flex', justifyContent: 'center' }}>
-          <div className="history-react">
-            <ChangeList />
-          </div>
+          <div className="history-react">{component}</div>
         </div>
       </HistoryProvider>
     </EditorProviders>
@@ -23,7 +24,9 @@ const mountChangeList = (scope: Record<string, unknown> = {}) => {
 describe('change list', function () {
   describe('toggle switch', function () {
     it('renders switch buttons', function () {
-      cy.mount(<ToggleSwitch labelsOnly={false} setLabelsOnly={() => {}} />)
+      mountWithEditorProviders(
+        <ToggleSwitch labelsOnly={false} setLabelsOnly={() => {}} />
+      )
 
       cy.findByLabelText(/all history/i)
       cy.findByLabelText(/labels/i)
@@ -40,7 +43,7 @@ describe('change list', function () {
         )
       }
 
-      cy.mount(<ToggleSwitchWrapped labelsOnly={false} />)
+      mountWithEditorProviders(<ToggleSwitchWrapped labelsOnly={false} />)
 
       cy.findByLabelText(/all history/i).as('all-history')
       cy.findByLabelText(/labels/i).as('labels')
@@ -76,7 +79,7 @@ describe('change list', function () {
     })
 
     it('renders tags', function () {
-      mountChangeList(scope)
+      mountWithEditorProviders(<ChangeList />, scope)
       waitForData()
 
       cy.findByLabelText(/all history/i).click({ force: true })
@@ -141,7 +144,7 @@ describe('change list', function () {
     })
 
     it('deletes tag', function () {
-      mountChangeList(scope)
+      mountWithEditorProviders(<ChangeList />, scope)
       waitForData()
 
       cy.findByLabelText(/all history/i).click({ force: true })