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

Merge pull request #12836 from overleaf/td-history-performance

History migration: Improve performance of selecting or comparing versions

GitOrigin-RevId: 2a18a93c246fb94ed8d8b9770449be83364177ea
June Kelly 3 лет назад
Родитель
Сommit
0346e32906
17 измененных файлов с 523 добавлено и 258 удалено
  1. 49 23
      services/web/frontend/js/features/history/components/change-list/all-history-list.tsx
  2. 10 3
      services/web/frontend/js/features/history/components/change-list/dropdown/actions-dropdown.tsx
  3. 23 0
      services/web/frontend/js/features/history/components/change-list/dropdown/history-dropdown.tsx
  4. 0 43
      services/web/frontend/js/features/history/components/change-list/dropdown/history-version-dropdown.tsx
  5. 48 0
      services/web/frontend/js/features/history/components/change-list/dropdown/label-dropdown-content.tsx
  6. 0 40
      services/web/frontend/js/features/history/components/change-list/dropdown/label-dropdown.tsx
  7. 13 2
      services/web/frontend/js/features/history/components/change-list/dropdown/menu-item/add-label.tsx
  8. 24 16
      services/web/frontend/js/features/history/components/change-list/dropdown/menu-item/compare.tsx
  9. 8 1
      services/web/frontend/js/features/history/components/change-list/dropdown/menu-item/download.tsx
  10. 52 0
      services/web/frontend/js/features/history/components/change-list/dropdown/version-dropdown-content.tsx
  11. 10 11
      services/web/frontend/js/features/history/components/change-list/history-version-details.tsx
  12. 95 59
      services/web/frontend/js/features/history/components/change-list/history-version.tsx
  13. 121 0
      services/web/frontend/js/features/history/components/change-list/label-list-item.tsx
  14. 21 59
      services/web/frontend/js/features/history/components/change-list/labels-list.tsx
  15. 1 1
      services/web/frontend/js/features/history/components/history-root.tsx
  16. 36 0
      services/web/frontend/js/features/history/hooks/use-dropdown-active-item.tsx
  17. 12 0
      services/web/frontend/js/features/history/utils/history-details.ts

+ 49 - 23
services/web/frontend/js/features/history/components/change-list/all-history-list.tsx

@@ -1,20 +1,35 @@
-import { Fragment, useEffect, useRef, useState } from 'react'
-import { useHistoryContext } from '../../context/history-context'
+import { useEffect, useRef, useState } from 'react'
 import HistoryVersion from './history-version'
 import LoadingSpinner from '../../../../shared/components/loading-spinner'
 import { OwnerPaywallPrompt } from './owner-paywall-prompt'
 import { NonOwnerPaywallPrompt } from './non-owner-paywall-prompt'
-import { relativeDate } from '../../../utils/format-date'
+import { isVersionSelected } from '../../utils/history-details'
+import { useUserContext } from '../../../../shared/context/user-context'
+import useDropdownActiveItem from '../../hooks/use-dropdown-active-item'
+import { useHistoryContext } from '../../context/history-context'
 
 function AllHistoryList() {
-  const { updatesInfo, fetchNextBatchOfUpdates, currentUserIsOwner } =
-    useHistoryContext()
-  const updatesLoadingState = updatesInfo.loadingState
-  const { visibleUpdateCount, updates, atEnd } = updatesInfo
+  const { id: currentUserId } = useUserContext()
+  const {
+    projectId,
+    updatesInfo,
+    fetchNextBatchOfUpdates,
+    currentUserIsOwner,
+    selection,
+    setSelection,
+  } = useHistoryContext()
+  const {
+    visibleUpdateCount,
+    updates,
+    atEnd,
+    loadingState: updatesLoadingState,
+  } = updatesInfo
   const scrollerRef = useRef<HTMLDivElement>(null)
   const bottomRef = useRef<HTMLDivElement>(null)
   const intersectionObserverRef = useRef<IntersectionObserver | null>(null)
   const [bottomVisible, setBottomVisible] = useState(false)
+  const { activeDropdownItem, setActiveDropdownItem, closeDropdownForItem } =
+    useDropdownActiveItem()
   const showPaywall =
     updatesLoadingState === 'ready' && updatesInfo.freeHistoryLimitHit
   const showOwnerPaywall = showPaywall && currentUserIsOwner
@@ -73,25 +88,36 @@ function AllHistoryList() {
     <div ref={scrollerRef} className="history-all-versions-scroller">
       <div className="history-all-versions-container">
         <div ref={bottomRef} className="history-versions-bottom" />
-        {visibleUpdates.map((update, index) => (
-          <Fragment key={`${update.fromV}_${update.toV}`}>
-            {update.meta.first_in_day && index > 0 && (
-              <hr className="history-version-divider" />
-            )}
-            {update.meta.first_in_day && (
-              <time className="history-version-day">
-                {relativeDate(update.meta.end_ts)}
-              </time>
-            )}
+        {visibleUpdates.map((update, index) => {
+          const selected = isVersionSelected(
+            selection,
+            update.fromV,
+            update.toV
+          )
+          const dropdownActive = update === activeDropdownItem.item
+          const showDivider = Boolean(update.meta.first_in_day && index > 0)
+          const faded =
+            updatesInfo.freeHistoryLimitHit &&
+            index === visibleUpdates.length - 1
+
+          return (
             <HistoryVersion
+              key={`${update.fromV}_${update.toV}`}
               update={update}
-              faded={
-                updatesInfo.freeHistoryLimitHit &&
-                index === visibleUpdates.length - 1
-              }
+              faded={faded}
+              showDivider={showDivider}
+              setSelection={setSelection}
+              selected={selected}
+              currentUserId={currentUserId}
+              comparing={selection.comparing}
+              projectId={projectId}
+              setActiveDropdownItem={setActiveDropdownItem}
+              closeDropdownForItem={closeDropdownForItem}
+              dropdownOpen={activeDropdownItem.isOpened && dropdownActive}
+              dropdownActive={dropdownActive}
             />
-          </Fragment>
-        ))}
+          )
+        })}
       </div>
       {showOwnerPaywall ? <OwnerPaywallPrompt /> : null}
       {showNonOwnerPaywall ? <NonOwnerPaywallPrompt /> : null}

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

@@ -1,4 +1,4 @@
-import { useState, useRef, useEffect } from 'react'
+import { useRef, useEffect } from 'react'
 import { useTranslation } from 'react-i18next'
 import { Dropdown } from 'react-bootstrap'
 import DropdownToggleWithTooltip from '../../../../../shared/components/dropdown/dropdown-toggle-with-tooltip'
@@ -9,11 +9,18 @@ type DropdownMenuProps = {
   id: string
   children: React.ReactNode
   parentSelector?: string
+  isOpened: boolean
+  setIsOpened: (isOpened: boolean) => void
 }
 
-function ActionsDropdown({ id, children, parentSelector }: DropdownMenuProps) {
+function ActionsDropdown({
+  id,
+  children,
+  parentSelector,
+  isOpened,
+  setIsOpened,
+}: DropdownMenuProps) {
   const { t } = useTranslation()
-  const [isOpened, setIsOpened] = useState(false)
   const menuRef = useRef<HTMLElement>()
 
   // handle the placement of the dropdown above or below the toggle button

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

@@ -0,0 +1,23 @@
+import ActionsDropdown from './actions-dropdown'
+
+type HistoryDropdownProps = {
+  children: React.ReactNode
+  id: string
+  isOpened: boolean
+  setIsOpened: (isOpened: boolean) => void
+}
+
+function HistoryDropdown({
+  children,
+  id,
+  isOpened,
+  setIsOpened,
+}: HistoryDropdownProps) {
+  return (
+    <ActionsDropdown id={id} isOpened={isOpened} setIsOpened={setIsOpened}>
+      {children}
+    </ActionsDropdown>
+  )
+}
+
+export default HistoryDropdown

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

@@ -1,43 +0,0 @@
-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}
-      parentSelector="[data-history-version-list-container]"
-    >
-      <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

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

@@ -0,0 +1,48 @@
+import Download from './menu-item/download'
+import Compare from './menu-item/compare'
+import { Version } from '../../../services/types/update'
+import { ActiveDropdown } from '../../../hooks/use-dropdown-active-item'
+import { useCallback } from 'react'
+
+type LabelDropdownContentProps = {
+  projectId: string
+  version: Version
+  updateMetaEndTimestamp: number
+  selected: boolean
+  comparing: boolean
+  closeDropdownForItem: ActiveDropdown['closeDropdownForItem']
+}
+
+function LabelDropdownContent({
+  projectId,
+  version,
+  updateMetaEndTimestamp,
+  selected,
+  comparing,
+  closeDropdownForItem,
+}: LabelDropdownContentProps) {
+  const closeDropdown = useCallback(() => {
+    closeDropdownForItem(version)
+  }, [closeDropdownForItem, version])
+
+  return (
+    <>
+      <Download
+        projectId={projectId}
+        version={version}
+        closeDropdown={closeDropdown}
+      />
+      {!comparing && !selected && (
+        <Compare
+          projectId={projectId}
+          fromV={version}
+          toV={version}
+          updateMetaEndTimestamp={updateMetaEndTimestamp}
+          closeDropdown={closeDropdown}
+        />
+      )}
+    </>
+  )
+}
+
+export default LabelDropdownContent

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

@@ -1,40 +0,0 @@
-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}
-      parentSelector="[data-history-version-list-container]"
-    >
-      <Download projectId={projectId} version={version} />
-      {!isComparing && !isSelected && (
-        <Compare
-          projectId={projectId}
-          fromV={version}
-          toV={version}
-          updateMetaEndTimestamp={updateMetaEndTimestamp}
-        />
-      )}
-    </ActionsDropdown>
-  )
-}
-
-export default LabelDropdown

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

@@ -7,15 +7,26 @@ import AddLabelModal from '../../add-label-modal'
 type DownloadProps = {
   projectId: string
   version: number
+  closeDropdown: () => void
 }
 
-function AddLabel({ version, projectId, ...props }: DownloadProps) {
+function AddLabel({
+  version,
+  projectId,
+  closeDropdown,
+  ...props
+}: DownloadProps) {
   const { t } = useTranslation()
   const [showModal, setShowModal] = useState(false)
 
+  const handleClick = () => {
+    closeDropdown()
+    setShowModal(true)
+  }
+
   return (
     <>
-      <MenuItem onClick={() => setShowModal(true)} {...props}>
+      <MenuItem onClick={handleClick} {...props}>
         <Icon type="tag" fw /> {t('history_label_this_version')}
       </MenuItem>
       <AddLabelModal

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

@@ -8,6 +8,7 @@ import { UpdateRange } from '../../../../services/types/update'
 type CompareProps = {
   projectId: string
   updateMetaEndTimestamp: number
+  closeDropdown: () => void
 } & Pick<UpdateRange, 'fromV' | 'toV'>
 
 function Compare({
@@ -15,29 +16,36 @@ function Compare({
   fromV,
   toV,
   updateMetaEndTimestamp,
+  closeDropdown,
   ...props
 }: CompareProps) {
   const { t } = useTranslation()
-  const { selection, setSelection } = useHistoryContext()
+  const { setSelection } = useHistoryContext()
 
   const handleCompareVersion = (e: React.MouseEvent<MenuItemProps>) => {
     e.stopPropagation()
+    closeDropdown()
 
-    const { updateRange } = selection
-    if (updateRange) {
-      const range = computeUpdateRange(
-        updateRange,
-        fromV,
-        toV,
-        updateMetaEndTimestamp
-      )
-
-      setSelection({
-        updateRange: range,
-        comparing: true,
-        files: [],
-      })
-    }
+    setSelection(prevSelection => {
+      const { updateRange } = prevSelection
+
+      if (updateRange) {
+        const range = computeUpdateRange(
+          updateRange,
+          fromV,
+          toV,
+          updateMetaEndTimestamp
+        )
+
+        return {
+          updateRange: range,
+          comparing: true,
+          files: [],
+        }
+      }
+
+      return prevSelection
+    })
   }
 
   return (

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

@@ -6,13 +6,20 @@ import * as location from '../../../../../../shared/components/location'
 type DownloadProps = {
   projectId: string
   version: number
+  closeDropdown: () => void
 }
 
-function Download({ version, projectId, ...props }: DownloadProps) {
+function Download({
+  version,
+  projectId,
+  closeDropdown,
+  ...props
+}: DownloadProps) {
   const { t } = useTranslation()
 
   const handleDownloadVersion = (e: React.MouseEvent<MenuItemProps>) => {
     e.preventDefault()
+    closeDropdown()
     const event = e as typeof e & { target: HTMLAnchorElement }
     location.assign(event.target.href)
   }

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

@@ -0,0 +1,52 @@
+import AddLabel from './menu-item/add-label'
+import Download from './menu-item/download'
+import Compare from './menu-item/compare'
+import { LoadedUpdate } from '../../../services/types/update'
+import { useCallback } from 'react'
+import { ActiveDropdown } from '../../../hooks/use-dropdown-active-item'
+
+type VersionDropdownContentProps = {
+  projectId: string
+  update: LoadedUpdate
+  selected: boolean
+  comparing: boolean
+  closeDropdownForItem: ActiveDropdown['closeDropdownForItem']
+}
+
+function VersionDropdownContent({
+  projectId,
+  update,
+  selected,
+  comparing,
+  closeDropdownForItem,
+}: VersionDropdownContentProps) {
+  const closeDropdown = useCallback(() => {
+    closeDropdownForItem(update)
+  }, [closeDropdownForItem, update])
+
+  return (
+    <>
+      <AddLabel
+        projectId={projectId}
+        version={update.toV}
+        closeDropdown={closeDropdown}
+      />
+      <Download
+        projectId={projectId}
+        version={update.toV}
+        closeDropdown={closeDropdown}
+      />
+      {!comparing && !selected && (
+        <Compare
+          projectId={projectId}
+          fromV={update.fromV}
+          toV={update.toV}
+          updateMetaEndTimestamp={update.meta.end_ts}
+          closeDropdown={closeDropdown}
+        />
+      )}
+    </>
+  )
+}
+
+export default VersionDropdownContent

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

@@ -1,29 +1,28 @@
-import { useHistoryContext } from '../../context/history-context'
 import classnames from 'classnames'
+import { HistoryContextValue } from '../../context/types/history-context-value'
 import { UpdateRange } from '../../services/types/update'
+import { ReactNode, MouseEvent } from 'react'
 
 type HistoryVersionDetailsProps = {
-  children: React.ReactNode
+  children: ReactNode
+  updateRange: UpdateRange
   selected: boolean
   selectable: boolean
-} & UpdateRange
+  setSelection: HistoryContextValue['setSelection']
+}
 
 function HistoryVersionDetails({
   children,
   selected,
+  updateRange,
   selectable,
-  fromV,
-  toV,
-  fromVTimestamp,
-  toVTimestamp,
+  setSelection,
 }: HistoryVersionDetailsProps) {
-  const { setSelection } = useHistoryContext()
-
-  const handleSelect = (e: React.MouseEvent<HTMLDivElement>) => {
+  const handleSelect = (e: MouseEvent<HTMLDivElement>) => {
     const target = e.target as HTMLElement
     if (!target.closest('.dropdown') && e.currentTarget.contains(target)) {
       setSelection({
-        updateRange: { fromV, toV, fromVTimestamp, toVTimestamp },
+        updateRange,
         comparing: false,
         files: [],
       })

+ 95 - 59
services/web/frontend/js/features/history/components/change-list/history-version.tsx

@@ -3,79 +3,115 @@ 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 { isVersionSelected } from '../../utils/history-details'
-import { formatTime } from '../../../utils/format-date'
+import HistoryDropdown from './dropdown/history-dropdown'
+import { formatTime, relativeDate } from '../../../utils/format-date'
 import { orderBy } from 'lodash'
 import { LoadedUpdate } from '../../services/types/update'
 import classNames from 'classnames'
+import { updateRangeForUpdate } from '../../utils/history-details'
+import { ActiveDropdown } from '../../hooks/use-dropdown-active-item'
+import { memo } from 'react'
+import { HistoryContextValue } from '../../context/types/history-context-value'
+import VersionDropdownContent from './dropdown/version-dropdown-content'
 
-type HistoryEntryProps = {
+type HistoryVersionProps = {
   update: LoadedUpdate
+  currentUserId: string
+  projectId: string
+  comparing: boolean
   faded: boolean
+  showDivider: boolean
+  selected: boolean
+  setSelection: HistoryContextValue['setSelection']
+  dropdownOpen: boolean
+  dropdownActive: boolean
+  setActiveDropdownItem: ActiveDropdown['setActiveDropdownItem']
+  closeDropdownForItem: ActiveDropdown['closeDropdownForItem']
 }
 
-function HistoryVersion({ update, faded }: HistoryEntryProps) {
-  const { id: currentUserId } = useUserContext()
-  const { projectId, selection } = useHistoryContext()
-
+function HistoryVersion({
+  update,
+  currentUserId,
+  projectId,
+  comparing,
+  faded,
+  showDivider,
+  selected,
+  setSelection,
+  dropdownOpen,
+  dropdownActive,
+  setActiveDropdownItem,
+  closeDropdownForItem,
+}: HistoryVersionProps) {
   const orderedLabels = orderBy(update.labels, ['created_at'], ['desc'])
-  const selected = isVersionSelected(selection, update.fromV, update.toV)
+  const selectable = !faded && (comparing || !selected)
 
   return (
-    <div
-      data-testid="history-version"
-      className={classNames({
-        'history-version-faded': faded,
-      })}
-    >
-      <HistoryVersionDetails
-        fromV={update.fromV}
-        toV={update.toV}
-        fromVTimestamp={update.meta.end_ts}
-        toVTimestamp={update.meta.end_ts}
-        selected={selected}
-        selectable={!faded && (selection.comparing || !selected)}
+    <>
+      {showDivider ? <hr className="history-version-divider" /> : null}
+      {update.meta.first_in_day ? (
+        <time className="history-version-day">
+          {relativeDate(update.meta.end_ts)}
+        </time>
+      ) : null}
+      <div
+        data-testid="history-version"
+        className={classNames({
+          'history-version-faded': faded,
+        })}
       >
-        <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
+        <HistoryVersionDetails
+          selected={selected}
+          setSelection={setSelection}
+          updateRange={updateRangeForUpdate(update)}
+          selectable={selectable}
+        >
+          <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>
-        {faded ? null : (
-          <HistoryVersionDropdown
-            id={`${update.fromV}_${update.toV}`}
-            projectId={projectId}
-            isComparing={selection.comparing}
-            isSelected={selected}
-            fromV={update.fromV}
-            toV={update.toV}
-            updateMetaEndTimestamp={update.meta.end_ts}
-          />
-        )}
-      </HistoryVersionDetails>
-    </div>
+            <Origin origin={update.meta.origin} />
+          </div>
+          {faded ? null : (
+            <HistoryDropdown
+              id={`${update.fromV}_${update.toV}`}
+              isOpened={dropdownOpen}
+              setIsOpened={(isOpened: boolean) =>
+                setActiveDropdownItem({ item: update, isOpened })
+              }
+            >
+              {dropdownActive ? (
+                <VersionDropdownContent
+                  comparing={comparing}
+                  selected={selected}
+                  update={update}
+                  projectId={projectId}
+                  closeDropdownForItem={closeDropdownForItem}
+                />
+              ) : null}
+            </HistoryDropdown>
+          )}
+        </HistoryVersionDetails>
+      </div>
+    </>
   )
 }
 
-export default HistoryVersion
+export default memo(HistoryVersion)

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

@@ -0,0 +1,121 @@
+import { memo, useCallback } from 'react'
+import { UpdateRange, Version } from '../../services/types/update'
+import TagTooltip from './tag-tooltip'
+import { formatTime, isoToUnix } from '../../../utils/format-date'
+import { isPseudoLabel } from '../../utils/label'
+import UserNameWithColoredBadge from './user-name-with-colored-badge'
+import HistoryDropdown from './dropdown/history-dropdown'
+import HistoryVersionDetails from './history-version-details'
+import { LoadedLabel } from '../../services/types/label'
+import { useTranslation } from 'react-i18next'
+import { ActiveDropdown } from '../../hooks/use-dropdown-active-item'
+import { HistoryContextValue } from '../../context/types/history-context-value'
+import LabelDropdownContent from './dropdown/label-dropdown-content'
+
+type LabelListItemProps = {
+  version: Version
+  labels: LoadedLabel[]
+  currentUserId: string
+  projectId: string
+  comparing: boolean
+  selected: boolean
+  selectable: boolean
+  setSelection: HistoryContextValue['setSelection']
+  dropdownOpen: boolean
+  dropdownActive: boolean
+  setActiveDropdownItem: ActiveDropdown['setActiveDropdownItem']
+  closeDropdownForItem: ActiveDropdown['closeDropdownForItem']
+}
+
+function LabelListItem({
+  version,
+  labels,
+  currentUserId,
+  projectId,
+  comparing,
+  selected,
+  selectable,
+  setSelection,
+  dropdownOpen,
+  dropdownActive,
+  setActiveDropdownItem,
+  closeDropdownForItem,
+}: LabelListItemProps) {
+  const { t } = useTranslation()
+
+  // first label
+  const fromVTimestamp = isoToUnix(labels[labels.length - 1].created_at)
+  // most recent label
+  const toVTimestamp = isoToUnix(labels[0].created_at)
+
+  const updateRange: UpdateRange = {
+    fromV: version,
+    toV: version,
+    fromVTimestamp,
+    toVTimestamp,
+  }
+
+  const setIsOpened = useCallback(
+    (isOpened: boolean) => {
+      setActiveDropdownItem({ item: version, isOpened })
+    },
+    [setActiveDropdownItem, version]
+  )
+
+  return (
+    <HistoryVersionDetails
+      key={version}
+      updateRange={updateRange}
+      selected={selected}
+      selectable={selectable}
+      setSelection={setSelection}
+    >
+      <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>
+        ))}
+      </div>
+      <HistoryDropdown
+        id={version.toString()}
+        isOpened={dropdownOpen}
+        setIsOpened={setIsOpened}
+      >
+        {dropdownActive ? (
+          <LabelDropdownContent
+            comparing={comparing}
+            selected={selected}
+            version={version}
+            updateMetaEndTimestamp={toVTimestamp}
+            projectId={projectId}
+            closeDropdownForItem={closeDropdownForItem}
+          />
+        ) : null}
+      </HistoryDropdown>
+    </HistoryVersionDetails>
+  )
+}
+
+export default memo(LabelListItem)

+ 21 - 59
services/web/frontend/js/features/history/components/change-list/labels-list.tsx

@@ -1,20 +1,17 @@
-import { useMemo } from 'react'
-import { useTranslation } from 'react-i18next'
-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 { isVersionSelected } from '../../utils/history-details'
-import { getVersionWithLabels, isPseudoLabel } from '../../utils/label'
-import { formatTime, isoToUnix } from '../../../utils/format-date'
+import { useMemo } from 'react'
 import { Version } from '../../services/types/update'
+import LabelListItem from './label-list-item'
+import useDropdownActiveItem from '../../hooks/use-dropdown-active-item'
+import { getVersionWithLabels } from '../../utils/label'
+import { useHistoryContext } from '../../context/history-context'
 
 function LabelsList() {
-  const { t } = useTranslation()
-  const { labels, projectId, selection } = useHistoryContext()
   const { id: currentUserId } = useUserContext()
+  const { projectId, labels, selection, setSelection } = useHistoryContext()
+  const { activeDropdownItem, setActiveDropdownItem, closeDropdownForItem } =
+    useDropdownActiveItem()
 
   const versionWithLabels = useMemo(
     () => getVersionWithLabels(labels),
@@ -33,59 +30,24 @@ function LabelsList() {
     <>
       {versionWithLabels.map(({ version, labels }) => {
         const selected = selectedVersions.has(version)
-
-        // first label
-        const fromVTimestamp = isoToUnix(labels[labels.length - 1].created_at)
-        // most recent label
-        const toVTimestamp = isoToUnix(labels[0].created_at)
+        const dropdownActive = version === activeDropdownItem.item
 
         return (
-          <HistoryVersionDetails
+          <LabelListItem
             key={version}
-            fromV={version}
-            toV={version}
-            fromVTimestamp={fromVTimestamp}
-            toVTimestamp={toVTimestamp}
+            labels={labels}
+            version={version}
+            currentUserId={currentUserId}
+            projectId={projectId}
+            comparing={selection.comparing}
             selected={selected}
             selectable={!(singleVersionSelected && 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>
-              ))}
-            </div>
-            <LabelDropdown
-              id={version.toString()}
-              projectId={projectId}
-              version={version}
-              updateMetaEndTimestamp={toVTimestamp}
-              isComparing={selection.comparing}
-              isSelected={selected}
-            />
-          </HistoryVersionDetails>
+            setSelection={setSelection}
+            dropdownOpen={activeDropdownItem.isOpened && dropdownActive}
+            dropdownActive={dropdownActive}
+            setActiveDropdownItem={setActiveDropdownItem}
+            closeDropdownForItem={closeDropdownForItem}
+          />
         )
       })}
     </>

+ 1 - 1
services/web/frontend/js/features/history/components/history-root.tsx

@@ -11,7 +11,7 @@ const fileTreeContainer = document.getElementById('history-file-tree')
 function Main() {
   const { updatesInfo, error } = useHistoryContext()
 
-  let content = null
+  let content
   if (updatesInfo.loadingState === 'loadingInitial') {
     content = <LoadingSpinner />
   } else if (error) {

+ 36 - 0
services/web/frontend/js/features/history/hooks/use-dropdown-active-item.tsx

@@ -0,0 +1,36 @@
+import { Dispatch, SetStateAction, useCallback, useState } from 'react'
+import { LoadedUpdate, Version } from '../services/types/update'
+
+type DropdownItem = LoadedUpdate | Version
+
+export type ActiveDropdownValue = {
+  item: DropdownItem | null
+  isOpened: boolean
+}
+
+export type ActiveDropdown = {
+  activeDropdownItem: ActiveDropdownValue
+  setActiveDropdownItem: Dispatch<SetStateAction<ActiveDropdownValue>>
+  closeDropdownForItem: (item: DropdownItem) => void
+}
+
+function useDropdownActiveItem(): ActiveDropdown {
+  const [activeDropdownItem, setActiveDropdownItem] =
+    useState<ActiveDropdownValue>({
+      item: null,
+      isOpened: false,
+    })
+
+  const closeDropdownForItem = useCallback(
+    (item: DropdownItem) => setActiveDropdownItem({ item, isOpened: false }),
+    [setActiveDropdownItem]
+  )
+
+  return {
+    activeDropdownItem,
+    setActiveDropdownItem,
+    closeDropdownForItem,
+  }
+}
+
+export default useDropdownActiveItem

+ 12 - 0
services/web/frontend/js/features/history/utils/history-details.ts

@@ -71,3 +71,15 @@ export function isVersionSelected(
 
 export const getUpdateForVersion = (version: number, updates: LoadedUpdate[]) =>
   updates.find(update => update.toV === version)
+
+export const updateRangeForUpdate = (update: LoadedUpdate) => {
+  const { fromV, toV, meta } = update
+  const fromVTimestamp = meta.end_ts
+
+  return {
+    fromV,
+    toV,
+    fromVTimestamp,
+    toVTimestamp: fromVTimestamp,
+  }
+}