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

Merge pull request #20860 from overleaf/td-bs5-history-versions-list

Migrate history versions list to Bootstrap 5

GitOrigin-RevId: 4e006ad353cb11eadaefb2df41d2b8591003c664
Tim Down 1 год назад
Родитель
Сommit
4138f9707a
57 измененных файлов с 1837 добавлено и 417 удалено
  1. 2 1
      services/web/.stylelintrc.json
  2. 40 28
      services/web/frontend/js/features/history/components/change-list/add-label-modal.tsx
  3. 34 20
      services/web/frontend/js/features/history/components/change-list/all-history-list.tsx
  4. 57 10
      services/web/frontend/js/features/history/components/change-list/dropdown/actions-dropdown.tsx
  5. 4 16
      services/web/frontend/js/features/history/components/change-list/dropdown/compare-version-dropdown-content.tsx
  6. 1 1
      services/web/frontend/js/features/history/components/change-list/dropdown/compare-version-dropdown.tsx
  7. 13 1
      services/web/frontend/js/features/history/components/change-list/dropdown/history-dropdown.tsx
  8. 11 5
      services/web/frontend/js/features/history/components/change-list/dropdown/menu-item/add-label.tsx
  9. 12 8
      services/web/frontend/js/features/history/components/change-list/dropdown/menu-item/compare-dropdown-item.tsx
  10. 12 16
      services/web/frontend/js/features/history/components/change-list/dropdown/menu-item/compare.tsx
  11. 12 4
      services/web/frontend/js/features/history/components/change-list/dropdown/menu-item/download.tsx
  12. 15 4
      services/web/frontend/js/features/history/components/change-list/dropdown/menu-item/restore-project.tsx
  13. 1 1
      services/web/frontend/js/features/history/components/change-list/history-version-details.tsx
  14. 5 1
      services/web/frontend/js/features/history/components/change-list/history-version.tsx
  15. 5 1
      services/web/frontend/js/features/history/components/change-list/label-list-item.tsx
  16. 15 3
      services/web/frontend/js/features/history/components/change-list/modal-error.tsx
  17. 120 106
      services/web/frontend/js/features/history/components/change-list/tag-tooltip.tsx
  18. 19 17
      services/web/frontend/js/features/history/components/diff-view/modals/restore-project-error-modal.tsx
  19. 24 23
      services/web/frontend/js/features/history/components/diff-view/modals/restore-project-modal.tsx
  20. 3 3
      services/web/frontend/js/features/project-list/components/table/project-tools/menu-items/copy-project-menu-item.tsx
  21. 3 3
      services/web/frontend/js/features/project-list/components/table/project-tools/menu-items/rename-project-menu-item.tsx
  22. 1 1
      services/web/frontend/js/features/ui/components/bootstrap-3/dropdown-menu-with-ref.tsx
  23. 2 2
      services/web/frontend/js/features/ui/components/bootstrap-3/dropdown-toggle-with-tooltip.tsx
  24. 52 0
      services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-toggle-with-tooltip.tsx
  25. 49 43
      services/web/frontend/js/features/ui/components/bootstrap-5/tag.tsx
  26. 12 0
      services/web/frontend/js/features/ui/components/ol/icons/ol-tag-icon.tsx
  27. 14 4
      services/web/frontend/js/features/ui/components/ol/ol-dropdown-menu-item.tsx
  28. 7 1
      services/web/frontend/js/features/ui/components/ol/ol-modal.tsx
  29. 3 2
      services/web/frontend/js/features/ui/components/ol/ol-overlay.tsx
  30. 1 1
      services/web/frontend/js/features/ui/components/ol/ol-popover.tsx
  31. 31 0
      services/web/frontend/js/features/ui/components/ol/ol-spinner.tsx
  32. 13 3
      services/web/frontend/js/features/ui/components/ol/ol-tag.tsx
  33. 3 0
      services/web/frontend/js/features/ui/components/types/dropdown-menu-props.ts
  34. 22 30
      services/web/frontend/js/shared/components/loading-spinner.tsx
  35. 3 1
      services/web/frontend/js/shared/components/system-message.tsx
  36. 23 12
      services/web/frontend/stories/ui/dropdown-menu.stories.tsx
  37. 3 3
      services/web/frontend/stories/ui/tag-bs3.stories.tsx
  38. 4 4
      services/web/frontend/stories/ui/tag-bs5.stories.tsx
  39. 11 7
      services/web/frontend/stylesheets/app/editor/history-react.less
  40. 39 0
      services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss
  41. 3 0
      services/web/frontend/stylesheets/bootstrap-5/base/links.scss
  42. 0 4
      services/web/frontend/stylesheets/bootstrap-5/base/typography.scss
  43. 1 0
      services/web/frontend/stylesheets/bootstrap-5/components/all.scss
  44. 4 0
      services/web/frontend/stylesheets/bootstrap-5/components/badge.scss
  45. 6 6
      services/web/frontend/stylesheets/bootstrap-5/components/button.scss
  46. 10 0
      services/web/frontend/stylesheets/bootstrap-5/components/close-button.scss
  47. 12 0
      services/web/frontend/stylesheets/bootstrap-5/components/dropdown-menu.scss
  48. 14 0
      services/web/frontend/stylesheets/bootstrap-5/components/loading-spinner.scss
  49. 1 0
      services/web/frontend/stylesheets/bootstrap-5/components/popover.scss
  50. 0 6
      services/web/frontend/stylesheets/bootstrap-5/components/system-messages.scss
  51. 1 0
      services/web/frontend/stylesheets/bootstrap-5/pages/all.scss
  52. 383 0
      services/web/frontend/stylesheets/bootstrap-5/pages/editor/history.scss
  53. 8 3
      services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar.scss
  54. 2 10
      services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss
  55. 5 0
      services/web/frontend/stylesheets/components/loading-spinner.less
  56. 684 0
      services/web/test/frontend/features/history/components/change-list-bs5.spec.tsx
  57. 2 2
      services/web/test/frontend/features/history/components/change-list.spec.tsx

+ 2 - 1
services/web/.stylelintrc.json

@@ -4,6 +4,7 @@
     "function-url-quotes": null,
     "no-descending-specificity": null,
     "scss/at-extend-no-missing-placeholder": null,
-    "scss/operator-no-newline-after": null
+    "scss/operator-no-newline-after": null,
+    "property-no-vendor-prefix": [true, { "ignoreProperties": ["mask-image"] }]
   }
 }

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

@@ -1,8 +1,15 @@
 import { useTranslation } from 'react-i18next'
 import { useEffect, useState } from 'react'
-import { Modal, FormGroup, FormControl } from 'react-bootstrap'
+import OLForm from '@/features/ui/components/ol/ol-form'
+import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
 import ModalError from './modal-error'
-import AccessibleModal from '../../../../shared/components/accessible-modal'
+import OLModal, {
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
+import OLButton from '@/features/ui/components/ol/ol-button'
 import useAsync from '../../../../shared/hooks/use-async'
 import useAbortController from '../../../../shared/hooks/use-abort-controller'
 import useAddOrRemoveLabels from '../../hooks/use-add-or-remove-labels'
@@ -11,6 +18,7 @@ import { addLabel } from '../../services/api'
 import { Label } from '../../services/types/label'
 import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
 import { debugConsole } from '@/utils/debugging'
+import OLFormControl from '@/features/ui/components/ol/ol-form-control'
 
 type AddLabelModalProps = {
   show: boolean
@@ -71,51 +79,55 @@ function AddLabelModal({ show, setShow, version }: AddLabelModalProps) {
   }
 
   return (
-    <AccessibleModal
+    <OLModal
       show={show}
       onExited={handleModalExited}
       onHide={() => setShow(false)}
       id="add-history-label"
     >
-      <Modal.Header>
-        <Modal.Title>{t('history_add_label')}</Modal.Title>
-      </Modal.Header>
-      <form onSubmit={handleSubmit}>
-        <Modal.Body>
+      <OLModalHeader>
+        <OLModalTitle>{t('history_add_label')}</OLModalTitle>
+      </OLModalHeader>
+      <OLForm onSubmit={handleSubmit}>
+        <OLModalBody>
           {isError && <ModalError error={responseError} />}
-          <FormGroup>
-            <input
+          <OLFormGroup>
+            <OLFormControl
               ref={autoFocusedRef}
-              className="form-control"
               type="text"
               placeholder={t('history_new_label_name')}
               required
               value={comment}
-              onChange={(
-                e: React.ChangeEvent<HTMLInputElement & FormControl>
-              ) => setComment(e.target.value)}
+              onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
+                setComment(e.target.value)
+              }
             />
-          </FormGroup>
-        </Modal.Body>
-        <Modal.Footer>
-          <button
-            type="button"
-            className="btn btn-secondary"
+          </OLFormGroup>
+        </OLModalBody>
+        <OLModalFooter>
+          <OLButton
+            variant="secondary"
             disabled={isLoading}
             onClick={() => setShow(false)}
           >
             {t('cancel')}
-          </button>
-          <button
+          </OLButton>
+          <OLButton
             type="submit"
-            className="btn btn-primary"
+            variant="primary"
             disabled={isLoading || !comment.length}
+            isLoading={isLoading}
+            bs3Props={{
+              loading: isLoading
+                ? t('history_adding_label')
+                : t('history_add_label'),
+            }}
           >
-            {isLoading ? t('history_adding_label') : t('history_add_label')}
-          </button>
-        </Modal.Footer>
-      </form>
-    </AccessibleModal>
+            {t('history_add_label')}
+          </OLButton>
+        </OLModalFooter>
+      </OLForm>
+    </OLModal>
   )
 }
 

+ 34 - 20
services/web/frontend/js/features/history/components/change-list/all-history-list.tsx

@@ -8,7 +8,8 @@ import { useUserContext } from '../../../../shared/context/user-context'
 import useDropdownActiveItem from '../../hooks/use-dropdown-active-item'
 import { useHistoryContext } from '../../context/history-context'
 import { useEditorContext } from '../../../../shared/context/editor-context'
-import { Overlay, Popover } from 'react-bootstrap'
+import OLPopover from '@/features/ui/components/ol/ol-popover'
+import OLOverlay from '@/features/ui/components/ol/ol-overlay'
 import Close from '@/shared/components/close'
 import { Trans, useTranslation } from 'react-i18next'
 import MaterialIcon from '@/shared/components/material-icon'
@@ -173,18 +174,29 @@ function AllHistoryList() {
 
   if (showHistoryTutorial) {
     popover = (
-      <Overlay
-        placement="left"
+      <OLOverlay
+        placement="left-start"
         show={showHistoryTutorial}
         rootClose
         onHide={hidePopover}
         // using scrollerRef to position the popover in the middle of the viewport
-        target={scrollerRef.current ?? undefined}
-        shouldUpdatePosition
+        target={scrollerRef.current}
+        // Only used in Bootstrap 5. In Bootstrap 3 this is done with CSS.
+        popperConfig={{
+          modifiers: [
+            {
+              name: 'offset',
+              options: {
+                offset: [10, 10],
+              },
+            },
+          ],
+        }}
+        bs3Props={{ shouldUpdatePosition: true }}
       >
-        <Popover
-          id="popover-toolbar-overflow"
-          arrowOffsetTop={10}
+        <OLPopover
+          id="popover-react-history-tutorial"
+          bs3Props={{ arrowOffsetTop: 10 }}
           title={
             <span>
               {t('react_history_tutorial_title')}{' '}
@@ -212,23 +224,23 @@ function AllHistoryList() {
               <a href="https://www.overleaf.com/learn/latex/Using_the_History_feature" />, // eslint-disable-line jsx-a11y/anchor-has-content, react/jsx-key
             ]}
           />
-        </Popover>
-      </Overlay>
+        </OLPopover>
+      </OLOverlay>
     )
   } else if (showRestorePromo) {
     popover = (
-      <Overlay
-        placement="left"
+      <OLOverlay
+        placement="left-start"
         show={showRestorePromo}
         rootClose
         onHide={hidePopover}
         // using scrollerRef to position the popover in the middle of the viewport
-        target={scrollerRef.current ?? undefined}
-        shouldUpdatePosition
+        target={scrollerRef.current}
+        bs3Props={{ shouldUpdatePosition: true }}
       >
-        <Popover
-          id="popover-toolbar-overflow"
-          arrowOffsetTop={10}
+        <OLPopover
+          id="popover-history-restore-promo"
+          bs3Props={{ arrowOffsetTop: 10 }}
           title={
             <span>
               {t('history_restore_promo_title')}
@@ -255,8 +267,8 @@ function AllHistoryList() {
               />,
             ]}
           />
-        </Popover>
-      </Overlay>
+        </OLPopover>
+      </OLOverlay>
     )
   }
 
@@ -330,7 +342,9 @@ function AllHistoryList() {
       {showNonOwnerPaywall ? <NonOwnerPaywallPrompt /> : null}
       {updatesLoadingState === 'loadingInitial' ||
       updatesLoadingState === 'loadingUpdates' ? (
-        <LoadingSpinner />
+        <div className="history-all-versions-loading">
+          <LoadingSpinner />
+        </div>
       ) : null}
     </div>
   )

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

@@ -1,9 +1,15 @@
-import { useRef, useEffect, ReactNode } from 'react'
-import { Dropdown } from 'react-bootstrap'
-import DropdownToggleWithTooltip from '../../../../../shared/components/dropdown/dropdown-toggle-with-tooltip'
-import DropdownMenuWithRef from '../../../../../shared/components/dropdown/dropdown-menu-with-ref'
+import React, { useRef, useEffect, ReactNode } from 'react'
+import { Dropdown as BS3Dropdown } from 'react-bootstrap'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import {
+  Dropdown,
+  DropdownMenu,
+} from '@/features/ui/components/bootstrap-5/dropdown-menu'
+import BS3DropdownToggleWithTooltip from '../../../../ui/components/bootstrap-3/dropdown-toggle-with-tooltip'
+import BS5DropdownToggleWithTooltip from '@/features/ui/components/bootstrap-5/dropdown-toggle-with-tooltip'
+import DropdownMenuWithRef from '../../../../ui/components/bootstrap-3/dropdown-menu-with-ref'
 
-type DropdownMenuProps = {
+type ActionDropdownProps = {
   id: string
   children: React.ReactNode
   parentSelector?: string
@@ -13,7 +19,7 @@ type DropdownMenuProps = {
   setIsOpened: (isOpened: boolean) => void
 }
 
-function ActionsDropdown({
+function BS3ActionsDropdown({
   id,
   children,
   parentSelector,
@@ -21,7 +27,7 @@ function ActionsDropdown({
   iconTag,
   setIsOpened,
   toolTipDescription,
-}: DropdownMenuProps) {
+}: ActionDropdownProps) {
   const menuRef = useRef<HTMLElement>()
 
   // handle the placement of the dropdown above or below the toggle button
@@ -47,14 +53,14 @@ function ActionsDropdown({
   })
 
   return (
-    <Dropdown
+    <BS3Dropdown
       id={`history-version-dropdown-${id}`}
       pullRight
       open={isOpened}
       onToggle={open => setIsOpened(open)}
       className="pull-right"
     >
-      <DropdownToggleWithTooltip
+      <BS3DropdownToggleWithTooltip
         bsRole="toggle"
         className="history-version-dropdown-menu-btn"
         isOpened={isOpened}
@@ -65,7 +71,7 @@ function ActionsDropdown({
         }}
       >
         {iconTag}
-      </DropdownToggleWithTooltip>
+      </BS3DropdownToggleWithTooltip>
       <DropdownMenuWithRef
         bsRole="menu"
         className="history-version-dropdown-menu"
@@ -73,8 +79,49 @@ function ActionsDropdown({
       >
         {children}
       </DropdownMenuWithRef>
+    </BS3Dropdown>
+  )
+}
+
+function BS5ActionsDropdown({
+  id,
+  children,
+  isOpened,
+  iconTag,
+  setIsOpened,
+  toolTipDescription,
+}: Omit<ActionDropdownProps, 'parentSelector'>) {
+  return (
+    <Dropdown
+      align="end"
+      className="float-end"
+      show={isOpened}
+      onToggle={open => setIsOpened(open)}
+    >
+      <BS5DropdownToggleWithTooltip
+        id={`history-version-dropdown-${id}`}
+        className="history-version-dropdown-menu-btn"
+        aria-label={toolTipDescription}
+        toolTipDescription={toolTipDescription}
+        overlayTriggerProps={{ placement: 'bottom' }}
+        tooltipProps={{ hidden: isOpened }}
+      >
+        {iconTag}
+      </BS5DropdownToggleWithTooltip>
+      <DropdownMenu className="history-version-dropdown-menu">
+        {children}
+      </DropdownMenu>
     </Dropdown>
   )
 }
 
+function ActionsDropdown(props: ActionDropdownProps) {
+  return (
+    <BootstrapVersionSwitcher
+      bs3={<BS3ActionsDropdown {...props} />}
+      bs5={<BS5ActionsDropdown {...props} />}
+    />
+  )
+}
+
 export default ActionsDropdown

+ 4 - 16
services/web/frontend/js/features/history/components/change-list/dropdown/compare-version-dropdown-content.tsx

@@ -40,10 +40,7 @@ function CompareVersionDropdownContentAllHistory({
         closeDropdown={closeDropdown}
         text={t('history_compare_up_to_this_version')}
         icon={
-          <MaterialIcon
-            type="align_start"
-            className="history-dropdown-icon p-1"
-          />
+          <MaterialIcon type="align_start" className="history-dropdown-icon" />
         }
       />
       <CompareDropDownItem
@@ -56,10 +53,7 @@ function CompareVersionDropdownContentAllHistory({
         closeDropdown={closeDropdown}
         text={t('history_compare_from_this_version')}
         icon={
-          <MaterialIcon
-            type="align_end"
-            className="history-dropdown-icon p-1"
-          />
+          <MaterialIcon type="align_end" className="history-dropdown-icon" />
         }
       />
     </>
@@ -100,10 +94,7 @@ function CompareVersionDropdownContentLabelsList({
         closeDropdown={closeDropdownLabels}
         text={t('history_compare_up_to_this_version')}
         icon={
-          <MaterialIcon
-            type="align_start"
-            className="history-dropdown-icon p-1"
-          />
+          <MaterialIcon type="align_start" className="history-dropdown-icon" />
         }
       />
       <CompareDropDownItem
@@ -116,10 +107,7 @@ function CompareVersionDropdownContentLabelsList({
         closeDropdown={closeDropdownLabels}
         text={t('history_compare_from_this_version')}
         icon={
-          <MaterialIcon
-            type="align_end"
-            className="history-dropdown-icon p-1"
-          />
+          <MaterialIcon type="align_end" className="history-dropdown-icon" />
         }
       />
     </>

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

@@ -27,7 +27,7 @@ function CompareVersionDropdown({
         <MaterialIcon
           type="align_space_even"
           className="history-dropdown-icon"
-          accessibilityLabel="compare drop down"
+          accessibilityLabel={t('compare')}
         />
       }
     >

+ 13 - 1
services/web/frontend/js/features/history/components/change-list/dropdown/history-dropdown.tsx

@@ -1,6 +1,8 @@
 import ActionsDropdown from './actions-dropdown'
 import Icon from '../../../../../shared/components/icon'
 import { useTranslation } from 'react-i18next'
+import MaterialIcon from '@/shared/components/material-icon'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 
 type HistoryDropdownProps = {
   children: React.ReactNode
@@ -23,7 +25,17 @@ function HistoryDropdown({
       toolTipDescription={t('more_actions')}
       setIsOpened={setIsOpened}
       iconTag={
-        <Icon type="ellipsis-v" accessibilityLabel={t('more_actions')} />
+        <BootstrapVersionSwitcher
+          bs3={
+            <Icon type="ellipsis-v" accessibilityLabel={t('more_actions')} />
+          }
+          bs5={
+            <MaterialIcon
+              type="more_vert"
+              accessibilityLabel={t('more_actions')}
+            />
+          }
+        />
       }
       parentSelector="[data-history-version-list-container]"
     >

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

@@ -1,7 +1,7 @@
 import { useState } from 'react'
 import { useTranslation } from 'react-i18next'
-import { MenuItem } from 'react-bootstrap'
-import Icon from '../../../../../../shared/components/icon'
+import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
+import OLTagIcon from '@/features/ui/components/ol/icons/ol-tag-icon'
 import AddLabelModal from '../../add-label-modal'
 
 type DownloadProps = {
@@ -26,9 +26,15 @@ function AddLabel({
 
   return (
     <>
-      <MenuItem onClick={handleClick} {...props}>
-        <Icon type="tag" fw /> {t('history_label_this_version')}
-      </MenuItem>
+      <OLDropdownMenuItem
+        onClick={handleClick}
+        leadingIcon={<OLTagIcon />}
+        as="button"
+        className="dropdown-item-material-icon-small"
+        {...props}
+      >
+        {t('history_label_this_version')}
+      </OLDropdownMenuItem>
       <AddLabelModal
         show={showModal}
         setShow={setShowModal}

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

@@ -1,8 +1,7 @@
-import Icon from '../../../../../../shared/components/icon'
 import { useHistoryContext } from '../../../../context/history-context'
 import { UpdateRange } from '../../../../services/types/update'
 import { ReactNode } from 'react'
-import { Button, MenuItem } from 'react-bootstrap'
+import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
 
 type CompareProps = {
   comparisonRange: UpdateRange
@@ -15,12 +14,12 @@ function CompareDropDownItem({
   comparisonRange,
   text,
   closeDropdown,
-  icon = <Icon type="exchange" fw />,
+  icon,
   ...props
 }: CompareProps) {
   const { setSelection } = useHistoryContext()
 
-  const handleCompareVersion = (e: React.MouseEvent<Button>) => {
+  const handleCompareVersion = (e: React.MouseEvent) => {
     e.stopPropagation()
     closeDropdown()
 
@@ -33,10 +32,15 @@ function CompareDropDownItem({
   }
 
   return (
-    <MenuItem {...props} onClick={handleCompareVersion}>
-      {icon}
-      <span className="">{text}</span>
-    </MenuItem>
+    <OLDropdownMenuItem
+      {...props}
+      leadingIcon={icon}
+      as="button"
+      onClick={handleCompareVersion}
+      className="dropdown-item-material-icon-small"
+    >
+      {text}
+    </OLDropdownMenuItem>
   )
 }
 

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

@@ -1,13 +1,12 @@
-import Icon from '../../../../../../shared/components/icon'
 import { useHistoryContext } from '../../../../context/history-context'
 import { UpdateRange } from '../../../../services/types/update'
 import { ReactNode } from 'react'
-import Tooltip from '../../../../../../shared/components/tooltip'
-import { Button } from 'react-bootstrap'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 type CompareProps = {
   comparisonRange: UpdateRange
-  icon?: ReactNode
+  icon: ReactNode
   toolTipDescription?: string
   closeDropdown: () => void
 }
@@ -16,12 +15,11 @@ function Compare({
   comparisonRange,
   closeDropdown,
   toolTipDescription,
-  icon = <Icon type="exchange" fw />,
-  ...props
+  icon,
 }: CompareProps) {
   const { setSelection } = useHistoryContext()
 
-  const handleCompareVersion = (e: React.MouseEvent<Button>) => {
+  const handleCompareVersion = (e: { stopPropagation: () => void }) => {
     e.stopPropagation()
     closeDropdown()
 
@@ -34,20 +32,18 @@ function Compare({
   }
 
   return (
-    <Tooltip
+    <OLTooltip
       description={toolTipDescription}
       id="compare-btn"
       overlayProps={{ placement: 'left' }}
     >
-      <Button
-        bsStyle={null}
-        className="history-compare-btn"
-        onClick={handleCompareVersion}
-      >
-        <span className="sr-only">{toolTipDescription}</span>
+      <button className="history-compare-btn" onClick={handleCompareVersion}>
+        <span className={bsVersion({ bs3: 'sr-only', bs5: 'visually-hidden' })}>
+          {toolTipDescription}
+        </span>
         {icon}
-      </Button>
-    </Tooltip>
+      </button>
+    </OLTooltip>
   )
 }
 

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

@@ -1,5 +1,7 @@
 import { useTranslation } from 'react-i18next'
-import { MenuItem } from 'react-bootstrap'
+import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
+import MaterialIcon from '@/shared/components/material-icon'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 import Icon from '../../../../../../shared/components/icon'
 
 type DownloadProps = {
@@ -17,15 +19,21 @@ function Download({
   const { t } = useTranslation()
 
   return (
-    <MenuItem
+    <OLDropdownMenuItem
       href={`/project/${projectId}/version/${version}/zip`}
       download={`${projectId}_v${version}.zip`}
       rel="noreferrer"
       onClick={closeDropdown}
+      leadingIcon={
+        <BootstrapVersionSwitcher
+          bs3={<Icon type="cloud-download" fw />}
+          bs5={<MaterialIcon type="download" />}
+        />
+      }
       {...props}
     >
-      <Icon type="cloud-download" fw /> {t('history_download_this_version')}
-    </MenuItem>
+      {t('history_download_this_version')}
+    </OLDropdownMenuItem>
   )
 }
 

+ 15 - 4
services/web/frontend/js/features/history/components/change-list/dropdown/menu-item/restore-project.tsx

@@ -1,12 +1,14 @@
 import Icon from '@/shared/components/icon'
 import { useCallback, useState } from 'react'
-import { MenuItem } from 'react-bootstrap'
+import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
 import { useTranslation } from 'react-i18next'
 import { RestoreProjectModal } from '../../../diff-view/modals/restore-project-modal'
 import { useSplitTestContext } from '@/shared/context/split-test-context'
 import { useRestoreProject } from '@/features/history/context/hooks/use-restore-project'
 import withErrorBoundary from '@/infrastructure/error-boundary'
 import { RestoreProjectErrorModal } from '../../../diff-view/modals/restore-project-error-modal'
+import MaterialIcon from '@/shared/components/material-icon'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 
 type RestoreProjectProps = {
   projectId: string
@@ -44,9 +46,18 @@ const RestoreProject = ({
 
   return (
     <>
-      <MenuItem onClick={handleClick}>
-        <Icon type="undo" fw /> {t('restore_project_to_this_version')}
-      </MenuItem>
+      <OLDropdownMenuItem
+        as="button"
+        leadingIcon={
+          <BootstrapVersionSwitcher
+            bs3={<Icon type="undo" fw />}
+            bs5={<MaterialIcon type="undo" />}
+          />
+        }
+        onClick={handleClick}
+      >
+        {t('restore_project_to_this_version')}
+      </OLDropdownMenuItem>
       <RestoreProjectModal
         setShow={setShowModal}
         show={showModal}

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

@@ -35,7 +35,7 @@ function HistoryVersionDetails({
     // 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 clearfix', {
+      className={classnames('history-version-details', {
         'history-version-selected':
           selectionState === 'upperSelected' ||
           selectionState === 'lowerSelected' ||

+ 5 - 1
services/web/frontend/js/features/history/components/change-list/history-version.tsx

@@ -22,6 +22,7 @@ import { CompareVersionDropdownContentAllHistory } from './dropdown/compare-vers
 import FileRestoreChange from './file-restore-change'
 import HistoryResyncChange from './history-resync-change'
 import ProjectRestoreChange from './project-restore-change'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 type HistoryVersionProps = {
   update: LoadedUpdate
@@ -123,7 +124,10 @@ function HistoryVersion({
           )}
 
           {selectionState !== 'selected' && !faded ? (
-            <div data-testid="compare-icon-version" className="pull-right">
+            <div
+              data-testid="compare-icon-version"
+              className={bsVersion({ bs3: 'pull-right', bs5: 'float-end' })}
+            >
               {selectionState !== 'withinSelected' ? (
                 <CompareItems
                   updateRange={updateRange}

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

@@ -13,6 +13,7 @@ import { ItemSelectionState } from '../../utils/history-details'
 import CompareVersionDropdown from './dropdown/compare-version-dropdown'
 import { CompareVersionDropdownContentLabelsList } from './dropdown/compare-version-dropdown-content'
 import HistoryDropdownContent from '@/features/history/components/change-list/dropdown/history-dropdown-content'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 type LabelListItemProps = {
   version: Version
@@ -96,7 +97,10 @@ function LabelListItem({
         ) : null}
       </HistoryDropdown>
       {selectionState !== 'selected' ? (
-        <div data-testid="compare-icon-version" className="pull-right">
+        <div
+          data-testid="compare-icon-version"
+          className={bsVersion({ bs3: 'pull-right', bs5: 'float-end' })}
+        >
           {selectionState !== 'withinSelected' ? (
             <CompareItems
               updateRange={updateRange}

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

@@ -1,5 +1,5 @@
 import { useTranslation } from 'react-i18next'
-import { Alert } from 'react-bootstrap'
+import OLNotification from '@/features/ui/components/ol/ol-notification'
 
 // Using this workaround due to inconsistent and improper error responses from the server
 type ModalErrorProps = {
@@ -15,10 +15,22 @@ function ModalError({ error }: ModalErrorProps) {
   const { t } = useTranslation()
 
   if (error.response?.status === 400 && error.data?.message) {
-    return <Alert bsStyle="danger">{error.data.message}</Alert>
+    return (
+      <OLNotification
+        type="error"
+        content={error.data.message}
+        className="row-spaced-small"
+      />
+    )
   }
 
-  return <Alert bsStyle="danger">{t('generic_something_went_wrong')}</Alert>
+  return (
+    <OLNotification
+      type="error"
+      content={t('generic_something_went_wrong')}
+      className="row-spaced-small"
+    />
+  )
 }
 
 export default ModalError

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

@@ -1,9 +1,12 @@
-import { useState } from 'react'
+import { forwardRef, useState } from 'react'
 import { useTranslation } from 'react-i18next'
-import { Modal } from 'react-bootstrap'
-import Icon from '../../../../shared/components/icon'
-import Tooltip from '../../../../shared/components/tooltip'
-import AccessibleModal from '../../../../shared/components/accessible-modal'
+import OLModal, {
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
 import ModalError from './modal-error'
 import useAbortController from '../../../../shared/hooks/use-abort-controller'
 import useAsync from '../../../../shared/hooks/use-async'
@@ -15,118 +18,127 @@ import { LoadedLabel } from '../../services/types/label'
 import { debugConsole } from '@/utils/debugging'
 import { formatTimeBasedOnYear } from '@/features/utils/format-date'
 import { useEditorContext } from '@/shared/context/editor-context'
-import Tag from '@/shared/components/tag'
+import OLTag from '@/features/ui/components/ol/ol-tag'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import OLTagIcon from '@/features/ui/components/ol/icons/ol-tag-icon'
 
 type TagProps = {
   label: LoadedLabel
   currentUserId: string
 }
 
-function ChangeTag({ label, currentUserId, ...props }: TagProps) {
-  const { isProjectOwner } = useEditorContext()
-
-  const { t } = useTranslation()
-  const [showDeleteModal, setShowDeleteModal] = useState(false)
-  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
-    : null
+const ChangeTag = forwardRef<HTMLElement, TagProps>(
+  ({ label, currentUserId, ...props }: TagProps, ref) => {
+    const { isProjectOwner } = useEditorContext()
+
+    const { t } = useTranslation()
+    const [showDeleteModal, setShowDeleteModal] = useState(false)
+    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
+      : null
+
+    const showConfirmationModal = (e: React.MouseEvent) => {
+      e.stopPropagation()
+      setShowDeleteModal(true)
+    }
 
-  const showConfirmationModal = (e: React.MouseEvent) => {
-    e.stopPropagation()
-    setShowDeleteModal(true)
-  }
+    const handleModalExited = () => {
+      if (!isSuccess) return
 
-  const handleModalExited = () => {
-    if (!isSuccess) return
+      if (!isPseudoCurrentStateLabel) {
+        removeUpdateLabel(label)
+      }
 
-    if (!isPseudoCurrentStateLabel) {
-      removeUpdateLabel(label)
+      reset()
     }
 
-    reset()
-  }
-
-  const localDeleteHandler = () => {
-    runAsync(deleteLabel(projectId, label.id, signal))
-      .then(() => setShowDeleteModal(false))
-      .catch(debugConsole.error)
-  }
-
-  const responseError = error as unknown as {
-    response: Response
-    data?: {
-      message?: string
+    const localDeleteHandler = () => {
+      runAsync(deleteLabel(projectId, label.id, signal))
+        .then(() => setShowDeleteModal(false))
+        .catch(debugConsole.error)
     }
-  }
 
-  const showCloseButton = Boolean(
-    (isOwnedByCurrentUser || isProjectOwner) && !isPseudoCurrentStateLabel
-  )
+    const responseError = error as unknown as {
+      response: Response
+      data?: {
+        message?: string
+      }
+    }
 
-  return (
-    <>
-      <Tag
-        prepend={<Icon type="tag" fw />}
-        closeBtnProps={
-          showCloseButton
-            ? { 'aria-label': t('delete'), onClick: showConfirmationModal }
-            : undefined
-        }
-        className="history-version-badge"
-        data-testid="history-version-badge"
-        {...props}
-      >
-        {isPseudoCurrentStateLabel
-          ? t('history_label_project_current_state')
-          : label.comment}
-      </Tag>
-      {!isPseudoCurrentStateLabel && (
-        <AccessibleModal
-          show={showDeleteModal}
-          onExited={handleModalExited}
-          onHide={() => setShowDeleteModal(false)}
-          id="delete-history-label"
+    const showCloseButton = Boolean(
+      (isOwnedByCurrentUser || isProjectOwner) && !isPseudoCurrentStateLabel
+    )
+
+    return (
+      <>
+        <OLTag
+          ref={ref}
+          prepend={<OLTagIcon />}
+          closeBtnProps={
+            showCloseButton
+              ? { 'aria-label': t('delete'), onClick: showConfirmationModal }
+              : undefined
+          }
+          className="history-version-badge"
+          data-testid="history-version-badge"
+          {...props}
         >
-          <Modal.Header>
-            <Modal.Title>{t('history_delete_label')}</Modal.Title>
-          </Modal.Header>
-          <Modal.Body>
-            {isError && <ModalError error={responseError} />}
-            <p>
-              {t('history_are_you_sure_delete_label')}&nbsp;
-              <strong>"{label.comment}"</strong>?
-            </p>
-          </Modal.Body>
-          <Modal.Footer>
-            <button
-              type="button"
-              className="btn btn-secondary"
-              disabled={isLoading}
-              onClick={() => setShowDeleteModal(false)}
-            >
-              {t('cancel')}
-            </button>
-            <button
-              type="button"
-              className="btn btn-danger"
-              disabled={isLoading}
-              onClick={localDeleteHandler}
-            >
-              {isLoading
-                ? t('history_deleting_label')
-                : t('history_delete_label')}
-            </button>
-          </Modal.Footer>
-        </AccessibleModal>
-      )}
-    </>
-  )
-}
+          {isPseudoCurrentStateLabel
+            ? t('history_label_project_current_state')
+            : label.comment}
+        </OLTag>
+        {!isPseudoCurrentStateLabel && (
+          <OLModal
+            show={showDeleteModal}
+            onExited={handleModalExited}
+            onHide={() => setShowDeleteModal(false)}
+            id="delete-history-label"
+          >
+            <OLModalHeader>
+              <OLModalTitle>{t('history_delete_label')}</OLModalTitle>
+            </OLModalHeader>
+            <OLModalBody>
+              {isError && <ModalError error={responseError} />}
+              <p>
+                {t('history_are_you_sure_delete_label')}&nbsp;
+                <strong>"{label.comment}"</strong>?
+              </p>
+            </OLModalBody>
+            <OLModalFooter>
+              <OLButton
+                variant="secondary"
+                disabled={isLoading}
+                onClick={() => setShowDeleteModal(false)}
+              >
+                {t('cancel')}
+              </OLButton>
+              <OLButton
+                variant="danger"
+                disabled={isLoading}
+                isLoading={isLoading}
+                onClick={localDeleteHandler}
+                bs3Props={{
+                  loading: isLoading
+                    ? t('history_deleting_label')
+                    : t('history_delete_label'),
+                }}
+              >
+                {t('history_delete_label')}
+              </OLButton>
+            </OLModalFooter>
+          </OLModal>
+        )}
+      </>
+    )
+  }
+)
+
+ChangeTag.displayName = 'ChangeTag'
 
 type LabelBadgesProps = {
   showTooltip: boolean
@@ -145,13 +157,14 @@ function TagTooltip({ label, currentUserId, showTooltip }: LabelBadgesProps) {
       ? currentLabelData.user_display_name
       : t('anonymous')
 
-  return showTooltip && !isPseudoCurrentStateLabel ? (
-    <Tooltip
+  return !isPseudoCurrentStateLabel ? (
+    <OLTooltip
       description={
         <div className="history-version-label-tooltip">
           <div className="history-version-label-tooltip-row">
             <b className="history-version-label-tooltip-row-comment">
-              <Icon type="tag" fw />
+              <OLTagIcon />
+              &nbsp;
               {label.comment}
             </b>
           </div>
@@ -165,9 +178,10 @@ function TagTooltip({ label, currentUserId, showTooltip }: LabelBadgesProps) {
       }
       id={label.id}
       overlayProps={{ placement: 'left' }}
+      hidden={!showTooltip}
     >
       <ChangeTag label={label} currentUserId={currentUserId} />
-    </Tooltip>
+    </OLTooltip>
   ) : (
     <ChangeTag label={label} currentUserId={currentUserId} />
   )

+ 19 - 17
services/web/frontend/js/features/history/components/diff-view/modals/restore-project-error-modal.tsx

@@ -1,4 +1,10 @@
-import { Button, Modal } from 'react-bootstrap'
+import OLModal, {
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
+import OLButton from '@/features/ui/components/ol/ol-button'
 import { useTranslation } from 'react-i18next'
 
 export function RestoreProjectErrorModal({
@@ -9,26 +15,22 @@ export function RestoreProjectErrorModal({
   const { t } = useTranslation()
 
   return (
-    <Modal show onHide={resetErrorBoundary}>
-      <Modal.Header closeButton>
-        <Modal.Title>
+    <OLModal show onHide={resetErrorBoundary}>
+      <OLModalHeader closeButton>
+        <OLModalTitle>
           {t('an_error_occured_while_restoring_project')}
-        </Modal.Title>
-      </Modal.Header>
-      <Modal.Body>
+        </OLModalTitle>
+      </OLModalHeader>
+      <OLModalBody>
         {t(
           'there_was_a_problem_restoring_the_project_please_try_again_in_a_few_moments_or_contact_us'
         )}
-      </Modal.Body>
-      <Modal.Footer>
-        <Button
-          bsStyle={null}
-          className="btn-secondary"
-          onClick={resetErrorBoundary}
-        >
+      </OLModalBody>
+      <OLModalFooter>
+        <OLButton variant="secondary" onClick={resetErrorBoundary}>
           {t('close')}
-        </Button>
-      </Modal.Footer>
-    </Modal>
+        </OLButton>
+      </OLModalFooter>
+    </OLModal>
   )
 }

+ 24 - 23
services/web/frontend/js/features/history/components/diff-view/modals/restore-project-modal.tsx

@@ -1,7 +1,12 @@
-import AccessibleModal from '@/shared/components/accessible-modal'
+import OLModal, {
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
 import { formatDate } from '@/utils/dates'
 import { useCallback } from 'react'
-import { Button, Modal } from 'react-bootstrap'
+import OLButton from '@/features/ui/components/ol/ol-button'
 import { useTranslation } from 'react-i18next'
 
 type RestoreProjectModalProps = {
@@ -26,35 +31,31 @@ export const RestoreProjectModal = ({
   }, [setShow])
 
   return (
-    <AccessibleModal onHide={() => setShow(false)} show={show}>
-      <Modal.Header>
-        <Modal.Title>{t('restore_this_version')}</Modal.Title>
-      </Modal.Header>
-      <Modal.Body>
+    <OLModal onHide={() => setShow(false)} show={show}>
+      <OLModalHeader>
+        <OLModalTitle>{t('restore_this_version')}</OLModalTitle>
+      </OLModalHeader>
+      <OLModalBody>
         <p>
           {t('your_current_project_will_revert_to_the_version_from_time', {
             timestamp: formatDate(endTimestamp),
           })}
         </p>
-      </Modal.Body>
-      <Modal.Footer>
-        <Button
-          className="btn btn-secondary"
-          bsStyle={null}
-          onClick={onCancel}
-          disabled={isRestoring}
-        >
+      </OLModalBody>
+      <OLModalFooter>
+        <OLButton variant="secondary" onClick={onCancel} disabled={isRestoring}>
           {t('cancel')}
-        </Button>
-        <Button
-          className="btn btn-primary"
-          bsStyle={null}
+        </OLButton>
+        <OLButton
+          variant="primary"
           onClick={onRestore}
           disabled={isRestoring}
+          isLoading={isRestoring}
+          bs3Props={{ loading: isRestoring ? t('restoring') : t('restore') }}
         >
-          {isRestoring ? t('restoring') : t('restore')}
-        </Button>
-      </Modal.Footer>
-    </AccessibleModal>
+          {t('restore')}
+        </OLButton>
+      </OLModalFooter>
+    </OLModal>
   )
 }

+ 3 - 3
services/web/frontend/js/features/project-list/components/table/project-tools/menu-items/copy-project-menu-item.tsx

@@ -1,6 +1,6 @@
 import { memo, useCallback, useState } from 'react'
 import { useTranslation } from 'react-i18next'
-import OlDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
+import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
 import CloneProjectModal from '../../../../../clone-project-modal/components/clone-project-modal'
 import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
 import { useProjectListContext } from '../../../../context/project-list-context'
@@ -64,9 +64,9 @@ function CopyProjectMenuItem() {
 
   return (
     <>
-      <OlDropdownMenuItem onClick={handleOpenModal} as="button" tabIndex={-1}>
+      <OLDropdownMenuItem onClick={handleOpenModal} as="button" tabIndex={-1}>
         {t('make_a_copy')}
-      </OlDropdownMenuItem>
+      </OLDropdownMenuItem>
       <CloneProjectModal
         show={showModal}
         handleHide={handleCloseModal}

+ 3 - 3
services/web/frontend/js/features/project-list/components/table/project-tools/menu-items/rename-project-menu-item.tsx

@@ -2,7 +2,7 @@ import { memo, useCallback, useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import { useProjectListContext } from '../../../../context/project-list-context'
 import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
-import OlDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
+import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
 import RenameProjectModal from '../../../modals/rename-project-modal'
 
 function RenameProjectMenuItem() {
@@ -34,9 +34,9 @@ function RenameProjectMenuItem() {
 
   return (
     <>
-      <OlDropdownMenuItem onClick={handleOpenModal} as="button" tabIndex={-1}>
+      <OLDropdownMenuItem onClick={handleOpenModal} as="button" tabIndex={-1}>
         {t('rename')}
-      </OlDropdownMenuItem>
+      </OLDropdownMenuItem>
       <RenameProjectModal
         handleCloseModal={handleCloseModal}
         showModal={showModal}

+ 1 - 1
services/web/frontend/js/shared/components/dropdown/dropdown-menu-with-ref.tsx → services/web/frontend/js/features/ui/components/bootstrap-3/dropdown-menu-with-ref.tsx

@@ -2,7 +2,7 @@ import { forwardRef, SyntheticEvent } from 'react'
 import classnames from 'classnames'
 import RootCloseWrapper from 'react-overlays/lib/RootCloseWrapper'
 import { DropdownProps } from 'react-bootstrap'
-import { MergeAndOverride } from '../../../../../types/utils'
+import { MergeAndOverride } from '../../../../../../types/utils'
 
 type DropdownMenuWithRefProps = MergeAndOverride<
   Pick<DropdownProps, 'bsClass' | 'open' | 'pullRight' | 'onClose'>,

+ 2 - 2
services/web/frontend/js/shared/components/dropdown/dropdown-toggle-with-tooltip.tsx → services/web/frontend/js/features/ui/components/bootstrap-3/dropdown-toggle-with-tooltip.tsx

@@ -1,8 +1,8 @@
 import { forwardRef } from 'react'
-import Tooltip from '../tooltip'
+import Tooltip from '../../../../shared/components/tooltip'
 import classnames from 'classnames'
 import { DropdownProps } from 'react-bootstrap'
-import { MergeAndOverride } from '../../../../../types/utils'
+import { MergeAndOverride } from '../../../../../../types/utils'
 
 type CustomToggleProps = MergeAndOverride<
   Pick<DropdownProps, 'bsClass' | 'open'>,

+ 52 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-toggle-with-tooltip.tsx

@@ -0,0 +1,52 @@
+import { ReactNode, forwardRef } from 'react'
+import { BsPrefixRefForwardingComponent } from 'react-bootstrap-5/helpers'
+import type { DropdownToggleProps } from '@/features/ui/components/types/dropdown-menu-props'
+import {
+  DropdownToggle as BS5DropdownToggle,
+  OverlayTrigger,
+  OverlayTriggerProps,
+  Tooltip,
+} from 'react-bootstrap-5'
+import type { MergeAndOverride } from '../../../../../../types/utils'
+
+type DropdownToggleWithTooltipProps = MergeAndOverride<
+  DropdownToggleProps,
+  {
+    children: ReactNode
+    overlayTriggerProps?: Omit<OverlayTriggerProps, 'overlay' | 'children'>
+    toolTipDescription: string
+    tooltipProps?: Omit<React.ComponentProps<typeof Tooltip>, 'children'>
+    'aria-label'?: string
+  }
+>
+const DropdownToggleWithTooltip = forwardRef<
+  BsPrefixRefForwardingComponent<'button', DropdownToggleProps>,
+  DropdownToggleWithTooltipProps
+>(
+  (
+    {
+      children,
+      toolTipDescription,
+      overlayTriggerProps,
+      tooltipProps,
+      id,
+      ...toggleProps
+    },
+    ref
+  ) => {
+    return (
+      <OverlayTrigger
+        overlay={<Tooltip {...tooltipProps}>{toolTipDescription}</Tooltip>}
+        {...overlayTriggerProps}
+      >
+        <BS5DropdownToggle {...toggleProps} ref={ref}>
+          {children}
+        </BS5DropdownToggle>
+      </OverlayTrigger>
+    )
+  }
+)
+
+DropdownToggleWithTooltip.displayName = 'DropdownToggleWithTooltip'
+
+export default DropdownToggleWithTooltip

+ 49 - 43
services/web/frontend/js/features/ui/components/bootstrap-5/tag.tsx

@@ -3,6 +3,7 @@ import { Badge, BadgeProps } from 'react-bootstrap-5'
 import MaterialIcon from '@/shared/components/material-icon'
 import { MergeAndOverride } from '../../../../../../types/utils'
 import classnames from 'classnames'
+import { forwardRef } from 'react'
 
 type TagProps = MergeAndOverride<
   BadgeProps,
@@ -13,50 +14,55 @@ type TagProps = MergeAndOverride<
   }
 >
 
-function Tag({
-  prepend,
-  children,
-  contentProps,
-  closeBtnProps,
-  className,
-  ...rest
-}: TagProps) {
-  const { t } = useTranslation()
+const Tag = forwardRef<HTMLElement, TagProps>(
+  (
+    { prepend, children, contentProps, closeBtnProps, className, ...rest },
+    ref
+  ) => {
+    const { t } = useTranslation()
 
-  const content = (
-    <>
-      {prepend && <span className="badge-prepend">{prepend}</span>}
-      <span className="badge-content">{children}</span>
-    </>
-  )
+    const content = (
+      <>
+        {prepend && <span className="badge-prepend">{prepend}</span>}
+        <span className="badge-content">{children}</span>
+      </>
+    )
 
-  return (
-    <Badge bg="light" className={classnames('badge-tag', className)} {...rest}>
-      {contentProps?.onClick ? (
-        <button
-          type="button"
-          className="badge-tag-content badge-tag-content-btn"
-          {...contentProps}
-        >
-          {content}
-        </button>
-      ) : (
-        <span className="badge-tag-content" {...contentProps}>
-          {content}
-        </span>
-      )}
-      {closeBtnProps && (
-        <button
-          type="button"
-          className="badge-close"
-          aria-label={t('remove_tag', { tagName: children })}
-          {...closeBtnProps}
-        >
-          <MaterialIcon className="badge-close-icon" type="close" />
-        </button>
-      )}
-    </Badge>
-  )
-}
+    return (
+      <Badge
+        ref={ref}
+        bg="light"
+        className={classnames('badge-tag', className)}
+        {...rest}
+      >
+        {contentProps?.onClick ? (
+          <button
+            type="button"
+            className="badge-tag-content badge-tag-content-btn"
+            {...contentProps}
+          >
+            {content}
+          </button>
+        ) : (
+          <span className="badge-tag-content" {...contentProps}>
+            {content}
+          </span>
+        )}
+        {closeBtnProps && (
+          <button
+            type="button"
+            className="badge-close"
+            aria-label={t('remove_tag', { tagName: children })}
+            {...closeBtnProps}
+          >
+            <MaterialIcon className="badge-close-icon" type="close" />
+          </button>
+        )}
+      </Badge>
+    )
+  }
+)
+
+Tag.displayName = 'Tag'
 
 export default Tag

+ 12 - 0
services/web/frontend/js/features/ui/components/ol/icons/ol-tag-icon.tsx

@@ -0,0 +1,12 @@
+import Icon from '@/shared/components/icon'
+import MaterialIcon from '@/shared/components/material-icon'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+
+export default function OLTagIcon() {
+  return (
+    <BootstrapVersionSwitcher
+      bs3={<Icon type="tag" fw />}
+      bs5={<MaterialIcon type="sell" />}
+    />
+  )
+}

+ 14 - 4
services/web/frontend/js/features/ui/components/ol/ol-dropdown-menu-item.tsx

@@ -3,16 +3,26 @@ import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu
 import { DropdownItemProps } from '@/features/ui/components/types/dropdown-menu-props'
 import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 
-type OlDropdownMenuItemProps = DropdownItemProps & {
+type OLDropdownMenuItemProps = DropdownItemProps & {
   bs3Props?: MenuItemProps
 }
 
-function OlDropdownMenuItem(props: OlDropdownMenuItemProps) {
+function OLDropdownMenuItem(props: OLDropdownMenuItemProps) {
   const { bs3Props, ...rest } = props
 
   const bs3MenuItemProps: MenuItemProps = {
-    children: rest.children,
+    children: rest.leadingIcon ? (
+      <>
+        {rest.leadingIcon}
+        &nbsp;
+        {rest.children}
+      </>
+    ) : (
+      rest.children
+    ),
     onClick: rest.onClick,
+    href: rest.href,
+    download: rest.download,
     ...bs3Props,
   }
 
@@ -24,4 +34,4 @@ function OlDropdownMenuItem(props: OlDropdownMenuItemProps) {
   )
 }
 
-export default OlDropdownMenuItem
+export default OLDropdownMenuItem

+ 7 - 1
services/web/frontend/js/features/ui/components/ol/ol-modal.tsx

@@ -47,6 +47,7 @@ export default function OLModal({ children, ...props }: OLModalProps) {
     bsSize: bs5Props.size,
     show: bs5Props.show,
     onHide: bs5Props.onHide,
+    onExited: bs5Props.onExited,
     backdrop: bs5Props.backdrop,
     animation: bs5Props.animation,
     id: bs5Props.id,
@@ -86,10 +87,15 @@ export function OLModalTitle({ children, ...props }: OLModalTitleProps) {
   const bs3ModalProps: BS3ModalTitleProps = {
     componentClass: bs5Props.as,
   }
+
   return (
     <BootstrapVersionSwitcher
       bs3={<BS3Modal.Title {...bs3ModalProps}>{children}</BS3Modal.Title>}
-      bs5={<BS5Modal.Title {...bs5Props}>{children}</BS5Modal.Title>}
+      bs5={
+        <BS5Modal.Title as="h2" {...bs5Props}>
+          {children}
+        </BS5Modal.Title>
+      }
     />
   )
 }

+ 3 - 2
services/web/frontend/js/features/ui/components/ol/ol-overlay.tsx

@@ -40,8 +40,9 @@ function OLOverlay(props: OLOverlayProps) {
     >
 
     for (const placement of bs3PlacementOptions) {
-      if (placement === bs5Props.placement) {
-        bs3OverlayProps.placement = bs5Props.placement
+      // BS5 has more placement options than BS3, such as "left-start", so these are mapped to "left" etc.
+      if (bs5Props.placement.startsWith(placement)) {
+        bs3OverlayProps.placement = placement
         break
       }
     }

+ 1 - 1
services/web/frontend/js/features/ui/components/ol/ol-popover.tsx

@@ -6,7 +6,7 @@ import {
 } from 'react-bootstrap'
 import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 
-type OLPopoverProps = PopoverProps & {
+type OLPopoverProps = Omit<PopoverProps, 'title'> & {
   title?: React.ReactNode
   bs3Props?: BS3PopoverProps
 }

+ 31 - 0
services/web/frontend/js/features/ui/components/ol/ol-spinner.tsx

@@ -0,0 +1,31 @@
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import Icon from '@/shared/components/icon'
+import { Spinner } from 'react-bootstrap-5'
+import classNames from 'classnames'
+
+export type OLSpinnerSize = 'sm' | 'lg'
+
+function OLSpinner({ size = 'sm' }: { size: OLSpinnerSize }) {
+  return (
+    <BootstrapVersionSwitcher
+      bs3={
+        <Icon
+          type="refresh"
+          fw
+          spin
+          className={classNames({ 'fa-2x': size === 'lg' })}
+        />
+      }
+      bs5={
+        <Spinner
+          size={size === 'sm' ? 'sm' : undefined}
+          animation="border"
+          aria-hidden="true"
+          role="status"
+        />
+      }
+    />
+  )
+}
+
+export default OLSpinner

+ 13 - 3
services/web/frontend/js/features/ui/components/ol/ol-tag.tsx

@@ -1,12 +1,14 @@
 import Tag from '@/features/ui/components/bootstrap-5/tag'
 import BS3Tag from '@/shared/components/tag'
 import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import { forwardRef } from 'react'
+import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
 
 type OLTagProps = React.ComponentProps<typeof Tag> & {
   bs3Props?: React.ComponentProps<typeof BS3Tag>
 }
 
-function OLTag(props: OLTagProps) {
+const OLTag = forwardRef<HTMLElement, OLTagProps>((props: OLTagProps, ref) => {
   const { bs3Props, ...rest } = props
 
   const bs3TagProps: React.ComponentProps<typeof BS3Tag> = {
@@ -14,15 +16,23 @@ function OLTag(props: OLTagProps) {
     prepend: rest.prepend,
     closeBtnProps: rest.closeBtnProps,
     className: rest.className,
+    onClick: rest.onClick,
+    onFocus: rest.onFocus,
+    onBlur: rest.onBlur,
+    onMouseOver: rest.onMouseOver,
+    onMouseOut: rest.onMouseOut,
+    ...getAriaAndDataProps(rest),
     ...bs3Props,
   }
 
   return (
     <BootstrapVersionSwitcher
       bs3={<BS3Tag {...bs3TagProps} />}
-      bs5={<Tag {...rest} />}
+      bs5={<Tag ref={ref} {...rest} />}
     />
   )
-}
+})
+
+OLTag.displayName = 'OLTag'
 
 export default OLTag

+ 3 - 0
services/web/frontend/js/features/ui/components/types/dropdown-menu-props.ts

@@ -42,6 +42,8 @@ export type DropdownItemProps = PropsWithChildren<{
   role?: string
   tabIndex?: number
   target?: string
+  download?: boolean | string
+  rel?: string
 }>
 
 export type DropdownToggleProps = PropsWithChildren<{
@@ -53,6 +55,7 @@ export type DropdownToggleProps = PropsWithChildren<{
   variant?: SplitButtonVariants
   as?: ElementType
   size?: 'sm' | 'lg' | undefined
+  'aria-label'?: string
 }>
 
 export type DropdownMenuProps = PropsWithChildren<{

+ 22 - 30
services/web/frontend/js/shared/components/loading-spinner.tsx

@@ -1,8 +1,9 @@
 import { useTranslation } from 'react-i18next'
-import Icon from './icon'
 import { useEffect, useState } from 'react'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import { Spinner } from 'react-bootstrap-5'
+import OLSpinner, {
+  OLSpinnerSize,
+} from '@/features/ui/components/ol/ol-spinner'
+import { isBootstrap5 } from '@/features/utils/bootstrap-5'
 import { setTimeout } from '@/utils/window'
 import classNames from 'classnames'
 
@@ -10,12 +11,14 @@ function LoadingSpinner({
   align,
   delay = 0,
   loadingText,
-  size,
+  size = 'sm',
+  className,
 }: {
   align?: 'left' | 'center'
   delay?: 0 | 500 // 500 is our standard delay
   loadingText?: string
-  size?: 'sm'
+  size?: OLSpinnerSize
+  className?: string
 }) {
   const { t } = useTranslation()
 
@@ -35,32 +38,19 @@ function LoadingSpinner({
     return null
   }
 
-  const alignmentClass =
-    align === 'left' ? 'align-items-start' : 'align-items-center'
+  const extraClasses = isBootstrap5()
+    ? [
+        'd-inline-flex',
+        align === 'left' ? 'align-items-start' : 'align-items-center',
+      ]
+    : null
 
   return (
-    <BootstrapVersionSwitcher
-      bs3={
-        <div className="loading">
-          <Icon type="refresh" fw spin />
-          &nbsp;
-          {loadingText || t('loading')}…
-        </div>
-      }
-      bs5={
-        <div className={classNames(`d-flex ${alignmentClass}`)}>
-          <Spinner
-            animation="border"
-            aria-hidden="true"
-            role="status"
-            className="align-self-center"
-            size={size}
-          />
-          &nbsp;
-          {loadingText || t('loading')}…
-        </div>
-      }
-    />
+    <div className={classNames('loading', className, extraClasses)}>
+      <OLSpinner size={size} />
+      &nbsp;
+      {loadingText || t('loading')}…
+    </div>
   )
 }
 
@@ -70,14 +60,16 @@ export function FullSizeLoadingSpinner({
   delay = 0,
   minHeight,
   loadingText,
+  size = 'sm',
 }: {
   delay?: 0 | 500
   minHeight?: string
   loadingText?: string
+  size?: OLSpinnerSize
 }) {
   return (
     <div className="full-size-loading-spinner-container" style={{ minHeight }}>
-      <LoadingSpinner loadingText={loadingText} delay={delay} />
+      <LoadingSpinner size={size} loadingText={loadingText} delay={delay} />
     </div>
   )
 }

+ 3 - 1
services/web/frontend/js/shared/components/system-message.tsx

@@ -18,7 +18,9 @@ function SystemMessage({ id, children }: SystemMessageProps) {
 
   return (
     <li className="system-message">
-      {id !== 'protected' ? <Close onDismiss={() => setHidden(true)} /> : null}
+      {id !== 'protected' ? (
+        <Close onDismiss={() => setHidden(true)} variant="dark" />
+      ) : null}
       {children}
     </li>
   )

+ 23 - 12
services/web/frontend/stories/ui/dropdown-menu.stories.tsx

@@ -5,6 +5,7 @@ import {
   DropdownHeader,
 } from '@/features/ui/components/bootstrap-5/dropdown-menu'
 import type { Meta } from '@storybook/react'
+import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
 
 type Args = React.ComponentProps<typeof DropdownMenu>
 
@@ -141,42 +142,52 @@ export const Icon = (args: Args) => {
   return (
     <DropdownMenu show>
       <li>
-        <DropdownItem
+        <OLDropdownMenuItem
           disabled={args.disabled}
           eventKey="1"
           href="#/action-1"
           leadingIcon="view_column_2"
         >
           Editor & PDF
-        </DropdownItem>
+        </OLDropdownMenuItem>
       </li>
       <li>
-        <DropdownItem
+        <OLDropdownMenuItem
           active
           eventKey="2"
           href="#/action-2"
           leadingIcon="terminal"
         >
           Editor only
-        </DropdownItem>
+        </OLDropdownMenuItem>
       </li>
       <li>
-        <DropdownItem
-          eventKey="2"
-          href="#/action-2"
+        <OLDropdownMenuItem
+          eventKey="3"
+          href="#/action-3"
           leadingIcon="picture_as_pdf"
         >
           PDF only
-        </DropdownItem>
+        </OLDropdownMenuItem>
       </li>
       <li>
-        <DropdownItem
-          eventKey="2"
-          href="#/action-2"
+        <OLDropdownMenuItem
+          eventKey="4"
+          href="#/action-4"
           leadingIcon="select_window"
         >
           PDF in separate tab
-        </DropdownItem>
+        </OLDropdownMenuItem>
+      </li>
+      <li>
+        <OLDropdownMenuItem
+          eventKey="5"
+          href="#/action-5"
+          leadingIcon="align_space_even"
+          className="dropdown-item-material-icon-small"
+        >
+          Small icon
+        </OLDropdownMenuItem>
       </li>
     </DropdownMenu>
   )

+ 3 - 3
services/web/frontend/stories/ui/tag-bs3.stories.tsx

@@ -1,4 +1,4 @@
-import Icon from '@/shared/components/icon'
+import OLTagIcon from '@/features/ui/components/ol/icons/ol-tag-icon'
 import BS3Tag from '@/shared/components/tag'
 import type { Meta, StoryObj } from '@storybook/react'
 
@@ -47,7 +47,7 @@ export const TagPrepend: Story = {
   render: args => {
     return (
       <div className="small">
-        <BS3Tag prepend={<Icon type="tag" fw />} {...args} />
+        <BS3Tag prepend={<OLTagIcon />} {...args} />
       </div>
     )
   },
@@ -58,7 +58,7 @@ export const TagWithCloseButton: Story = {
     return (
       <div className="small">
         <BS3Tag
-          prepend={<Icon type="tag" fw />}
+          prepend={<OLTagIcon />}
           closeBtnProps={{
             onClick: () => alert('Close triggered!'),
           }}

+ 4 - 4
services/web/frontend/stories/ui/tag-bs5.stories.tsx

@@ -1,4 +1,4 @@
-import Icon from '@/shared/components/icon'
+import OLTagIcon from '@/features/ui/components/ol/icons/ol-tag-icon'
 import Tag from '@/features/ui/components/bootstrap-5/tag'
 import type { Meta, StoryObj } from '@storybook/react'
 
@@ -41,7 +41,7 @@ export const TagDefault: Story = {
 
 export const TagPrepend: Story = {
   render: args => {
-    return <Tag prepend={<Icon type="tag" fw />} {...args} />
+    return <Tag prepend={<OLTagIcon />} {...args} />
   },
 }
 
@@ -49,7 +49,7 @@ export const TagWithCloseButton: Story = {
   render: args => {
     return (
       <Tag
-        prepend={<Icon type="tag" fw />}
+        prepend={<OLTagIcon />}
         closeBtnProps={{
           onClick: () => alert('Close triggered!'),
         }}
@@ -63,7 +63,7 @@ export const TagWithContentButtonAndCloseButton: Story = {
   render: args => {
     return (
       <Tag
-        prepend={<Icon type="tag" fw />}
+        prepend={<OLTagIcon />}
         contentProps={{
           onClick: () => alert('Content button clicked!'),
         }}

+ 11 - 7
services/web/frontend/stylesheets/app/editor/history-react.less

@@ -44,9 +44,14 @@ history-root {
     .doc-container {
       flex: 1;
       overflow-y: auto;
+      display: flex;
     }
   }
 
+  .doc-container .loading {
+    margin: 10rem auto auto;
+  }
+
   .change-list {
     display: flex;
     flex-direction: column;
@@ -102,6 +107,7 @@ history-root {
   }
 
   .history-version-details {
+    display: flow-root;
     padding-top: 8px;
     padding-bottom: 8px;
     position: relative;
@@ -240,20 +246,15 @@ history-root {
   }
 
   .loading {
-    padding-top: 10rem;
     font-family: @font-family-serif;
-    text-align: center;
   }
 
-  & > .loading {
-    flex: 1;
-  }
-
-  .history-all-versions-scroller .loading {
+  .history-all-versions-loading {
     position: sticky;
     bottom: 0;
     padding: @line-height-computed / 2 0;
     background-color: @gray-lightest;
+    text-align: center;
   }
 
   .history-version-saved-by {
@@ -271,8 +272,11 @@ history-root {
 
   .history-compare-btn,
   .history-version-dropdown-menu-btn {
+    .reset-button;
+
     @size: 30px;
     padding: 0;
+    border-radius: @btn-border-radius-large;
     width: @size;
     height: @size;
     line-height: 1;

+ 39 - 0
services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss

@@ -45,6 +45,20 @@
   }
 }
 
+@mixin action-button {
+  font-size: 0;
+  line-height: 1;
+  border-radius: 50%;
+  color: var(--content-primary);
+  background-color: transparent;
+
+  &:hover,
+  &:active,
+  &[aria-expanded='true'] {
+    background-color: rgb($neutral-90, 0.08);
+  }
+}
+
 @mixin reset-button() {
   padding: 0;
   cursor: pointer;
@@ -104,3 +118,28 @@
     transparent
   );
 }
+
+@mixin mask-image($gradient) {
+  // mask-image isn't supported without the -webkit prefix on all browsers we support yet
+  -webkit-mask-image: $gradient;
+  mask-image: $gradient;
+}
+
+@mixin premium-background {
+  background-image: var(--premium-gradient);
+}
+
+@mixin premium-text {
+  @include premium-background;
+
+  background-clip: text;
+  -webkit-text-fill-color: transparent;
+  color: var(--white); // Fallback
+  background-color: var(--blue-70); // Fallback
+}
+
+@mixin dark-bg {
+  --link-color: var(--link-color-dark);
+  --link-hover-color: var(--link-hover-color-dark);
+  --link-visited-color: var(--link-visited-color-dark);
+}

+ 3 - 0
services/web/frontend/stylesheets/bootstrap-5/base/links.scss

@@ -5,6 +5,9 @@
   --link-color: var(--link-ui);
   --link-hover-color: var(--link-ui-hover);
   --link-visited-color: var(--link-ui-visited);
+  --link-color-dark: var(--link-ui-dark);
+  --link-hover-color-dark: var(--link-ui-hover-dark);
+  --link-visited-color-dark: var(--link-ui-visited-dark);
 }
 
 a {

+ 0 - 4
services/web/frontend/stylesheets/bootstrap-5/base/typography.scss

@@ -74,10 +74,6 @@ samp {
   list-style-image: url('../../../../public/img/fa-check-green.svg');
 }
 
-.text-center {
-  text-align: center;
-}
-
 .text-muted {
   color: var(--content-disabled) !important;
 }

+ 1 - 0
services/web/frontend/stylesheets/bootstrap-5/components/all.scss

@@ -29,3 +29,4 @@
 @import 'pagination';
 @import 'loading-spinner';
 @import 'error-boundary';
+@import 'close-button';

+ 4 - 0
services/web/frontend/stylesheets/bootstrap-5/components/badge.scss

@@ -16,6 +16,10 @@ $max-width: 160px;
   margin-right: var(--spacing-02);
   display: flex;
   align-items: center;
+
+  .material-symbols {
+    font-size: inherit;
+  }
 }
 
 .badge-close {

+ 6 - 6
services/web/frontend/stylesheets/bootstrap-5/components/button.scss

@@ -164,14 +164,14 @@
 
   .loading-spinner-small {
     border-width: 0.2em;
-    height: 20px;
-    width: 20px;
+    height: 1.25rem;
+    width: 1.25rem;
   }
 
   .loading-spinner-large {
     border-width: 0.2em;
-    height: 24px;
-    width: 24px;
+    height: 1.5rem;
+    width: 1.5rem;
   }
 }
 
@@ -187,11 +187,11 @@
   justify-content: center;
 
   .icon-small {
-    font-size: 20px;
+    font-size: 1.25rem;
   }
 
   .icon-large {
-    font-size: 24px;
+    font-size: 1.5rem;
   }
 
   .spinner {

+ 10 - 0
services/web/frontend/stylesheets/bootstrap-5/components/close-button.scss

@@ -0,0 +1,10 @@
+// This is our own implementation because the Bootstrap close button requires more customization than is worthwhile
+.close {
+  @include reset-button;
+
+  color: var(--content-primary);
+
+  &.dark {
+    color: var(--content-primary-dark);
+  }
+}

+ 12 - 0
services/web/frontend/stylesheets/bootstrap-5/components/dropdown-menu.scss

@@ -167,3 +167,15 @@
 .dropdown-item-highlighted {
   background-color: var(--bg-light-secondary);
 }
+
+.dropdown-item-material-icon-small {
+  .material-symbols,
+  &.material-symbols {
+    font-size: var(--bs-body-font-size);
+
+    // Centre the symbol in a 20px-by-20px box
+    width: 20px;
+    line-height: 20px;
+    text-align: center;
+  }
+}

+ 14 - 0
services/web/frontend/stylesheets/bootstrap-5/components/loading-spinner.scss

@@ -1,3 +1,17 @@
+.loading {
+  .spinner-border-sm,
+  .spinner-border {
+    // Ensure the thickness of the spinner is independent of the font size of its container
+    font-size: var(--font-size-03);
+  }
+
+  // Adjust the small spinner to be 25% larger than Bootstrap's default in each dimension
+  .spinner-border-sm {
+    --bs-spinner-width: 1.25rem;
+    --bs-spinner-height: 1.25rem;
+  }
+}
+
 .full-size-loading-spinner-container {
   width: 100%;
   height: 100%;

+ 1 - 0
services/web/frontend/stylesheets/bootstrap-5/components/popover.scss

@@ -1,5 +1,6 @@
 .popover {
   @include shadow-md;
+  @include dark-bg;
 
   line-height: var(--line-height-02);
 }

+ 0 - 6
services/web/frontend/stylesheets/bootstrap-5/components/system-messages.scss

@@ -17,9 +17,3 @@
     }
   }
 }
-
-.system-message .close {
-  @include reset-button;
-
-  color: var(--content-primary-dark);
-}

+ 1 - 0
services/web/frontend/stylesheets/bootstrap-5/pages/all.scss

@@ -15,6 +15,7 @@
 @import 'editor/figure-modal';
 @import 'editor/review-panel';
 @import 'editor/chat';
+@import 'editor/history';
 @import 'subscription';
 @import 'editor/pdf';
 @import 'editor/compile-button';

+ 383 - 0
services/web/frontend/stylesheets/bootstrap-5/pages/editor/history.scss

@@ -0,0 +1,383 @@
+history-root {
+  height: 100%;
+  display: block;
+}
+
+// Adding !important to override the styling of overlays and popovers
+.history-popover .popover-arrow {
+  top: 20px !important;
+  transform: unset !important;
+}
+
+.history-react {
+  --history-change-list-padding: var(--spacing-06);
+
+  display: flex;
+  height: 100%;
+  background-color: var(--bg-light-primary);
+
+  .history-header {
+    @include body-sm;
+
+    height: 40px;
+    background-color: var(--bg-dark-secondary);
+    color: var(--content-primary-dark);
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    box-sizing: border-box;
+  }
+
+  .doc-panel {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+
+    .toolbar-container {
+      border-bottom: 1px solid var(--border-divider-dark);
+      padding: 0 var(--spacing-04);
+    }
+
+    .doc-container {
+      flex: 1;
+      overflow-y: auto;
+      display: flex;
+    }
+  }
+
+  .doc-container .loading {
+    margin: 10rem auto auto;
+  }
+
+  .change-list {
+    @include body-sm;
+
+    display: flex;
+    flex-direction: column;
+    width: 320px;
+    border-left: 1px solid var(--border-divider-dark);
+    box-sizing: content-box;
+  }
+
+  .toggle-switch-label {
+    flex: 1;
+
+    span {
+      display: block;
+    }
+  }
+
+  .history-version-list-container {
+    flex: 1;
+    overflow-y: auto;
+  }
+
+  .history-all-versions-scroller {
+    overflow-y: auto;
+    height: 100%;
+  }
+
+  .history-all-versions-container {
+    position: relative;
+  }
+
+  .history-versions-bottom {
+    position: absolute;
+    height: 8em;
+    bottom: 0;
+  }
+
+  .history-toggle-switch-container,
+  .history-version-day,
+  .history-version-details {
+    padding: 0 var(--history-change-list-padding);
+  }
+
+  .history-version-day {
+    background-color: white;
+    position: sticky;
+    z-index: 1;
+    top: 0;
+    display: block;
+    padding-top: var(--spacing-05);
+    padding-bottom: var(--spacing-02);
+    line-height: var(--line-height-02);
+  }
+
+  .history-version-details {
+    display: flow-root;
+    padding-top: var(--spacing-04);
+    padding-bottom: var(--spacing-04);
+    position: relative;
+
+    &.history-version-selectable {
+      cursor: pointer;
+
+      &:hover {
+        background-color: var(--bg-light-secondary);
+      }
+    }
+
+    &.history-version-selected {
+      background-color: var(--bg-accent-03);
+      border-left: var(--spacing-02) solid var(--green-50);
+      padding-left: calc(
+        var(--history-change-list-padding) - var(--spacing-02)
+      );
+    }
+
+    &.history-version-selected.history-version-selectable:hover {
+      background-color: rgb($green-70, 16%);
+      border-left: var(--spacing-02) solid var(--green-50);
+    }
+
+    &.history-version-within-selected {
+      background-color: var(--bg-light-secondary);
+      border-left: var(--spacing-02) solid var(--green-50);
+    }
+
+    &.history-version-within-selected:hover {
+      background-color: rgb($neutral-90, 8%);
+    }
+  }
+
+  .version-element-within-selected {
+    background-color: var(--bg-light-secondary);
+    border-left: var(--spacing-02) solid var(--green-50);
+  }
+
+  .version-element-selected {
+    background-color: var(--bg-accent-03);
+    border-left: var(--spacing-02) solid var(--green-50);
+  }
+
+  .history-version-metadata-time {
+    display: block;
+    margin-bottom: var(--spacing-02);
+    color: var(--content-primary);
+
+    &:last-child {
+      margin-bottom: initial;
+    }
+  }
+
+  .history-version-metadata-users,
+  .history-version-changes {
+    margin: 0;
+    padding: 0;
+    list-style: none;
+  }
+
+  .history-version-restore-file {
+    margin-bottom: var(--spacing-04);
+  }
+
+  .history-version-metadata-users {
+    display: inline;
+    vertical-align: bottom;
+
+    > li {
+      display: inline-flex;
+      align-items: center;
+      margin-right: var(--spacing-04);
+    }
+  }
+
+  .history-version-changes {
+    > li {
+      margin-bottom: var(--spacing-02);
+    }
+  }
+
+  .history-version-user-badge-color {
+    --badge-size: 8px;
+
+    display: inline-block;
+    width: var(--badge-size);
+    height: var(--badge-size);
+    margin-right: var(--spacing-02);
+    border-radius: 2px;
+  }
+
+  .history-version-user-badge-text {
+    overflow-wrap: anywhere;
+    flex: 1;
+  }
+
+  .history-version-day,
+  .history-version-change-action,
+  .history-version-metadata-users,
+  .history-version-origin,
+  .history-version-saved-by {
+    color: var(--content-secondary);
+  }
+
+  .history-version-change-action {
+    overflow-wrap: anywhere;
+  }
+
+  .history-version-change-doc {
+    color: var(--content-primary);
+    overflow-wrap: anywhere;
+    white-space: pre-wrap;
+  }
+
+  .history-version-divider-container {
+    padding: var(--spacing-03) var(--spacing-04);
+  }
+
+  .history-version-divider {
+    margin: 0;
+    border-color: var(--border-divider);
+  }
+
+  .history-version-badge {
+    margin-bottom: var(--spacing-02);
+    margin-right: var(--spacing-05);
+    height: unset;
+    white-space: normal;
+    overflow-wrap: anywhere;
+
+    .material-symbols {
+      font-size: inherit;
+    }
+  }
+
+  .history-version-label {
+    margin-bottom: var(--spacing-02);
+
+    &:last-child {
+      margin-bottom: initial;
+    }
+  }
+
+  .loading {
+    font-family: $font-family-serif;
+  }
+
+  .history-all-versions-loading {
+    position: sticky;
+    bottom: 0;
+    padding: var(--spacing-05) 0;
+    background-color: var(--bg-light-secondary);
+    text-align: center;
+  }
+
+  .history-version-saved-by {
+    .history-version-saved-by-label {
+      margin-right: var(--spacing-04);
+    }
+  }
+
+  .dropdown.open {
+    .history-version-dropdown-menu-btn {
+      background-color: rgb(var(--bg-dark-primary) 0.08);
+      box-shadow: initial;
+    }
+  }
+
+  .history-compare-btn,
+  .history-version-dropdown-menu-btn {
+    @include reset-button;
+    @include action-button;
+
+    padding: 0;
+    width: 30px;
+    height: 30px;
+  }
+
+  .history-loading-panel {
+    padding-top: 10rem;
+    font-family: $font-family-serif;
+    text-align: center;
+  }
+
+  .history-paywall-prompt {
+    padding: var(--history-change-list-padding);
+
+    .history-feature-list {
+      list-style: none;
+      padding-left: var(--spacing-04);
+
+      li {
+        margin-bottom: var(--spacing-06);
+      }
+    }
+
+    button {
+      width: 100%;
+    }
+  }
+
+  .history-version-faded .history-version-details {
+    max-height: 6em;
+
+    @include mask-image(linear-gradient(black 35%, transparent));
+
+    overflow: hidden;
+  }
+
+  .history-paywall-heading {
+    @include heading-sm;
+    @include premium-text;
+
+    font-family: inherit;
+    font-weight: 700;
+    margin-top: var(--spacing-08);
+  }
+
+  .history-content {
+    padding: var(--spacing-05);
+  }
+}
+
+.history-version-label-tooltip {
+  padding: 6px;
+  text-align: initial;
+
+  .history-version-label-tooltip-row {
+    margin-bottom: var(--spacing-03);
+
+    .history-version-label-tooltip-row-comment {
+      overflow-wrap: anywhere;
+
+      & .material-symbols {
+        font-size: inherit;
+      }
+    }
+
+    &:last-child {
+      margin-bottom: initial;
+    }
+  }
+}
+
+.history-version-dropdown-menu {
+  [role='menuitem'] {
+    padding: var(--spacing-05);
+    color: var(--content-primary);
+
+    &:hover,
+    &:focus {
+      color: var(--content-primary);
+      background-color: var(--bg-light-secondary);
+    }
+  }
+}
+
+.history-dropdown-icon {
+  color: var(--content-primary);
+}
+
+.history-dropdown-icon-inverted {
+  color: var(--neutral-10);
+  vertical-align: top;
+}
+
+.history-restore-promo-icon {
+  vertical-align: middle;
+}
+
+.history-error {
+  padding: 16px;
+}

+ 8 - 3
services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar.scss

@@ -328,20 +328,25 @@
 ***************************************/
 
 .toggle-switch {
+  --toggle-switch-height: 26px;
+  --toggle-switch-padding: var(--spacing-01);
+
   display: inline-flex;
   align-items: center;
-  height: 26px;
+  height: var(--toggle-switch-height);
   margin-right: var(--spacing-03);
   border-radius: var(--border-radius-full);
   background-color: var(--neutral-20);
-  padding: var(--spacing-01);
+  padding: var(--toggle-switch-padding);
 }
 
 .toggle-switch-label {
   display: inline-block;
   float: left;
   font-weight: normal;
-  height: 100%;
+
+  // It seems we need to set the height explicitly rather than using 100% to get the button to display correctly in Blink
+  height: calc(var(--toggle-switch-height) - 2 * var(--toggle-switch-padding));
   text-align: center;
   margin: 0;
   cursor: pointer;

+ 2 - 10
services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss

@@ -657,17 +657,9 @@
       }
 
       .dropdown-table-button-toggle {
-        padding: var(--spacing-04);
-        font-size: 0;
-        line-height: 1;
-        border-radius: 50%;
-        color: var(--content-primary);
-        background-color: transparent;
+        @include action-button;
 
-        &:hover,
-        &:active {
-          background-color: rgba($neutral-90, 0.08);
-        }
+        padding: var(--spacing-04);
       }
     }
   }

+ 5 - 0
services/web/frontend/stylesheets/components/loading-spinner.less

@@ -5,3 +5,8 @@
   align-items: center;
   justify-content: center;
 }
+
+.loading {
+  display: inline-flex;
+  align-items: center;
+}

+ 684 - 0
services/web/test/frontend/features/history/components/change-list-bs5.spec.tsx

@@ -0,0 +1,684 @@
+import '../../../helpers/bootstrap-5'
+import { useState } from 'react'
+import ToggleSwitch from '../../../../../frontend/js/features/history/components/change-list/toggle-switch'
+import ChangeList from '../../../../../frontend/js/features/history/components/change-list/change-list'
+import {
+  EditorProviders,
+  USER_EMAIL,
+  USER_ID,
+} from '../../../helpers/editor-providers'
+import { HistoryProvider } from '../../../../../frontend/js/features/history/context/history-context'
+import { updates } from '../fixtures/updates'
+import { labels } from '../fixtures/labels'
+import {
+  formatTime,
+  relativeDate,
+} from '../../../../../frontend/js/features/utils/format-date'
+
+const mountWithEditorProviders = (
+  component: React.ReactNode,
+  scope: Record<string, unknown> = {},
+  props: Record<string, unknown> = {}
+) => {
+  cy.mount(
+    <EditorProviders scope={scope} {...props}>
+      <HistoryProvider>
+        <div style={{ display: 'flex', justifyContent: 'center' }}>
+          <div className="history-react">{component}</div>
+        </div>
+      </HistoryProvider>
+    </EditorProviders>
+  )
+}
+
+describe('change list (Bootstrap 5)', function () {
+  const scope = {
+    ui: { view: 'history', pdfLayout: 'sideBySide', chatOpen: true },
+  }
+
+  const waitForData = () => {
+    cy.wait('@updates')
+    cy.wait('@labels')
+    cy.wait('@diff')
+  }
+
+  beforeEach(function () {
+    cy.intercept('GET', '/project/*/updates*', {
+      body: updates,
+    }).as('updates')
+    cy.intercept('GET', '/project/*/labels', {
+      body: labels,
+    }).as('labels')
+    cy.intercept('GET', '/project/*/filetree/diff*', {
+      body: { diff: [{ pathname: 'main.tex' }, { pathname: 'name.tex' }] },
+    }).as('diff')
+    window.metaAttributesCache.set('ol-inactiveTutorials', [
+      'react-history-buttons-tutorial',
+    ])
+  })
+
+  describe('toggle switch', function () {
+    it('renders switch buttons', function () {
+      mountWithEditorProviders(
+        <ToggleSwitch labelsOnly={false} setLabelsOnly={() => {}} />
+      )
+
+      cy.findByLabelText(/all history/i)
+      cy.findByLabelText(/labels/i)
+    })
+
+    it('toggles "all history" and "labels" buttons', function () {
+      function ToggleSwitchWrapped({ labelsOnly }: { labelsOnly: boolean }) {
+        const [labelsOnlyLocal, setLabelsOnlyLocal] = useState(labelsOnly)
+        return (
+          <ToggleSwitch
+            labelsOnly={labelsOnlyLocal}
+            setLabelsOnly={setLabelsOnlyLocal}
+          />
+        )
+      }
+
+      mountWithEditorProviders(<ToggleSwitchWrapped labelsOnly={false} />)
+
+      cy.findByLabelText(/all history/i).as('all-history')
+      cy.findByLabelText(/labels/i).as('labels')
+      cy.get('@all-history').should('be.checked')
+      cy.get('@labels').should('not.be.checked')
+      cy.get('@labels').click({ force: true })
+      cy.get('@all-history').should('not.be.checked')
+      cy.get('@labels').should('be.checked')
+    })
+  })
+
+  describe('tags', function () {
+    it('renders tags', function () {
+      mountWithEditorProviders(<ChangeList />, scope, {
+        user: {
+          id: USER_ID,
+          email: USER_EMAIL,
+          isAdmin: true,
+        },
+      })
+      waitForData()
+
+      cy.findByLabelText(/all history/i).click({ force: true })
+      cy.findAllByTestId('history-version-details').as('details')
+      cy.get('@details').should('have.length', 5)
+      // start with 2nd details entry, as first has no tags
+      cy.get('@details')
+        .eq(1)
+        .within(() => {
+          cy.findAllByTestId('history-version-badge').as('tags')
+        })
+      cy.get('@tags').should('have.length', 2)
+      cy.get('@tags').eq(0).should('contain.text', 'tag-2')
+      cy.get('@tags').eq(1).should('contain.text', 'tag-1')
+      // should have delete buttons
+      cy.get('@tags').each(tag =>
+        cy.wrap(tag).within(() => {
+          cy.findByRole('button', { name: /delete/i })
+        })
+      )
+      // 3rd details entry
+      cy.get('@details')
+        .eq(2)
+        .within(() => {
+          cy.findAllByTestId('history-version-badge').should('have.length', 0)
+        })
+      // 4th details entry
+      cy.get('@details')
+        .eq(3)
+        .within(() => {
+          cy.findAllByTestId('history-version-badge').as('tags')
+        })
+      cy.get('@tags').should('have.length', 2)
+      cy.get('@tags').eq(0).should('contain.text', 'tag-4')
+      cy.get('@tags').eq(1).should('contain.text', 'tag-3')
+      // should not have delete buttons
+      cy.get('@tags').each(tag =>
+        cy.wrap(tag).within(() => {
+          cy.findByRole('button', { name: /delete/i }).should('not.exist')
+        })
+      )
+      cy.findByLabelText(/labels/i).click({ force: true })
+      cy.findAllByTestId('history-version-details').as('details')
+      // first details on labels is always "current version", start testing on second
+      cy.get('@details').should('have.length', 3)
+      cy.get('@details')
+        .eq(1)
+        .within(() => {
+          cy.findAllByTestId('history-version-badge').as('tags')
+        })
+      cy.get('@tags').should('have.length', 2)
+      cy.get('@tags').eq(0).should('contain.text', 'tag-2')
+      cy.get('@tags').eq(1).should('contain.text', 'tag-1')
+      cy.get('@details')
+        .eq(2)
+        .within(() => {
+          cy.findAllByTestId('history-version-badge').as('tags')
+        })
+      cy.get('@tags').should('have.length', 3)
+      cy.get('@tags').eq(0).should('contain.text', 'tag-5')
+      cy.get('@tags').eq(1).should('contain.text', 'tag-4')
+      cy.get('@tags').eq(2).should('contain.text', 'tag-3')
+    })
+
+    it('deletes tag', function () {
+      mountWithEditorProviders(<ChangeList />, scope, {
+        user: {
+          id: USER_ID,
+          email: USER_EMAIL,
+          isAdmin: true,
+        },
+      })
+      waitForData()
+
+      cy.findByLabelText(/all history/i).click({ force: true })
+
+      const labelToDelete = 'tag-2'
+      cy.findAllByTestId('history-version-details').eq(1).as('details')
+      cy.get('@details').within(() => {
+        cy.findAllByTestId('history-version-badge').eq(0).as('tag')
+      })
+      cy.get('@tag').should('contain.text', labelToDelete)
+      cy.get('@tag').within(() => {
+        cy.findByRole('button', { name: /delete/i }).as('delete-btn')
+      })
+      cy.get('@delete-btn').click()
+      cy.findByRole('dialog').as('modal')
+      cy.get('@modal').within(() => {
+        cy.findByRole('heading', { name: /delete label/i })
+      })
+      cy.get('@modal').contains(
+        new RegExp(
+          `are you sure you want to delete the following label "${labelToDelete}"?`,
+          'i'
+        )
+      )
+      cy.get('@modal').within(() => {
+        cy.findByRole('button', { name: /cancel/i }).click()
+      })
+      cy.findByRole('dialog').should('not.exist')
+      cy.get('@delete-btn').click()
+      cy.findByRole('dialog').as('modal')
+      cy.intercept('DELETE', '/project/*/labels/*', {
+        statusCode: 500,
+      }).as('delete')
+      cy.get('@modal').within(() => {
+        cy.findByRole('button', { name: /delete/i }).click()
+      })
+      cy.wait('@delete')
+      cy.get('@modal').within(() => {
+        cy.findByRole('alert').within(() => {
+          cy.contains(/sorry, something went wrong/i)
+        })
+      })
+      cy.findByText(labelToDelete).should('have.length', 1)
+
+      cy.intercept('DELETE', '/project/*/labels/*', {
+        statusCode: 204,
+      }).as('delete')
+      cy.get('@modal').within(() => {
+        cy.findByRole('button', { name: /delete/i }).click()
+      })
+      cy.wait('@delete')
+      cy.findByText(labelToDelete).should('not.exist')
+    })
+
+    it('verifies that selecting the same list item will not trigger a new diff', function () {
+      mountWithEditorProviders(<ChangeList />, scope, {
+        user: {
+          id: USER_ID,
+          email: USER_EMAIL,
+          isAdmin: true,
+        },
+      })
+      waitForData()
+
+      const stub = cy.stub().as('diffStub')
+      cy.intercept('GET', '/project/*/filetree/diff*', stub).as('diff')
+
+      cy.findAllByTestId('history-version-details').eq(2).as('details')
+      cy.get('@details').click() // 1st click
+      cy.wait('@diff')
+      cy.get('@details').click() // 2nd click
+      cy.get('@diffStub').should('have.been.calledOnce')
+    })
+  })
+
+  describe('all history', function () {
+    beforeEach(function () {
+      mountWithEditorProviders(<ChangeList />, scope, {
+        user: {
+          id: USER_ID,
+          email: USER_EMAIL,
+          isAdmin: true,
+        },
+      })
+      waitForData()
+    })
+
+    it('shows grouped versions date', function () {
+      cy.findByText(relativeDate(updates.updates[0].meta.end_ts))
+      cy.findByText(relativeDate(updates.updates[1].meta.end_ts))
+    })
+
+    it('shows the date of the version', function () {
+      cy.findAllByTestId('history-version-details')
+        .eq(1)
+        .within(() => {
+          cy.findByTestId('history-version-metadata-time').should(
+            'have.text',
+            formatTime(updates.updates[0].meta.end_ts, 'Do MMMM, h:mm a')
+          )
+        })
+    })
+
+    it('shows change action', function () {
+      cy.findAllByTestId('history-version-details')
+        .eq(1)
+        .within(() => {
+          cy.findByTestId('history-version-change-action').should(
+            'have.text',
+            'Created'
+          )
+        })
+    })
+
+    it('shows changed document name', function () {
+      cy.findAllByTestId('history-version-details')
+        .eq(2)
+        .within(() => {
+          cy.findByTestId('history-version-change-doc').should(
+            'have.text',
+            updates.updates[2].pathnames[0]
+          )
+        })
+    })
+
+    it('shows users', function () {
+      cy.findAllByTestId('history-version-details')
+        .eq(1)
+        .within(() => {
+          cy.findByTestId('history-version-metadata-users')
+            .should('contain.text', 'You')
+            .and('contain.text', updates.updates[1].meta.users[1].first_name)
+        })
+    })
+  })
+
+  describe('labels only', function () {
+    beforeEach(function () {
+      mountWithEditorProviders(<ChangeList />, scope, {
+        user: {
+          id: USER_ID,
+          email: USER_EMAIL,
+          isAdmin: true,
+        },
+      })
+      waitForData()
+      cy.findByLabelText(/labels/i).click({ force: true })
+    })
+
+    it('shows the dropdown menu item for adding new labels', function () {
+      cy.findAllByTestId('history-version-details')
+        .eq(1)
+        .within(() => {
+          cy.findByRole('button', { name: /more actions/i }).click()
+          cy.findByRole('menu').within(() => {
+            cy.findByRole('menuitem', {
+              name: /label this version/i,
+            }).should('exist')
+          })
+        })
+    })
+
+    it('resets from compare to view mode when switching tabs', function () {
+      cy.findAllByTestId('history-version-details')
+        .eq(1)
+        .within(() => {
+          cy.findByRole('button', {
+            name: /Compare/i,
+          }).click()
+        })
+      cy.findByLabelText(/all history/i).click({ force: true })
+      cy.findAllByTestId('history-version-details').should($versions => {
+        const [selected, ...rest] = Array.from($versions)
+        expect(selected).to.have.attr('data-selected', 'selected')
+        expect(
+          rest.every(version => version.dataset.selected === 'belowSelected')
+        ).to.be.true
+      })
+    })
+    it('opens the compare drop down and compares with selected version', function () {
+      cy.findByLabelText(/all history/i).click({ force: true })
+      cy.findAllByTestId('history-version-details')
+        .eq(3)
+        .within(() => {
+          cy.findByRole('button', {
+            name: /compare from this version/i,
+          }).click()
+        })
+
+      cy.findAllByTestId('history-version-details')
+        .eq(1)
+        .within(() => {
+          cy.get('[aria-label="Compare"]').click()
+          cy.findByRole('menu').within(() => {
+            cy.findByRole('menuitem', {
+              name: /compare up to this version/i,
+            }).click()
+          })
+        })
+
+      cy.findAllByTestId('history-version-details').should($versions => {
+        const [
+          aboveSelected,
+          upperSelected,
+          withinSelected,
+          lowerSelected,
+          belowSelected,
+        ] = Array.from($versions)
+        expect(aboveSelected).to.have.attr('data-selected', 'aboveSelected')
+        expect(upperSelected).to.have.attr('data-selected', 'upperSelected')
+        expect(withinSelected).to.have.attr('data-selected', 'withinSelected')
+        expect(lowerSelected).to.have.attr('data-selected', 'lowerSelected')
+        expect(belowSelected).to.have.attr('data-selected', 'belowSelected')
+      })
+    })
+  })
+
+  describe('compare mode', function () {
+    beforeEach(function () {
+      mountWithEditorProviders(<ChangeList />, scope, {
+        user: {
+          id: USER_ID,
+          email: USER_EMAIL,
+          isAdmin: true,
+        },
+      })
+      waitForData()
+    })
+
+    it('compares versions', function () {
+      cy.findAllByTestId('history-version-details').should($versions => {
+        const [first, ...rest] = Array.from($versions)
+        expect(first).to.have.attr('data-selected', 'selected')
+        rest.forEach(version =>
+          // Based on the fact that we are selecting first version as we load the page
+          // Every other version will be belowSelected
+          expect(version).to.have.attr('data-selected', 'belowSelected')
+        )
+      })
+
+      cy.intercept('GET', '/project/*/filetree/diff*', {
+        body: { diff: [{ pathname: 'main.tex' }, { pathname: 'name.tex' }] },
+      }).as('compareDiff')
+
+      cy.findAllByTestId('history-version-details')
+        .last()
+        .within(() => {
+          cy.findByTestId('compare-icon-version').click()
+        })
+      cy.wait('@compareDiff')
+    })
+  })
+
+  describe('dropdown', function () {
+    beforeEach(function () {
+      mountWithEditorProviders(<ChangeList />, scope, {
+        user: {
+          id: USER_ID,
+          email: USER_EMAIL,
+          isAdmin: true,
+        },
+      })
+      waitForData()
+    })
+
+    it('adds badge/label', function () {
+      cy.findAllByTestId('history-version-details').eq(1).as('version')
+      cy.get('@version').within(() => {
+        cy.findByRole('button', { name: /more actions/i }).click()
+        cy.findByRole('menu').within(() => {
+          cy.findByRole('menuitem', {
+            name: /label this version/i,
+          }).click()
+        })
+      })
+      cy.intercept('POST', '/project/*/labels', req => {
+        req.reply(200, {
+          id: '64633ee158e9ef7da614c000',
+          comment: req.body.comment,
+          version: req.body.version,
+          user_id: USER_ID,
+          created_at: '2023-05-16T08:29:21.250Z',
+          user_display_name: 'john.doe',
+        })
+      }).as('addLabel')
+      const newLabel = 'my new label'
+      cy.findByRole('dialog').within(() => {
+        cy.findByRole('heading', { name: /add label/i })
+        cy.findByRole('button', { name: /cancel/i })
+        cy.findByRole('button', { name: /add label/i }).should('be.disabled')
+        cy.findByPlaceholderText(/new label name/i).as('input')
+        cy.get('@input').type(newLabel)
+        cy.findByRole('button', { name: /add label/i }).should('be.enabled')
+        cy.get('@input').type('{enter}')
+      })
+      cy.wait('@addLabel')
+      cy.get('@version').within(() => {
+        cy.findAllByTestId('history-version-badge').should($badges => {
+          const includes = Array.from($badges).some(badge =>
+            badge.textContent?.includes(newLabel)
+          )
+          expect(includes).to.be.true
+        })
+      })
+    })
+
+    it('downloads version', function () {
+      cy.intercept('GET', '/project/*/version/*/zip', { statusCode: 200 }).as(
+        'download'
+      )
+      cy.findAllByTestId('history-version-details')
+        .eq(0)
+        .within(() => {
+          cy.findByRole('button', { name: /more actions/i }).click()
+          cy.findByRole('menu').within(() => {
+            cy.findByRole('menuitem', {
+              name: /download this version/i,
+            }).click()
+          })
+        })
+      cy.wait('@download')
+    })
+  })
+
+  describe('paywall', function () {
+    const now = Date.now()
+    const oneMinuteAgo = now - 60 * 1000
+    const justOverADayAgo = now - 25 * 60 * 60 * 1000
+    const twoDaysAgo = now - 48 * 60 * 60 * 1000
+
+    const updates = {
+      updates: [
+        {
+          fromV: 3,
+          toV: 4,
+          meta: {
+            users: [
+              {
+                first_name: 'john.doe',
+                last_name: '',
+                email: 'john.doe@test.com',
+                id: '1',
+              },
+            ],
+            start_ts: oneMinuteAgo,
+            end_ts: oneMinuteAgo,
+          },
+          labels: [],
+          pathnames: [],
+          project_ops: [{ add: { pathname: 'name.tex' }, atV: 3 }],
+        },
+        {
+          fromV: 1,
+          toV: 3,
+          meta: {
+            users: [
+              {
+                first_name: 'bobby.lapointe',
+                last_name: '',
+                email: 'bobby.lapointe@test.com',
+                id: '2',
+              },
+            ],
+            start_ts: justOverADayAgo,
+            end_ts: justOverADayAgo - 10 * 1000,
+          },
+          labels: [],
+          pathnames: ['main.tex'],
+          project_ops: [],
+        },
+        {
+          fromV: 0,
+          toV: 1,
+          meta: {
+            users: [
+              {
+                first_name: 'john.doe',
+                last_name: '',
+                email: 'john.doe@test.com',
+                id: '1',
+              },
+            ],
+            start_ts: twoDaysAgo,
+            end_ts: twoDaysAgo,
+          },
+          labels: [
+            {
+              id: 'label1',
+              comment: 'tag-1',
+              version: 0,
+              user_id: USER_ID,
+              created_at: justOverADayAgo,
+            },
+          ],
+          pathnames: [],
+          project_ops: [{ add: { pathname: 'main.tex' }, atV: 0 }],
+        },
+      ],
+    }
+
+    const labels = [
+      {
+        id: 'label1',
+        comment: 'tag-1',
+        version: 0,
+        user_id: USER_ID,
+        created_at: justOverADayAgo,
+        user_display_name: 'john.doe',
+      },
+    ]
+
+    const waitForData = () => {
+      cy.wait('@updates')
+      cy.wait('@labels')
+      cy.wait('@diff')
+    }
+
+    beforeEach(function () {
+      cy.intercept('GET', '/project/*/updates*', {
+        body: updates,
+      }).as('updates')
+      cy.intercept('GET', '/project/*/labels', {
+        body: labels,
+      }).as('labels')
+      cy.intercept('GET', '/project/*/filetree/diff*', {
+        body: { diff: [{ pathname: 'main.tex' }, { pathname: 'name.tex' }] },
+      }).as('diff')
+    })
+
+    it('shows non-owner paywall', function () {
+      const scope = {
+        ui: {
+          view: 'history',
+          pdfLayout: 'sideBySide',
+          chatOpen: true,
+        },
+      }
+
+      mountWithEditorProviders(<ChangeList />, scope, {
+        user: {
+          id: USER_ID,
+          email: USER_EMAIL,
+          isAdmin: false,
+        },
+      })
+
+      waitForData()
+
+      cy.get('.history-paywall-prompt').should('have.length', 1)
+      cy.findAllByTestId('history-version').should('have.length', 2)
+      cy.get('.history-paywall-prompt button').should('not.exist')
+    })
+
+    it('shows owner paywall', function () {
+      const scope = {
+        ui: {
+          view: 'history',
+          pdfLayout: 'sideBySide',
+          chatOpen: true,
+        },
+      }
+
+      mountWithEditorProviders(<ChangeList />, scope, {
+        user: {
+          id: USER_ID,
+          email: USER_EMAIL,
+          isAdmin: false,
+        },
+        projectOwner: {
+          _id: USER_ID,
+          email: USER_EMAIL,
+        },
+      })
+
+      waitForData()
+
+      cy.get('.history-paywall-prompt').should('have.length', 1)
+      cy.findAllByTestId('history-version').should('have.length', 2)
+      cy.get('.history-paywall-prompt button').should('have.length', 1)
+    })
+
+    it('shows all labels in free tier', function () {
+      const scope = {
+        ui: {
+          view: 'history',
+          pdfLayout: 'sideBySide',
+          chatOpen: true,
+        },
+      }
+
+      mountWithEditorProviders(<ChangeList />, scope, {
+        user: {
+          id: USER_ID,
+          email: USER_EMAIL,
+          isAdmin: false,
+        },
+        projectOwner: {
+          _id: USER_ID,
+          email: USER_EMAIL,
+        },
+      })
+
+      waitForData()
+
+      cy.findByLabelText(/labels/i).click({ force: true })
+
+      // One pseudo-label for the current state, one for our label
+      cy.get('.history-version-label').should('have.length', 2)
+    })
+  })
+})

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

@@ -31,7 +31,7 @@ const mountWithEditorProviders = (
   )
 }
 
-describe('change list', function () {
+describe('change list (Bootstrap 3)', function () {
   const scope = {
     ui: { view: 'history', pdfLayout: 'sideBySide', chatOpen: true },
   }
@@ -363,7 +363,7 @@ describe('change list', function () {
       cy.findAllByTestId('history-version-details')
         .eq(1)
         .within(() => {
-          cy.findByRole('button', { name: /compare drop down/i }).click()
+          cy.findByRole('button', { name: /compare/i }).click()
           cy.findByRole('menu').within(() => {
             cy.findByRole('menuitem', {
               name: /compare up to this version/i,