Răsfoiți Sursa

Merge pull request #19840 from overleaf/ii-bs5-project-tools

[web] BS5 project tools

GitOrigin-RevId: 3181c62985b6db4051292b484f53178a0736fa75
ilkin-overleaf 2 ani în urmă
părinte
comite
0e71084600
54 a modificat fișierele cu 1374 adăugiri și 580 ștergeri
  1. 2 2
      server-ce/test/project-list.spec.ts
  2. 6 0
      services/web/frontend/extracted-translations.json
  3. 32 6
      services/web/frontend/js/features/project-list/components/current-plan-widget/commons-plan.tsx
  4. 43 14
      services/web/frontend/js/features/project-list/components/current-plan-widget/free-plan.tsx
  5. 32 6
      services/web/frontend/js/features/project-list/components/current-plan-widget/group-plan.tsx
  6. 29 6
      services/web/frontend/js/features/project-list/components/current-plan-widget/individual-plan.tsx
  7. 1 1
      services/web/frontend/js/features/project-list/components/dropdown/actions-dropdown.tsx
  8. 116 43
      services/web/frontend/js/features/project-list/components/dropdown/projects-dropdown.tsx
  9. 115 41
      services/web/frontend/js/features/project-list/components/dropdown/sort-by-dropdown.tsx
  10. 3 3
      services/web/frontend/js/features/project-list/components/modals/create-tag-modal.tsx
  11. 2 2
      services/web/frontend/js/features/project-list/components/modals/delete-tag-modal.tsx
  12. 4 4
      services/web/frontend/js/features/project-list/components/modals/edit-tag-modal.tsx
  13. 72 62
      services/web/frontend/js/features/project-list/components/modals/manage-tag-modal.tsx
  14. 5 1
      services/web/frontend/js/features/project-list/components/project-list-root.tsx
  15. 1 1
      services/web/frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button.tsx
  16. 19 12
      services/web/frontend/js/features/project-list/components/table/cells/owner-cell.tsx
  17. 15 10
      services/web/frontend/js/features/project-list/components/table/project-tools/buttons/archive-projects-button.tsx
  18. 3 3
      services/web/frontend/js/features/project-list/components/table/project-tools/buttons/delete-leave-projects-button.tsx
  19. 3 3
      services/web/frontend/js/features/project-list/components/table/project-tools/buttons/delete-projects-button.tsx
  20. 15 10
      services/web/frontend/js/features/project-list/components/table/project-tools/buttons/download-projects-button.tsx
  21. 3 3
      services/web/frontend/js/features/project-list/components/table/project-tools/buttons/leave-projects-button.tsx
  22. 42 13
      services/web/frontend/js/features/project-list/components/table/project-tools/buttons/project-tools-more-dropdown-button.tsx
  23. 124 54
      services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx
  24. 15 10
      services/web/frontend/js/features/project-list/components/table/project-tools/buttons/trash-projects-button.tsx
  25. 4 8
      services/web/frontend/js/features/project-list/components/table/project-tools/buttons/unarchive-projects-button.tsx
  26. 3 7
      services/web/frontend/js/features/project-list/components/table/project-tools/buttons/untrash-projects-button.tsx
  27. 4 2
      services/web/frontend/js/features/project-list/components/table/project-tools/menu-items/copy-project-menu-item.tsx
  28. 4 2
      services/web/frontend/js/features/project-list/components/table/project-tools/menu-items/rename-project-menu-item.tsx
  29. 24 18
      services/web/frontend/js/features/project-list/components/table/project-tools/project-tools.tsx
  30. 141 64
      services/web/frontend/js/features/project-list/components/tags-list.tsx
  31. 3 3
      services/web/frontend/js/features/project-list/components/title/project-list-title.tsx
  32. 2 2
      services/web/frontend/js/features/ui/components/bootstrap-5/bootstrap-version-switcher.tsx
  33. 65 45
      services/web/frontend/js/features/ui/components/bootstrap-5/button.tsx
  34. 72 60
      services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-menu.tsx
  35. 27 22
      services/web/frontend/js/features/ui/components/bootstrap-5/icon-button.tsx
  36. 8 6
      services/web/frontend/js/features/ui/components/bootstrap-5/tooltip.tsx
  37. 30 0
      services/web/frontend/js/features/ui/components/ol/ol-button-group.tsx
  38. 31 0
      services/web/frontend/js/features/ui/components/ol/ol-button-toolbar.tsx
  39. 27 0
      services/web/frontend/js/features/ui/components/ol/ol-dropdown-menu-item.tsx
  40. 38 20
      services/web/frontend/js/features/ui/components/ol/ol-icon-button.tsx
  41. 6 2
      services/web/frontend/js/features/ui/components/types/button-props.ts
  42. 3 0
      services/web/frontend/js/features/ui/components/types/dropdown-menu-props.ts
  43. 3 3
      services/web/frontend/js/utils/functions.ts
  44. 21 0
      services/web/frontend/js/utils/react.ts
  45. 30 0
      services/web/frontend/stories/ui/dropdown-menu.stories.tsx
  46. 0 2
      services/web/frontend/stylesheets/app/project-list-react.less
  47. 1 0
      services/web/frontend/stylesheets/bootstrap-5/base/bootstrap.scss
  48. 1 0
      services/web/frontend/stylesheets/bootstrap-5/components/all.scss
  49. 15 0
      services/web/frontend/stylesheets/bootstrap-5/components/button-group.scss
  50. 16 0
      services/web/frontend/stylesheets/bootstrap-5/components/button.scss
  51. 12 0
      services/web/frontend/stylesheets/bootstrap-5/components/dropdown-menu.scss
  52. 74 3
      services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss
  53. 6 0
      services/web/locales/en.json
  54. 1 1
      services/web/test/frontend/features/project-list/components/table/project-tools/project-tools.test.tsx

+ 2 - 2
server-ce/test/project-list.spec.ts

@@ -41,7 +41,7 @@ describe('Project List', () => {
       cy.visit('/project')
 
       findProjectRow(projectName).within(() =>
-        cy.contains(`Download .zip file`).click()
+        cy.findByRole('button', { name: 'Download .zip file' }).click()
       )
 
       cy.task('readFileInZip', {
@@ -55,7 +55,7 @@ describe('Project List', () => {
       cy.visit('/project')
 
       findProjectRow(projectName).within(() =>
-        cy.contains(`Download PDF`).click()
+        cy.findByRole('button', { name: 'Download PDF' }).click()
       )
 
       const pdfName = projectName.replaceAll('-', '_')

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

@@ -446,6 +446,7 @@
   "files_cannot_include_invalid_characters": "",
   "files_selected": "",
   "fill_in_our_quick_survey": "",
+  "filter_projects": "",
   "find_out_more": "",
   "find_out_more_about_institution_login": "",
   "find_out_more_about_the_file_outline": "",
@@ -1298,6 +1299,7 @@
   "sorry_your_table_cant_be_displayed_at_the_moment": "",
   "sort_by": "",
   "sort_by_x": "",
+  "sort_projects": "",
   "source": "",
   "spell_check": "",
   "sso": "",
@@ -1499,6 +1501,10 @@
   "toolbar_insert_table": "",
   "toolbar_numbered_list": "",
   "toolbar_redo": "",
+  "toolbar_selected_projects": "",
+  "toolbar_selected_projects_management_actions": "",
+  "toolbar_selected_projects_remove": "",
+  "toolbar_selected_projects_restore": "",
   "toolbar_table_insert_size_table": "",
   "toolbar_table_insert_table_lowercase": "",
   "toolbar_toggle_symbol_palette": "",

+ 32 - 6
services/web/frontend/js/features/project-list/components/current-plan-widget/commons-plan.tsx

@@ -1,6 +1,10 @@
 import { useTranslation, Trans } from 'react-i18next'
 import { CommonsPlanSubscription } from '../../../../../../types/project/dashboard/subscription'
-import Tooltip from '../../../../shared/components/tooltip'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
+import MaterialIcon from '@/shared/components/material-icon'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import { bsVersion } from '@/features/utils/bootstrap-5'
+import classnames from 'classnames'
 
 type CommonsPlanProps = Pick<
   CommonsPlanSubscription,
@@ -19,8 +23,15 @@ function CommonsPlan({
 
   return (
     <>
-      <span className="current-plan-label visible-xs">{currentPlanLabel}</span>
-      <Tooltip
+      <span
+        className={classnames(
+          'current-plan-label',
+          bsVersion({ bs5: 'd-md-none', bs3: 'visible-xs' })
+        )}
+      >
+        {currentPlanLabel}
+      </span>
+      <OLTooltip
         description={t('commons_plan_tooltip', {
           plan: plan.name,
           institution: subscription.name,
@@ -28,10 +39,25 @@ function CommonsPlan({
         id="commons-plan"
         overlayProps={{ placement: 'bottom' }}
       >
-        <a href={featuresPageURL} className="current-plan-label hidden-xs">
-          {currentPlanLabel} <span className="info-badge" />
+        <a
+          href={featuresPageURL}
+          className={classnames(
+            'current-plan-label',
+            bsVersion({
+              bs5: 'd-none d-md-inline-block',
+              bs3: 'hidden-xs',
+            })
+          )}
+        >
+          {currentPlanLabel}&nbsp;
+          <BootstrapVersionSwitcher
+            bs3={<span className="info-badge" />}
+            bs5={
+              <MaterialIcon type="info" className="current-plan-label-icon" />
+            }
+          />
         </a>
-      </Tooltip>
+      </OLTooltip>
     </>
   )
 }

+ 43 - 14
services/web/frontend/js/features/project-list/components/current-plan-widget/free-plan.tsx

@@ -1,8 +1,12 @@
 import { useTranslation, Trans } from 'react-i18next'
-import { Button } from 'react-bootstrap'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import MaterialIcon from '@/shared/components/material-icon'
 import { FreePlanSubscription } from '../../../../../../types/project/dashboard/subscription'
-import Tooltip from '../../../../shared/components/tooltip'
 import * as eventTracking from '../../../../infrastructure/event-tracking'
+import { bsVersion } from '@/features/utils/bootstrap-5'
+import classnames from 'classnames'
 
 type FreePlanProps = Pick<FreePlanSubscription, 'featuresPageURL'>
 
@@ -23,24 +27,49 @@ function FreePlan({ featuresPageURL }: FreePlanProps) {
 
   return (
     <>
-      <span className="current-plan-label visible-xs">{currentPlanLabel}</span>
-      <Tooltip
+      <span
+        className={classnames(
+          'current-plan-label',
+          bsVersion({ bs5: 'd-md-none', bs3: 'visible-xs' })
+        )}
+      >
+        {currentPlanLabel}
+      </span>
+      <OLTooltip
         description={t('free_plan_tooltip')}
         id="free-plan"
         overlayProps={{ placement: 'bottom' }}
       >
-        <a href={featuresPageURL} className="current-plan-label hidden-xs">
-          {currentPlanLabel} <span className="info-badge" />
+        <a
+          href={featuresPageURL}
+          className={classnames(
+            'current-plan-label',
+            bsVersion({ bs5: 'd-none d-md-inline-block', bs3: 'hidden-xs' })
+          )}
+        >
+          {currentPlanLabel}&nbsp;
+          <BootstrapVersionSwitcher
+            bs3={<span className="info-badge" />}
+            bs5={
+              <MaterialIcon type="info" className="current-plan-label-icon" />
+            }
+          />
         </a>
-      </Tooltip>{' '}
-      <Button
-        bsStyle="primary"
-        className="hidden-xs"
-        href="/user/subscription/plans"
-        onClick={handleClick}
+      </OLTooltip>{' '}
+      <span
+        className={bsVersion({
+          bs5: 'd-none d-md-inline-block',
+          bs3: 'hidden-xs',
+        })}
       >
-        {t('upgrade')}
-      </Button>
+        <OLButton
+          variant="primary"
+          href="/user/subscription/plans"
+          onClick={handleClick}
+        >
+          {t('upgrade')}
+        </OLButton>
+      </span>
     </>
   )
 }

+ 32 - 6
services/web/frontend/js/features/project-list/components/current-plan-widget/group-plan.tsx

@@ -1,6 +1,10 @@
 import { useTranslation, Trans } from 'react-i18next'
 import { GroupPlanSubscription } from '../../../../../../types/project/dashboard/subscription'
-import Tooltip from '../../../../shared/components/tooltip'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import MaterialIcon from '@/shared/components/material-icon'
+import { bsVersion } from '@/features/utils/bootstrap-5'
+import classnames from 'classnames'
 
 type GroupPlanProps = Pick<
   GroupPlanSubscription,
@@ -33,8 +37,15 @@ function GroupPlan({
 
   return (
     <>
-      <span className="current-plan-label visible-xs">{currentPlanLabel}</span>
-      <Tooltip
+      <span
+        className={classnames(
+          'current-plan-label',
+          bsVersion({ bs5: 'd-md-none', bs3: 'visible-xs' })
+        )}
+      >
+        {currentPlanLabel}
+      </span>
+      <OLTooltip
         description={
           subscription.teamName != null
             ? t('group_plan_with_name_tooltip', {
@@ -46,10 +57,25 @@ function GroupPlan({
         id="group-plan"
         overlayProps={{ placement: 'bottom' }}
       >
-        <a href={featuresPageURL} className="current-plan-label hidden-xs">
-          {currentPlanLabel} <span className="info-badge" />
+        <a
+          href={featuresPageURL}
+          className={classnames(
+            'current-plan-label',
+            bsVersion({
+              bs5: 'd-none d-md-inline-block',
+              bs3: 'hidden-xs',
+            })
+          )}
+        >
+          {currentPlanLabel}&nbsp;
+          <BootstrapVersionSwitcher
+            bs3={<span className="info-badge" />}
+            bs5={
+              <MaterialIcon type="info" className="current-plan-label-icon" />
+            }
+          />
         </a>
-      </Tooltip>
+      </OLTooltip>
     </>
   )
 }

+ 29 - 6
services/web/frontend/js/features/project-list/components/current-plan-widget/individual-plan.tsx

@@ -1,6 +1,10 @@
 import { useTranslation, Trans } from 'react-i18next'
 import { IndividualPlanSubscription } from '../../../../../../types/project/dashboard/subscription'
-import Tooltip from '../../../../shared/components/tooltip'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import MaterialIcon from '@/shared/components/material-icon'
+import { bsVersion } from '@/features/utils/bootstrap-5'
+import classnames from 'classnames'
 
 type IndividualPlanProps = Pick<
   IndividualPlanSubscription,
@@ -32,16 +36,35 @@ function IndividualPlan({
 
   return (
     <>
-      <span className="current-plan-label visible-xs">{currentPlanLabel}</span>
-      <Tooltip
+      <span
+        className={classnames(
+          'current-plan-label',
+          bsVersion({ bs5: 'd-md-none', bs3: 'visible-xs' })
+        )}
+      >
+        {currentPlanLabel}
+      </span>
+      <OLTooltip
         description={t('plan_tooltip', { plan: plan.name })}
         id="individual-plan"
         overlayProps={{ placement: 'bottom' }}
       >
-        <a href={featuresPageURL} className="current-plan-label hidden-xs">
-          {currentPlanLabel} <span className="info-badge" />
+        <a
+          href={featuresPageURL}
+          className={classnames(
+            'current-plan-label',
+            bsVersion({ bs5: 'd-none d-md-inline-block', bs3: 'hidden-xs' })
+          )}
+        >
+          {currentPlanLabel}&nbsp;
+          <BootstrapVersionSwitcher
+            bs3={<span className="info-badge" />}
+            bs5={
+              <MaterialIcon type="info" className="current-plan-label-icon" />
+            }
+          />
         </a>
-      </Tooltip>
+      </OLTooltip>
     </>
   )
 }

+ 1 - 1
services/web/frontend/js/features/project-list/components/dropdown/actions-dropdown.tsx

@@ -318,7 +318,7 @@ function BS5ActionsDropdown({ project }: ActionDropdownProps) {
                 as="button"
                 tabIndex={-1}
                 onClick={downloadProject}
-                leadingIcon="cloud_download"
+                leadingIcon="download"
               >
                 {text}
               </DropdownItem>

+ 116 - 43
services/web/frontend/js/features/project-list/components/dropdown/projects-dropdown.tsx

@@ -1,41 +1,67 @@
 import { useState, useEffect, useRef } from 'react'
 import { useTranslation } from 'react-i18next'
-import { Dropdown, MenuItem } from 'react-bootstrap'
+import {
+  Dropdown as BS3Dropdown,
+  MenuItem as BS3MenuItem,
+} from 'react-bootstrap'
 import Icon from '../../../../shared/components/icon'
 import {
   Filter,
   UNCATEGORIZED_KEY,
   useProjectListContext,
 } from '../../context/project-list-context'
+import {
+  Dropdown,
+  DropdownHeader,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
+} from '@/features/ui/components/bootstrap-5/dropdown-menu'
 import ProjectsFilterMenu from '../projects-filter-menu'
 import TagsList from '../tags-list'
 import MenuItemButton from './menu-item-button'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 
 type ItemProps = {
   filter: Filter
   text: string
-  onClick: () => void
+  onClick?: () => void
 }
 
 export function Item({ filter, text, onClick }: ItemProps) {
   const { selectFilter } = useProjectListContext()
   const handleClick = () => {
     selectFilter(filter)
-    onClick()
+    onClick?.()
   }
 
   return (
     <ProjectsFilterMenu filter={filter}>
       {isActive => (
-        <MenuItemButton
-          onClick={handleClick}
-          className="projects-types-menu-item"
-        >
-          {isActive ? (
-            <Icon type="check" className="menu-item-button-icon" />
-          ) : null}
-          <span className="menu-item-button-text">{text}</span>
-        </MenuItemButton>
+        <BootstrapVersionSwitcher
+          bs3={
+            <MenuItemButton
+              onClick={handleClick}
+              className="projects-types-menu-item"
+            >
+              {isActive ? (
+                <Icon type="check" className="menu-item-button-icon" />
+              ) : null}
+              <span className="menu-item-button-text">{text}</span>
+            </MenuItemButton>
+          }
+          bs5={
+            <DropdownItem
+              as="button"
+              tabIndex={-1}
+              onClick={handleClick}
+              trailingIcon={isActive ? 'check' : undefined}
+              active={isActive}
+            >
+              {text}
+            </DropdownItem>
+          }
+        />
       )}
     </ProjectsFilterMenu>
   )
@@ -72,37 +98,84 @@ function ProjectsDropdown() {
   }, [filter, tags, selectedTagId, t])
 
   return (
-    <Dropdown
-      id="projects-types-dropdown"
-      open={isOpened}
-      onToggle={open => setIsOpened(open)}
-    >
-      <Dropdown.Toggle bsSize="large" noCaret className="ps-0 btn-transparent">
-        <span className="text-truncate me-1">{title}</span>
-        <Icon type="angle-down" />
-      </Dropdown.Toggle>
-      <Dropdown.Menu className="projects-dropdown-menu">
-        <Item filter="all" text={t('all_projects')} onClick={handleClose} />
-        <Item filter="owned" text={t('your_projects')} onClick={handleClose} />
-        <Item
-          filter="shared"
-          text={t('shared_with_you')}
-          onClick={handleClose}
-        />
-        <Item
-          filter="archived"
-          text={t('archived_projects')}
-          onClick={handleClose}
-        />
-        <Item
-          filter="trashed"
-          text={t('trashed_projects')}
-          onClick={handleClose}
-        />
-        <MenuItem header>{t('tags')}:</MenuItem>
-        <TagsList onTagClick={handleClose} onEditClick={handleClose} />
-      </Dropdown.Menu>
-    </Dropdown>
+    <BootstrapVersionSwitcher
+      bs3={
+        <BS3Dropdown
+          id="projects-types-dropdown"
+          open={isOpened}
+          onToggle={open => setIsOpened(open)}
+        >
+          <BS3Dropdown.Toggle
+            bsSize="large"
+            noCaret
+            className="ps-0 btn-transparent"
+          >
+            <span className="text-truncate me-1">{title}</span>
+            <Icon type="angle-down" />
+          </BS3Dropdown.Toggle>
+          <BS3Dropdown.Menu className="projects-dropdown-menu">
+            <Item filter="all" text={t('all_projects')} onClick={handleClose} />
+            <Item
+              filter="owned"
+              text={t('your_projects')}
+              onClick={handleClose}
+            />
+            <Item
+              filter="shared"
+              text={t('shared_with_you')}
+              onClick={handleClose}
+            />
+            <Item
+              filter="archived"
+              text={t('archived_projects')}
+              onClick={handleClose}
+            />
+            <Item
+              filter="trashed"
+              text={t('trashed_projects')}
+              onClick={handleClose}
+            />
+            <BS3MenuItem header>{t('tags')}:</BS3MenuItem>
+            <TagsList onTagClick={handleClose} onEditClick={handleClose} />
+          </BS3Dropdown.Menu>
+        </BS3Dropdown>
+      }
+      bs5={
+        <Dropdown>
+          <DropdownToggle
+            id="projects-types-dropdown-toggle-btn"
+            className="ps-0 mb-0 btn-transparent h3"
+            size="lg"
+            aria-label={t('filter_projects')}
+          >
+            <span className="text-truncate" aria-hidden>
+              {title}
+            </span>
+          </DropdownToggle>
+          <DropdownMenu flip={false}>
+            <li role="none">
+              <Item filter="all" text={t('all_projects')} />
+            </li>
+            <li role="none">
+              <Item filter="owned" text={t('your_projects')} />
+            </li>
+            <li role="none">
+              <Item filter="shared" text={t('shared_with_you')} />
+            </li>
+            <li role="none">
+              <Item filter="archived" text={t('archived_projects')} />
+            </li>
+            <li role="none">
+              <Item filter="trashed" text={t('trashed_projects')} />
+            </li>
+            <DropdownHeader className="text-uppercase">
+              {t('tags')}:
+            </DropdownHeader>
+            <TagsList />
+          </DropdownMenu>
+        </Dropdown>
+      }
+    />
   )
 }
 

+ 115 - 41
services/web/frontend/js/features/project-list/components/dropdown/sort-by-dropdown.tsx

@@ -1,22 +1,47 @@
 import { useState, useEffect, useRef } from 'react'
 import { useTranslation } from 'react-i18next'
-import { Dropdown, MenuItem } from 'react-bootstrap'
+import {
+  Dropdown as BS3Dropdown,
+  MenuItem as BS3MenuItem,
+} from 'react-bootstrap'
 import Icon from '../../../../shared/components/icon'
 import useSort from '../../hooks/use-sort'
 import withContent, { SortBtnProps } from '../sort/with-content'
 import { useProjectListContext } from '../../context/project-list-context'
 import { Sort } from '../../../../../../types/project/dashboard/api'
 import MenuItemButton from './menu-item-button'
+import {
+  Dropdown,
+  DropdownHeader,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
+} from '@/features/ui/components/bootstrap-5/dropdown-menu'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 
 function Item({ onClick, text, iconType, screenReaderText }: SortBtnProps) {
   return (
-    <MenuItemButton onClick={onClick} className="projects-sort-menu-item">
-      {iconType ? (
-        <Icon type={iconType} className="menu-item-button-icon" />
-      ) : null}
-      <span className="menu-item-button-text">{text}</span>
-      <span className="sr-only">{screenReaderText}</span>
-    </MenuItemButton>
+    <BootstrapVersionSwitcher
+      bs3={
+        <MenuItemButton onClick={onClick} className="projects-sort-menu-item">
+          {iconType ? (
+            <Icon type={iconType} className="menu-item-button-icon" />
+          ) : null}
+          <span className="menu-item-button-text">{text}</span>
+          <span className="sr-only">{screenReaderText}</span>
+        </MenuItemButton>
+      }
+      bs5={
+        <DropdownItem
+          as="button"
+          tabIndex={-1}
+          onClick={onClick}
+          trailingIcon={iconType}
+        >
+          {text}
+        </DropdownItem>
+      }
+    />
   )
 }
 
@@ -39,45 +64,94 @@ function SortByDropdown() {
     setIsOpened(false)
     handleSort(by)
   }
+  const handleClickBS5 = (by: Sort['by']) => {
+    setTitle(sortByTranslations.current[by])
+    handleSort(by)
+  }
 
   useEffect(() => {
     setTitle(sortByTranslations.current[sort.by])
   }, [sort.by])
 
   return (
-    <Dropdown
-      id="projects-sort-dropdown"
-      className="projects-sort-dropdown"
-      pullRight
-      open={isOpened}
-      onToggle={open => setIsOpened(open)}
-    >
-      <Dropdown.Toggle bsSize="small" noCaret className="pe-0 btn-transparent">
-        <span className="text-truncate me-1">{title}</span>
-        <Icon type="angle-down" />
-      </Dropdown.Toggle>
-      <Dropdown.Menu className="projects-dropdown-menu">
-        <MenuItem header>{t('sort_by')}:</MenuItem>
-        <ItemWithContent
-          column="title"
-          text={t('title')}
-          sort={sort}
-          onClick={() => handleClick('title')}
-        />
-        <ItemWithContent
-          column="owner"
-          text={t('owner')}
-          sort={sort}
-          onClick={() => handleClick('owner')}
-        />
-        <ItemWithContent
-          column="lastUpdated"
-          text={t('last_modified')}
-          sort={sort}
-          onClick={() => handleClick('lastUpdated')}
-        />
-      </Dropdown.Menu>
-    </Dropdown>
+    <BootstrapVersionSwitcher
+      bs3={
+        <BS3Dropdown
+          id="projects-sort-dropdown"
+          className="projects-sort-dropdown"
+          pullRight
+          open={isOpened}
+          onToggle={open => setIsOpened(open)}
+        >
+          <BS3Dropdown.Toggle
+            bsSize="small"
+            noCaret
+            className="pe-0 btn-transparent"
+          >
+            <span className="text-truncate me-1">{title}</span>
+            <Icon type="angle-down" />
+          </BS3Dropdown.Toggle>
+          <BS3Dropdown.Menu className="projects-dropdown-menu">
+            <BS3MenuItem header>{t('sort_by')}:</BS3MenuItem>
+            <ItemWithContent
+              column="title"
+              text={t('title')}
+              sort={sort}
+              onClick={() => handleClick('title')}
+            />
+            <ItemWithContent
+              column="owner"
+              text={t('owner')}
+              sort={sort}
+              onClick={() => handleClick('owner')}
+            />
+            <ItemWithContent
+              column="lastUpdated"
+              text={t('last_modified')}
+              sort={sort}
+              onClick={() => handleClick('lastUpdated')}
+            />
+          </BS3Dropdown.Menu>
+        </BS3Dropdown>
+      }
+      bs5={
+        <Dropdown className="projects-sort-dropdown" align="end">
+          <DropdownToggle
+            id="projects-sort-dropdown"
+            className="pe-0 mb-0 btn-transparent"
+            size="sm"
+            aria-label={t('sort_projects')}
+          >
+            <span className="text-truncate" aria-hidden>
+              {title}
+            </span>
+          </DropdownToggle>
+          <DropdownMenu flip={false}>
+            <DropdownHeader className="text-uppercase">
+              {t('sort_by')}:
+            </DropdownHeader>
+            <ItemWithContent
+              column="title"
+              text={t('title')}
+              sort={sort}
+              onClick={() => handleClickBS5('title')}
+            />
+            <ItemWithContent
+              column="owner"
+              text={t('owner')}
+              sort={sort}
+              onClick={() => handleClickBS5('owner')}
+            />
+            <ItemWithContent
+              column="lastUpdated"
+              text={t('last_modified')}
+              sort={sort}
+              onClick={() => handleClickBS5('lastUpdated')}
+            />
+          </DropdownMenu>
+        </Dropdown>
+      }
+    />
   )
 }
 

+ 3 - 3
services/web/frontend/js/features/project-list/components/modals/create-tag-modal.tsx

@@ -18,9 +18,9 @@ import OLModal, {
 import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
 import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
 import OLButton from '@/features/ui/components/ol/ol-button'
-import OLNotification from '@/features/ui/components/ol/ol-notification'
 import OLFormControl from '@/features/ui/components/ol/ol-form-control'
 import OLForm from '@/features/ui/components/ol/ol-form'
+import Notification from '@/shared/components/notification'
 
 type CreateTagModalProps = {
   id: string
@@ -104,10 +104,10 @@ export default function CreateTagModal({
           </OLFormGroup>
         </OLForm>
         {validationError && (
-          <OLNotification type="error" content={validationError} />
+          <Notification type="error" content={validationError} />
         )}
         {isError && (
-          <OLNotification
+          <Notification
             type="error"
             content={t('generic_something_went_wrong')}
           />

+ 2 - 2
services/web/frontend/js/features/project-list/components/modals/delete-tag-modal.tsx

@@ -11,7 +11,7 @@ import OLModal, {
   OLModalTitle,
 } from '@/features/ui/components/ol/ol-modal'
 import OLButton from '@/features/ui/components/ol/ol-button'
-import OLNotification from '@/features/ui/components/ol/ol-notification'
+import Notification from '@/shared/components/notification'
 
 type DeleteTagModalProps = {
   id: string
@@ -56,7 +56,7 @@ export default function DeleteTagModal({
           <li>{tag.name}</li>
         </ul>
         {isError && (
-          <OLNotification
+          <Notification
             type="error"
             content={t('generic_something_went_wrong')}
           />

+ 4 - 4
services/web/frontend/js/features/project-list/components/modals/edit-tag-modal.tsx

@@ -19,7 +19,7 @@ import OLForm from '@/features/ui/components/ol/ol-form'
 import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
 import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
 import OLButton from '@/features/ui/components/ol/ol-button'
-import OLNotification from '@/features/ui/components/ol/ol-notification'
+import Notification from '@/shared/components/notification'
 
 type EditTagModalProps = {
   id: string
@@ -113,12 +113,12 @@ export function EditTagModal({ id, tag, onEdit, onClose }: EditTagModalProps) {
           </OLFormGroup>
         </OLForm>
         {validationError && (
-          <OLNotification type="error" content={validationError} />
+          <Notification content={validationError} type="error" />
         )}
         {isError && (
-          <OLNotification
-            type="error"
+          <Notification
             content={t('generic_something_went_wrong')}
+            type="error"
           />
         )}
       </OLModalBody>

+ 72 - 62
services/web/frontend/js/features/project-list/components/modals/manage-tag-modal.tsx

@@ -1,7 +1,5 @@
 import { useCallback, useState } from 'react'
 import { useTranslation } from 'react-i18next'
-import { Button, ControlLabel, Form, FormGroup, Modal } from 'react-bootstrap'
-import AccessibleModal from '../../../../shared/components/accessible-modal'
 import useAsync from '../../../../shared/hooks/use-async'
 import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
 import useSelectColor from '../../hooks/use-select-color'
@@ -10,6 +8,19 @@ import { Tag } from '../../../../../../app/src/Features/Tags/types'
 import { getTagColor } from '../../util/tag'
 import { ColorPicker } from '../color-picker/color-picker'
 import { debugConsole } from '@/utils/debugging'
+import OLModal, {
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
+import OLForm from '@/features/ui/components/ol/ol-form'
+import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
+import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import Notification from '@/shared/components/notification'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 type ManageTagModalProps = {
   id: string
@@ -78,14 +89,14 @@ export function ManageTagModal({
   }
 
   return (
-    <AccessibleModal show animation onHide={onClose} id={id} backdrop="static">
-      <Modal.Header closeButton>
-        <Modal.Title>{t('edit_tag')}</Modal.Title>
-      </Modal.Header>
+    <OLModal show animation onHide={onClose} id={id} backdrop="static">
+      <OLModalHeader closeButton>
+        <OLModalTitle>{t('edit_tag')}</OLModalTitle>
+      </OLModalHeader>
 
-      <Modal.Body>
-        <Form name="editTagRenameForm" onSubmit={handleSubmit}>
-          <FormGroup>
+      <OLModalBody>
+        <OLForm onSubmit={handleSubmit}>
+          <OLFormGroup>
             <input
               ref={autoFocusedRef}
               className="form-control"
@@ -96,61 +107,60 @@ export function ManageTagModal({
               required
               onChange={e => setNewTagName(e.target.value)}
             />
-          </FormGroup>
-          <FormGroup aria-hidden="true">
-            <ControlLabel>{t('tag_color')}</ControlLabel>:<br />
+          </OLFormGroup>
+          <OLFormGroup aria-hidden="true">
+            <OLFormLabel>{t('tag_color')}</OLFormLabel>:<br />
             <ColorPicker disableCustomColor />
-          </FormGroup>
-        </Form>
-      </Modal.Body>
-
-      <Modal.Footer>
-        <div className="clearfix">
-          <div className="modal-footer-left">
-            <Button
-              onClick={() => runDeleteTag(tag._id)}
-              bsStyle="danger"
-              disabled={isDeleteLoading || isUpdateLoading}
-            >
-              {isDeleteLoading ? (
-                <>{t('deleting')} &hellip;</>
-              ) : (
-                t('delete_tag')
-              )}
-            </Button>
-          </div>
-          <Button
-            onClick={onClose}
-            disabled={isDeleteLoading || isUpdateLoading}
-          >
-            {t('save_or_cancel-cancel')}
-          </Button>
-          <Button
-            onClick={() => runUpdateTag(tag._id)}
-            bsStyle={null}
-            className="btn-secondary"
-            disabled={Boolean(
-              isUpdateLoading ||
-                isDeleteLoading ||
-                !newTagName?.length ||
-                (newTagName === tag?.name && selectedColor === getTagColor(tag))
-            )}
-          >
-            {isUpdateLoading ? (
-              <>{t('saving')} &hellip;</>
-            ) : (
-              t('save_or_cancel-save')
-            )}
-          </Button>
-        </div>
+          </OLFormGroup>
+        </OLForm>
         {(isDeleteError || isRenameError) && (
-          <div className="modal-footer-left mt-2">
-            <span className="text-danger error">
-              {t('generic_something_went_wrong')}
-            </span>
-          </div>
+          <Notification
+            type="error"
+            content={t('generic_something_went_wrong')}
+          />
         )}
-      </Modal.Footer>
-    </AccessibleModal>
+      </OLModalBody>
+
+      <OLModalFooter>
+        <OLButton
+          variant="danger"
+          onClick={() => runDeleteTag(tag._id)}
+          className={bsVersion({ bs3: 'pull-left', bs5: 'me-auto' })}
+          disabled={isDeleteLoading || isUpdateLoading}
+          isLoading={isDeleteLoading}
+          bs3Props={{
+            loading: isDeleteLoading ? `${t('deleting')}…` : t('delete_tag'),
+          }}
+        >
+          {t('delete_tag')}
+        </OLButton>
+        <OLButton
+          variant="secondary"
+          onClick={onClose}
+          disabled={isDeleteLoading || isUpdateLoading}
+        >
+          {t('save_or_cancel-cancel')}
+        </OLButton>
+        <OLButton
+          variant="primary"
+          onClick={() => runUpdateTag(tag._id)}
+          disabled={Boolean(
+            isUpdateLoading ||
+              isDeleteLoading ||
+              !newTagName?.length ||
+              (newTagName === tag?.name && selectedColor === getTagColor(tag))
+          )}
+          isLoading={isUpdateLoading}
+          bs3Props={{
+            loading: isUpdateLoading
+              ? `${t('saving')}…`
+              : t('save_or_cancel-save'),
+          }}
+        >
+          {t('save_or_cancel-save')}
+        </OLButton>
+        <BootstrapVersionSwitcher bs3={<div className="clearfix" />} />
+      </OLModalFooter>
+    </OLModal>
   )
 }

+ 5 - 1
services/web/frontend/js/features/project-list/components/project-list-root.tsx

@@ -191,7 +191,11 @@ function ProjectListPageContent() {
                   bsVersion({ bs5: 'd-md-none', bs3: 'visible-xs' })
                 )}
               >
-                <div role="toolbar" className="projects-toolbar">
+                <div
+                  role="toolbar"
+                  className="projects-toolbar"
+                  aria-label={t('projects')}
+                >
                   <ProjectsDropdown />
                   <SortByDropdown />
                 </div>

+ 1 - 1
services/web/frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button.tsx

@@ -54,7 +54,7 @@ const DownloadProjectButtonTooltip = memo(
                 className="action-btn"
                 icon={
                   bsVersion({
-                    bs5: 'cloud_download',
+                    bs5: 'download',
                     bs3: 'cloud-download',
                   }) as string
                 }

+ 19 - 12
services/web/frontend/js/features/project-list/components/table/cells/owner-cell.tsx

@@ -3,6 +3,8 @@ import Icon from '../../../../../shared/components/icon'
 import { getOwnerName } from '../../../util/project'
 import { Project } from '../../../../../../../types/project/dashboard/api'
 import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import MaterialIcon from '@/shared/components/material-icon'
 
 type LinkSharingIconProps = {
   prependSpace: boolean
@@ -26,10 +28,21 @@ function LinkSharingIcon({
       {/* OverlayTrigger won't fire unless icon is wrapped in a span */}
       <span className={className}>
         {prependSpace ? ' ' : ''}
-        <Icon
-          type="link"
-          className="small"
-          accessibilityLabel={t('link_sharing')}
+        <BootstrapVersionSwitcher
+          bs3={
+            <Icon
+              type="link"
+              className="small"
+              accessibilityLabel={t('link_sharing')}
+            />
+          }
+          bs5={
+            <MaterialIcon
+              type="link"
+              className="align-text-bottom"
+              accessibilityLabel={t('link_sharing')}
+            />
+          }
         />
       </span>
     </OLTooltip>
@@ -48,14 +61,8 @@ export default function OwnerCell({ project }: OwnerCellProps) {
   return (
     <>
       {ownerName === 'You' ? t('you') : ownerName}
-      {project.source === 'token' ? (
-        <LinkSharingIcon
-          className="hidden-xs"
-          project={project}
-          prependSpace={!!project.owner}
-        />
-      ) : (
-        ''
+      {project.source === 'token' && (
+        <LinkSharingIcon project={project} prependSpace={!!project.owner} />
       )}
     </>
   )

+ 15 - 10
services/web/frontend/js/features/project-list/components/table/project-tools/buttons/archive-projects-button.tsx

@@ -1,12 +1,13 @@
 import { memo, useCallback, useState } from 'react'
 import { useTranslation } from 'react-i18next'
-import Icon from '../../../../../../shared/components/icon'
-import Tooltip from '../../../../../../shared/components/tooltip'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
+import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
 import ArchiveProjectModal from '../../../modals/archive-project-modal'
 import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
 import { useProjectListContext } from '../../../../context/project-list-context'
 import { archiveProject } from '../../../../util/api'
 import { Project } from '../../../../../../../../types/project/dashboard/api'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 function ArchiveProjectsButton() {
   const { selectedProjects, toggleSelectedProject, updateProjectViewData } =
@@ -40,19 +41,23 @@ function ArchiveProjectsButton() {
 
   return (
     <>
-      <Tooltip
+      <OLTooltip
         id="tooltip-archive-projects"
         description={text}
         overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
       >
-        <button
-          className="btn btn-secondary"
-          aria-label={text}
+        <OLIconButton
           onClick={handleOpenModal}
-        >
-          <Icon type="inbox" />
-        </button>
-      </Tooltip>
+          variant="secondary"
+          accessibilityLabel={text}
+          icon={
+            bsVersion({
+              bs5: 'inbox',
+              bs3: 'inbox',
+            }) as string
+          }
+        />
+      </OLTooltip>
       <ArchiveProjectModal
         projects={selectedProjects}
         actionHandler={handleArchiveProject}

+ 3 - 3
services/web/frontend/js/features/project-list/components/table/project-tools/buttons/delete-leave-projects-button.tsx

@@ -1,5 +1,5 @@
 import { useState } from 'react'
-import { Button } from 'react-bootstrap'
+import OLButton from '@/features/ui/components/ol/ol-button'
 import { useTranslation } from 'react-i18next'
 import DeleteLeaveProjectModal from '../../../modals/delete-leave-project-modal'
 import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
@@ -41,9 +41,9 @@ function DeleteLeaveProjectsButton() {
   return (
     <>
       {hasDeletableProjectsSelected && hasLeavableProjectsSelected && (
-        <Button bsStyle={null} className="btn-danger" onClick={handleOpenModal}>
+        <OLButton variant="danger" onClick={handleOpenModal}>
           {t('delete_and_leave')}
-        </Button>
+        </OLButton>
       )}
       <DeleteLeaveProjectModal
         projects={selectedProjects}

+ 3 - 3
services/web/frontend/js/features/project-list/components/table/project-tools/buttons/delete-projects-button.tsx

@@ -1,5 +1,5 @@
 import { useState } from 'react'
-import { Button } from 'react-bootstrap'
+import OLButton from '@/features/ui/components/ol/ol-button'
 import { useTranslation } from 'react-i18next'
 import DeleteProjectModal from '../../../modals/delete-project-modal'
 import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
@@ -37,9 +37,9 @@ function DeleteProjectsButton() {
   return (
     <>
       {hasDeletableProjectsSelected && !hasLeavableProjectsSelected && (
-        <Button bsStyle={null} className="btn-danger" onClick={handleOpenModal}>
+        <OLButton variant="danger" onClick={handleOpenModal}>
           {t('delete')}
-        </Button>
+        </OLButton>
       )}
       <DeleteProjectModal
         projects={selectedProjects}

+ 15 - 10
services/web/frontend/js/features/project-list/components/table/project-tools/buttons/download-projects-button.tsx

@@ -1,11 +1,12 @@
 import { memo, useCallback } from 'react'
 import { useTranslation } from 'react-i18next'
-import Icon from '../../../../../../shared/components/icon'
-import Tooltip from '../../../../../../shared/components/tooltip'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
+import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
 import * as eventTracking from '../../../../../../infrastructure/event-tracking'
 import { useProjectListContext } from '../../../../context/project-list-context'
 import { useLocation } from '../../../../../../shared/hooks/use-location'
 import { isSmallDevice } from '../../../../../../infrastructure/event-tracking'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 function DownloadProjectsButton() {
   const { selectedProjects, selectOrUnselectAllProjects } =
@@ -29,19 +30,23 @@ function DownloadProjectsButton() {
   }, [projectIds, selectOrUnselectAllProjects, location])
 
   return (
-    <Tooltip
+    <OLTooltip
       id="tooltip-download-projects"
       description={text}
       overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
     >
-      <button
-        className="btn btn-secondary"
-        aria-label={text}
+      <OLIconButton
         onClick={handleDownloadProjects}
-      >
-        <Icon type="cloud-download" />
-      </button>
-    </Tooltip>
+        variant="secondary"
+        accessibilityLabel={text}
+        icon={
+          bsVersion({
+            bs5: 'download',
+            bs3: 'cloud-download',
+          }) as string
+        }
+      />
+    </OLTooltip>
   )
 }
 

+ 3 - 3
services/web/frontend/js/features/project-list/components/table/project-tools/buttons/leave-projects-button.tsx

@@ -1,5 +1,5 @@
 import { useState } from 'react'
-import { Button } from 'react-bootstrap'
+import OLButton from '@/features/ui/components/ol/ol-button'
 import { useTranslation } from 'react-i18next'
 import LeaveProjectModal from '../../../modals/leave-project-modal'
 import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
@@ -37,9 +37,9 @@ function LeaveProjectsButton() {
   return (
     <>
       {!hasDeletableProjectsSelected && hasLeavableProjectsSelected && (
-        <Button bsStyle={null} className="btn-danger" onClick={handleOpenModal}>
+        <OLButton variant="danger" onClick={handleOpenModal}>
           {t('leave')}
-        </Button>
+        </OLButton>
       )}
       <LeaveProjectModal
         projects={selectedProjects}

+ 42 - 13
services/web/frontend/js/features/project-list/components/table/project-tools/buttons/project-tools-more-dropdown-button.tsx

@@ -1,25 +1,54 @@
 import { memo } from 'react'
-import { Dropdown } from 'react-bootstrap'
+import { Dropdown as BS3Dropdown } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import ControlledDropdown from '../../../../../../shared/components/controlled-dropdown'
 import CopyProjectMenuItem from '../menu-items/copy-project-menu-item'
 import RenameProjectMenuItem from '../menu-items/rename-project-menu-item'
+import {
+  Dropdown,
+  DropdownMenu,
+  DropdownToggle,
+} from '@/features/ui/components/bootstrap-5/dropdown-menu'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 
 function ProjectToolsMoreDropdownButton() {
   const { t } = useTranslation()
+
   return (
-    <ControlledDropdown id="project-tools-more-dropdown">
-      <Dropdown.Toggle bsStyle={null} className="btn-secondary">
-        {t('more')}
-      </Dropdown.Toggle>
-      <Dropdown.Menu
-        className="dropdown-menu-right"
-        data-testid="project-tools-more-dropdown-menu"
-      >
-        <RenameProjectMenuItem />
-        <CopyProjectMenuItem />
-      </Dropdown.Menu>
-    </ControlledDropdown>
+    <BootstrapVersionSwitcher
+      bs3={
+        <ControlledDropdown id="project-tools-more-dropdown">
+          <BS3Dropdown.Toggle bsStyle={null} className="btn-secondary">
+            {t('more')}
+          </BS3Dropdown.Toggle>
+          <BS3Dropdown.Menu
+            className="dropdown-menu-right"
+            data-testid="project-tools-more-dropdown-menu"
+          >
+            <RenameProjectMenuItem />
+            <CopyProjectMenuItem />
+          </BS3Dropdown.Menu>
+        </ControlledDropdown>
+      }
+      bs5={
+        <Dropdown align="end">
+          <DropdownToggle id="project-tools-more-dropdown" variant="secondary">
+            {t('more')}
+          </DropdownToggle>
+          <DropdownMenu
+            flip={false}
+            data-testid="project-tools-more-dropdown-menu"
+          >
+            <li role="none">
+              <RenameProjectMenuItem />
+            </li>
+            <li role="none">
+              <CopyProjectMenuItem />
+            </li>
+          </DropdownMenu>
+        </Dropdown>
+      }
+    />
   )
 }
 

+ 124 - 54
services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx

@@ -1,6 +1,6 @@
 import { sortBy } from 'lodash'
 import { memo, useCallback } from 'react'
-import { Button, Dropdown } from 'react-bootstrap'
+import { Button, Dropdown as BS3Dropdown } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import ControlledDropdown from '../../../../../../shared/components/controlled-dropdown'
 import Icon from '../../../../../../shared/components/icon'
@@ -9,6 +9,15 @@ import { useProjectListContext } from '../../../../context/project-list-context'
 import useTag from '../../../../hooks/use-tag'
 import { addProjectsToTag, removeProjectsFromTag } from '../../../../util/api'
 import { getTagColor } from '../../../../util/tag'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import {
+  Dropdown,
+  DropdownDivider,
+  DropdownHeader,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
+} from '@/features/ui/components/bootstrap-5/dropdown-menu'
 
 function TagsDropdown() {
   const {
@@ -84,65 +93,126 @@ function TagsDropdown() {
 
   return (
     <>
-      <ControlledDropdown id="tags">
-        <Dropdown.Toggle
-          bsStyle={null}
-          className="btn-secondary"
-          title={t('tags')}
-          aria-label={t('tags')}
-        >
-          <MaterialIcon type="label" style={{ verticalAlign: 'sub' }} />
-        </Dropdown.Toggle>
-        <Dropdown.Menu className="dropdown-menu-right">
-          <li className="dropdown-header" role="heading" aria-level={3}>
-            {t('add_to_tag')}
-          </li>
-          {sortBy(tags, tag => tag.name?.toLowerCase()).map(tag => {
-            return (
-              <li key={tag._id}>
+      <BootstrapVersionSwitcher
+        bs3={
+          <ControlledDropdown id="tags">
+            <BS3Dropdown.Toggle
+              bsStyle={null}
+              className="btn-secondary"
+              aria-label={t('tags')}
+            >
+              <MaterialIcon type="label" style={{ verticalAlign: 'sub' }} />
+            </BS3Dropdown.Toggle>
+            <BS3Dropdown.Menu className="dropdown-menu-right">
+              <li className="dropdown-header" role="heading" aria-level={3}>
+                {t('add_to_tag')}
+              </li>
+              {sortBy(tags, tag => tag.name?.toLowerCase()).map(tag => {
+                return (
+                  <li key={tag._id}>
+                    <Button
+                      className="tag-dropdown-button"
+                      onClick={e =>
+                        containsAllSelectedProjects(tag)
+                          ? handleRemoveTagFromSelectedProjects(e, tag._id)
+                          : handleAddTagToSelectedProjects(e, tag._id)
+                      }
+                      aria-label={t('add_or_remove_project_from_tag', {
+                        tagName: tag.name,
+                      })}
+                    >
+                      <Icon
+                        type={
+                          containsAllSelectedProjects(tag)
+                            ? 'check-square-o'
+                            : containsSomeSelectedProjects(tag)
+                              ? 'minus-square-o'
+                              : 'square-o'
+                        }
+                        className="tag-checkbox"
+                      />{' '}
+                      <span
+                        className="tag-dot"
+                        style={{
+                          backgroundColor: getTagColor(tag),
+                        }}
+                      />{' '}
+                      {tag.name}
+                    </Button>
+                  </li>
+                )
+              })}
+              <li className="divider" />
+              <li>
                 <Button
                   className="tag-dropdown-button"
-                  onClick={e =>
-                    containsAllSelectedProjects(tag)
-                      ? handleRemoveTagFromSelectedProjects(e, tag._id)
-                      : handleAddTagToSelectedProjects(e, tag._id)
-                  }
-                  aria-label={t('add_or_remove_project_from_tag', {
-                    tagName: tag.name,
-                  })}
+                  onClick={handleOpenCreateTagModal}
                 >
-                  <Icon
-                    type={
-                      containsAllSelectedProjects(tag)
-                        ? 'check-square-o'
-                        : containsSomeSelectedProjects(tag)
-                          ? 'minus-square-o'
-                          : 'square-o'
-                    }
-                    className="tag-checkbox"
-                  />{' '}
-                  <span
-                    className="tag-dot"
-                    style={{
-                      backgroundColor: getTagColor(tag),
-                    }}
-                  />{' '}
-                  {tag.name}
+                  {t('create_new_tag')}
                 </Button>
               </li>
-            )
-          })}
-          <li className="divider" />
-          <li>
-            <Button
-              className="tag-dropdown-button"
-              onClick={handleOpenCreateTagModal}
+            </BS3Dropdown.Menu>
+          </ControlledDropdown>
+        }
+        bs5={
+          <Dropdown align="end" autoClose="outside">
+            <DropdownToggle
+              id="project-tools-more-dropdown"
+              variant="secondary"
+              aria-label={t('tags')}
+            >
+              <MaterialIcon type="label" className="align-text-top" />
+            </DropdownToggle>
+            <DropdownMenu
+              flip={false}
+              data-testid="project-tools-more-dropdown-menu"
             >
-              {t('create_new_tag')}
-            </Button>
-          </li>
-        </Dropdown.Menu>
-      </ControlledDropdown>
+              <DropdownHeader>{t('add_to_tag')}</DropdownHeader>
+              {sortBy(tags, tag => tag.name?.toLowerCase()).map(
+                (tag, index) => (
+                  <li role="none" key={tag._id}>
+                    <DropdownItem
+                      onClick={e =>
+                        containsAllSelectedProjects(tag)
+                          ? handleRemoveTagFromSelectedProjects(e, tag._id)
+                          : handleAddTagToSelectedProjects(e, tag._id)
+                      }
+                      aria-label={t('add_or_remove_project_from_tag', {
+                        tagName: tag.name,
+                      })}
+                      as="button"
+                      tabIndex={-1}
+                      leadingIcon={
+                        containsAllSelectedProjects(tag) ? (
+                          'check'
+                        ) : (
+                          <DropdownItem.EmptyLeadingIcon />
+                        )
+                      }
+                    >
+                      <span
+                        className="badge-tag-circle align-self-center ms-0"
+                        style={{ backgroundColor: getTagColor(tag) }}
+                      />
+                      <span className="text-truncate">{tag.name}</span>
+                    </DropdownItem>
+                  </li>
+                )
+              )}
+              <DropdownDivider />
+              <li role="none">
+                <DropdownItem
+                  onClick={handleOpenCreateTagModal}
+                  as="button"
+                  tabIndex={-1}
+                >
+                  {t('create_new_tag')}
+                </DropdownItem>
+              </li>
+            </DropdownMenu>
+          </Dropdown>
+        }
+      />
       <CreateTagModal id="toolbar-create-tag-modal" />
     </>
   )

+ 15 - 10
services/web/frontend/js/features/project-list/components/table/project-tools/buttons/trash-projects-button.tsx

@@ -1,12 +1,13 @@
 import { memo, useCallback, useState } from 'react'
 import { useTranslation } from 'react-i18next'
-import Icon from '../../../../../../shared/components/icon'
-import Tooltip from '../../../../../../shared/components/tooltip'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
+import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
 import TrashProjectModal from '../../../modals/trash-project-modal'
 import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
 import { useProjectListContext } from '../../../../context/project-list-context'
 import { trashProject } from '../../../../util/api'
 import { Project } from '../../../../../../../../types/project/dashboard/api'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 function TrashProjectsButton() {
   const { selectedProjects, toggleSelectedProject, updateProjectViewData } =
@@ -40,19 +41,23 @@ function TrashProjectsButton() {
 
   return (
     <>
-      <Tooltip
+      <OLTooltip
         id="tooltip-trash-projects"
         description={text}
         overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
       >
-        <button
-          className="btn btn-secondary"
-          aria-label={text}
+        <OLIconButton
           onClick={handleOpenModal}
-        >
-          <Icon type="trash" />
-        </button>
-      </Tooltip>
+          variant="secondary"
+          accessibilityLabel={text}
+          icon={
+            bsVersion({
+              bs5: 'delete',
+              bs3: 'trash',
+            }) as string
+          }
+        />
+      </OLTooltip>
       <TrashProjectModal
         projects={selectedProjects}
         actionHandler={handleTrashProject}

+ 4 - 8
services/web/frontend/js/features/project-list/components/table/project-tools/buttons/unarchive-projects-button.tsx

@@ -1,5 +1,5 @@
 import { memo } from 'react'
-import { Button } from 'react-bootstrap'
+import OLButton from '@/features/ui/components/ol/ol-button'
 import { useTranslation } from 'react-i18next'
 import { useProjectListContext } from '../../../../context/project-list-context'
 import { unarchiveProject } from '../../../../util/api'
@@ -18,13 +18,9 @@ function UnarchiveProjectsButton() {
   }
 
   return (
-    <Button
-      bsStyle={null}
-      className="btn-secondary"
-      onClick={handleUnarchiveProjects}
-    >
-      {t('unarchive')}
-    </Button>
+    <OLButton variant="secondary" onClick={handleUnarchiveProjects}>
+      {t('untrash')}
+    </OLButton>
   )
 }
 

+ 3 - 7
services/web/frontend/js/features/project-list/components/table/project-tools/buttons/untrash-projects-button.tsx

@@ -1,5 +1,5 @@
 import { memo } from 'react'
-import { Button } from 'react-bootstrap'
+import OLButton from '@/features/ui/components/ol/ol-button'
 import { useTranslation } from 'react-i18next'
 import { useProjectListContext } from '../../../../context/project-list-context'
 import { untrashProject } from '../../../../util/api'
@@ -18,13 +18,9 @@ function UntrashProjectsButton() {
   }
 
   return (
-    <Button
-      bsStyle={null}
-      className="btn-secondary"
-      onClick={handleUntrashProjects}
-    >
+    <OLButton variant="secondary" onClick={handleUntrashProjects}>
       {t('untrash')}
-    </Button>
+    </OLButton>
   )
 }
 

+ 4 - 2
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 { MenuItem } from 'react-bootstrap'
+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,6 +64,9 @@ function CopyProjectMenuItem() {
 
   return (
     <>
+      <OlDropdownMenuItem onClick={handleOpenModal} as="button" tabIndex={-1}>
+        {t('make_a_copy')}
+      </OlDropdownMenuItem>
       <CloneProjectModal
         show={showModal}
         handleHide={handleCloseModal}
@@ -72,7 +75,6 @@ function CopyProjectMenuItem() {
         projectName={selectedProjects[0].name}
         projectTags={projectTags}
       />
-      <MenuItem onClick={handleOpenModal}>{t('make_a_copy')}</MenuItem>
     </>
   )
 }

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

@@ -1,8 +1,8 @@
 import { memo, useCallback, useState } from 'react'
-import { MenuItem } from 'react-bootstrap'
 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 RenameProjectModal from '../../../modals/rename-project-modal'
 
 function RenameProjectMenuItem() {
@@ -34,7 +34,9 @@ function RenameProjectMenuItem() {
 
   return (
     <>
-      <MenuItem onClick={handleOpenModal}>{t('rename')}</MenuItem>
+      <OlDropdownMenuItem onClick={handleOpenModal} as="button" tabIndex={-1}>
+        {t('rename')}
+      </OlDropdownMenuItem>
       <RenameProjectModal
         handleCloseModal={handleCloseModal}
         showModal={showModal}

+ 24 - 18
services/web/frontend/js/features/project-list/components/table/project-tools/project-tools.tsx

@@ -1,5 +1,5 @@
 import { memo } from 'react'
-import { ButtonGroup, ButtonToolbar } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
 import { useProjectListContext } from '../../../context/project-list-context'
 import ArchiveProjectsButton from './buttons/archive-projects-button'
 import DownloadProjectsButton from './buttons/download-projects-button'
@@ -11,38 +11,44 @@ import UntrashProjectsButton from './buttons/untrash-projects-button'
 import DeleteLeaveProjectsButton from './buttons/delete-leave-projects-button'
 import LeaveProjectsButton from './buttons/leave-projects-button'
 import DeleteProjectsButton from './buttons/delete-projects-button'
+import OlButtonToolbar from '@/features/ui/components/ol/ol-button-toolbar'
+import OlButtonGroup from '@/features/ui/components/ol/ol-button-group'
 
 function ProjectTools() {
+  const { t } = useTranslation()
   const { filter, selectedProjects } = useProjectListContext()
+
   return (
-    <ButtonToolbar>
-      <ButtonGroup>
+    <OlButtonToolbar aria-label={t('toolbar_selected_projects')}>
+      <OlButtonGroup
+        aria-label={t('toolbar_selected_projects_management_actions')}
+      >
         <DownloadProjectsButton />
         {filter !== 'archived' && <ArchiveProjectsButton />}
         {filter !== 'trashed' && <TrashProjectsButton />}
-      </ButtonGroup>
+      </OlButtonGroup>
 
-      <ButtonGroup>
-        {filter === 'trashed' && <UntrashProjectsButton />}
-        {filter === 'archived' && <UnarchiveProjectsButton />}
-      </ButtonGroup>
+      {(filter === 'trashed' || filter === 'archived') && (
+        <OlButtonGroup aria-label={t('toolbar_selected_projects_restore')}>
+          {filter === 'trashed' && <UntrashProjectsButton />}
+          {filter === 'archived' && <UnarchiveProjectsButton />}
+        </OlButtonGroup>
+      )}
 
-      <ButtonGroup>
-        {filter === 'trashed' && (
-          <>
-            <LeaveProjectsButton />
-            <DeleteProjectsButton />
-            <DeleteLeaveProjectsButton />
-          </>
-        )}
-      </ButtonGroup>
+      {filter === 'trashed' && (
+        <OlButtonGroup aria-label={t('toolbar_selected_projects_remove')}>
+          <LeaveProjectsButton />
+          <DeleteProjectsButton />
+          <DeleteLeaveProjectsButton />
+        </OlButtonGroup>
+      )}
 
       {!['archived', 'trashed'].includes(filter) && <TagsDropdown />}
 
       {selectedProjects.length === 1 &&
         filter !== 'archived' &&
         filter !== 'trashed' && <ProjectToolsMoreDropdownButton />}
-    </ButtonToolbar>
+    </OlButtonToolbar>
   )
 }
 

+ 141 - 64
services/web/frontend/js/features/project-list/components/tags-list.tsx

@@ -11,10 +11,12 @@ import {
 import useTag from '../hooks/use-tag'
 import { sortBy } from 'lodash'
 import { Tag } from '../../../../../app/src/Features/Tags/types'
+import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 
 type TagsListProps = {
-  onTagClick: () => void
-  onEditClick: () => void
+  onTagClick?: () => void
+  onEditClick?: () => void
 }
 
 function TagsList({ onTagClick, onEditClick }: TagsListProps) {
@@ -32,76 +34,151 @@ function TagsList({ onTagClick, onEditClick }: TagsListProps) {
 
   const handleClick = (e: React.MouseEvent, tag: Tag) => {
     handleSelectTag(e, tag._id)
-    onTagClick()
+    onTagClick?.()
   }
 
   return (
     <>
-      {sortBy(tags, ['name']).map((tag, index) => (
-        <MenuItemButton
-          key={index}
-          onClick={e => handleClick(e as unknown as React.MouseEvent, tag)}
-          className="projects-types-menu-item projects-types-menu-tag-item"
-          afterNode={
-            <Button
-              onClick={e => {
-                e.stopPropagation()
-                handleManageTag(e, tag._id)
-                onEditClick()
+      <BootstrapVersionSwitcher
+        bs3={
+          <>
+            {sortBy(tags, ['name']).map((tag, index) => (
+              <MenuItemButton
+                key={index}
+                onClick={e =>
+                  handleClick(e as unknown as React.MouseEvent, tag)
+                }
+                className="projects-types-menu-item projects-types-menu-tag-item"
+                afterNode={
+                  <Button
+                    onClick={e => {
+                      e.stopPropagation()
+                      handleManageTag(e, tag._id)
+                      onEditClick?.()
+                    }}
+                    className="btn-transparent edit-btn me-2"
+                    bsStyle={null}
+                  >
+                    <Icon type="pencil" fw />
+                  </Button>
+                }
+              >
+                <span className="tag-item menu-item-button-text">
+                  {selectedTagId === tag._id ? (
+                    <Icon type="check" className="menu-item-button-icon" />
+                  ) : null}
+                  <span
+                    className="me-2"
+                    style={{
+                      color: getTagColor(tag),
+                    }}
+                  >
+                    <MaterialIcon
+                      type="label"
+                      style={{ verticalAlign: 'sub' }}
+                    />
+                  </span>
+                  <span>
+                    {tag.name}{' '}
+                    <span className="subdued">
+                      {' '}
+                      ({tag.project_ids?.length})
+                    </span>
+                  </span>
+                </span>
+              </MenuItemButton>
+            ))}
+            <MenuItemButton
+              className="untagged projects-types-menu-item"
+              onClick={() => {
+                selectTag(UNCATEGORIZED_KEY)
+                onTagClick?.()
               }}
-              className="btn-transparent edit-btn me-2"
-              bsStyle={null}
             >
-              <Icon type="pencil" fw />
-            </Button>
-          }
-        >
-          <span className="tag-item menu-item-button-text">
-            {selectedTagId === tag._id ? (
-              <Icon type="check" className="menu-item-button-icon" />
-            ) : null}
-            <span
-              className="me-2"
-              style={{
-                color: getTagColor(tag),
+              {selectedTagId === UNCATEGORIZED_KEY ? (
+                <Icon type="check" className="menu-item-button-icon" />
+              ) : null}
+              <span className="tag-item menu-item-button-text">
+                {t('uncategorized')}&nbsp;
+                <span className="subdued">({untaggedProjectsCount})</span>
+              </span>
+            </MenuItemButton>
+            <MenuItemButton
+              onClick={() => {
+                openCreateTagModal()
+                onTagClick?.()
               }}
+              className="projects-types-menu-item"
             >
-              <MaterialIcon type="label" style={{ verticalAlign: 'sub' }} />
-            </span>
-            <span>
-              {tag.name}{' '}
-              <span className="subdued"> ({tag.project_ids?.length})</span>
-            </span>
-          </span>
-        </MenuItemButton>
-      ))}
-      <MenuItemButton
-        className="untagged projects-types-menu-item"
-        onClick={() => {
-          selectTag(UNCATEGORIZED_KEY)
-          onTagClick()
-        }}
-      >
-        {selectedTagId === UNCATEGORIZED_KEY ? (
-          <Icon type="check" className="menu-item-button-icon" />
-        ) : null}
-        <span className="tag-item menu-item-button-text">
-          {t('uncategorized')}&nbsp;
-          <span className="subdued">({untaggedProjectsCount})</span>
-        </span>
-      </MenuItemButton>
-      <MenuItemButton
-        onClick={() => {
-          openCreateTagModal()
-          onTagClick()
-        }}
-        className="projects-types-menu-item"
-      >
-        <span className="tag-item menu-item-button-text">
-          <Icon type="plus" className="me-2" />
-          <span>{t('new_tag')}</span>
-        </span>
-      </MenuItemButton>
+              <span className="tag-item menu-item-button-text">
+                <Icon type="plus" className="me-2" />
+                <span>{t('new_tag')}</span>
+              </span>
+            </MenuItemButton>
+          </>
+        }
+        bs5={
+          <>
+            {sortBy(tags, ['name']).map((tag, index) => (
+              <li role="none" className="position-relative" key={index}>
+                <DropdownItem
+                  as="button"
+                  tabIndex={-1}
+                  onClick={e =>
+                    handleClick(e as unknown as React.MouseEvent, tag)
+                  }
+                  leadingIcon={
+                    <span style={{ color: getTagColor(tag) }}>
+                      <MaterialIcon type="label" className="align-text-top" />
+                    </span>
+                  }
+                  trailingIcon={selectedTagId === tag._id ? 'check' : undefined}
+                  active={selectedTagId === tag._id}
+                >
+                  <span className="project-menu-item-tag-name text-truncate">
+                    {tag.name}&nbsp;({tag.project_ids?.length})
+                  </span>
+                </DropdownItem>
+                <DropdownItem
+                  as="button"
+                  tabIndex={-1}
+                  className="project-menu-item-edit-btn"
+                  onClick={e => {
+                    e.stopPropagation()
+                    handleManageTag(e, tag._id)
+                  }}
+                  aria-label={t('edit_tag')}
+                >
+                  <MaterialIcon type="edit" className="align-text-top" />
+                </DropdownItem>
+              </li>
+            ))}
+            <li role="none">
+              <DropdownItem
+                as="button"
+                tabIndex={-1}
+                onClick={() => selectTag(UNCATEGORIZED_KEY)}
+                trailingIcon={
+                  selectedTagId === UNCATEGORIZED_KEY ? 'check' : undefined
+                }
+                active={selectedTagId === UNCATEGORIZED_KEY}
+              >
+                {t('uncategorized')}&nbsp;({untaggedProjectsCount})
+              </DropdownItem>
+            </li>
+            <li role="none">
+              <DropdownItem
+                as="button"
+                tabIndex={-1}
+                onClick={openCreateTagModal}
+                leadingIcon="add"
+              >
+                {t('new_tag')}
+              </DropdownItem>
+            </li>
+          </>
+        }
+      />
       <CreateTagModal id="create-tag-modal-dropdown" disableCustomColor />
       <ManageTagModal id="manage-tag-modal-dropdown" />
     </>

+ 3 - 3
services/web/frontend/js/features/project-list/components/title/project-list-title.tsx

@@ -16,6 +16,7 @@ function ProjectListTitle({
 }) {
   const { t } = useTranslation()
   let message = t('projects')
+
   if (selectedTag) {
     message = `${selectedTag.name}`
   } else if (selectedTagId === UNCATEGORIZED_KEY) {
@@ -39,10 +40,9 @@ function ProjectListTitle({
         break
     }
   }
+
   return (
-    <div className={classnames('project-list-title', className)}>
-      <span>{message}</span>
-    </div>
+    <div className={classnames('project-list-title', className)}>{message}</div>
   )
 }
 

+ 2 - 2
services/web/frontend/js/features/ui/components/bootstrap-5/bootstrap-version-switcher.tsx

@@ -1,8 +1,8 @@
 import { isBootstrap5 } from '@/features/utils/bootstrap-5'
 
 type BootstrapVersionSwitcherProps = {
-  bs3: React.ReactNode
-  bs5: React.ReactNode
+  bs3?: React.ReactNode
+  bs5?: React.ReactNode
 }
 
 function BootstrapVersionSwitcher({

+ 65 - 45
services/web/frontend/js/features/ui/components/bootstrap-5/button.tsx

@@ -1,3 +1,4 @@
+import { forwardRef } from 'react'
 import { Button as BS5Button, Spinner } from 'react-bootstrap-5'
 import type { ButtonProps } from '@/features/ui/components/types/button-props'
 import classNames from 'classnames'
@@ -10,52 +11,71 @@ const sizeClasses = new Map<ButtonProps['size'], string>([
   ['large', 'btn-lg'],
 ])
 
-export default function Button({
-  children,
-  className,
-  leadingIcon,
-  isLoading = false,
-  loadingLabel,
-  size = 'default',
-  trailingIcon,
-  variant = 'primary',
-  ...props
-}: ButtonProps) {
-  const { t } = useTranslation()
+const Button = forwardRef<HTMLButtonElement, ButtonProps>(
+  (
+    {
+      children,
+      className,
+      leadingIcon,
+      isLoading = false,
+      loadingLabel,
+      size = 'default',
+      trailingIcon,
+      variant = 'primary',
+      ...props
+    },
+    ref
+  ) => {
+    const { t } = useTranslation()
 
-  const sizeClass = sizeClasses.get(size)
-  const buttonClassName = classNames('d-inline-grid', sizeClass, className, {
-    'button-loading': isLoading,
-  })
-  const loadingSpinnerClassName =
-    size === 'large' ? 'loading-spinner-large' : 'loading-spinner-small'
-  const materialIconClassName = size === 'large' ? 'icon-large' : 'icon-small'
+    const sizeClass = sizeClasses.get(size)
+    const buttonClassName = classNames('d-inline-grid', sizeClass, className, {
+      'button-loading': isLoading,
+    })
+    const loadingSpinnerClassName =
+      size === 'large' ? 'loading-spinner-large' : 'loading-spinner-small'
+    const materialIconClassName = size === 'large' ? 'icon-large' : 'icon-small'
 
-  return (
-    <BS5Button className={buttonClassName} variant={variant} {...props}>
-      {isLoading && (
-        <span className="spinner-container">
-          <Spinner
-            animation="border"
-            aria-hidden="true"
-            as="span"
-            className={loadingSpinnerClassName}
-            role="status"
-          />
-          <span className="visually-hidden">
-            {loadingLabel ?? t('loading')}
+    return (
+      <BS5Button
+        className={buttonClassName}
+        variant={variant}
+        {...props}
+        ref={ref}
+      >
+        {isLoading && (
+          <span className="spinner-container">
+            <Spinner
+              animation="border"
+              aria-hidden="true"
+              as="span"
+              className={loadingSpinnerClassName}
+              role="status"
+            />
+            <span className="visually-hidden">
+              {loadingLabel ?? t('loading')}
+            </span>
           </span>
-        </span>
-      )}
-      <span className="button-content" aria-hidden={isLoading}>
-        {leadingIcon && (
-          <MaterialIcon type={leadingIcon} className={materialIconClassName} />
-        )}
-        {children}
-        {trailingIcon && (
-          <MaterialIcon type={trailingIcon} className={materialIconClassName} />
         )}
-      </span>
-    </BS5Button>
-  )
-}
+        <span className="button-content" aria-hidden={isLoading}>
+          {leadingIcon && (
+            <MaterialIcon
+              type={leadingIcon}
+              className={materialIconClassName}
+            />
+          )}
+          {children}
+          {trailingIcon && (
+            <MaterialIcon
+              type={trailingIcon}
+              className={materialIconClassName}
+            />
+          )}
+        </span>
+      </BS5Button>
+    )
+  }
+)
+Button.displayName = 'Button'
+
+export default Button

+ 72 - 60
services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-menu.tsx

@@ -16,76 +16,88 @@ import type {
   DropdownHeaderProps,
 } from '@/features/ui/components/types/dropdown-menu-props'
 import MaterialIcon from '@/shared/components/material-icon'
+import { fixedForwardRef } from '@/utils/react'
 
 export function Dropdown({ ...props }: DropdownProps) {
   return <BS5Dropdown {...props} />
 }
 
-export const DropdownItem = forwardRef<
-  typeof BS5DropdownItem,
-  DropdownItemProps
->(
-  (
-    { active, children, description, leadingIcon, trailingIcon, ...props },
-    ref
-  ) => {
-    let leadingIconComponent = null
-    if (leadingIcon) {
-      if (typeof leadingIcon === 'string') {
-        leadingIconComponent = (
-          <MaterialIcon
-            className="dropdown-item-leading-icon"
-            type={leadingIcon}
-          />
-        )
-      } else {
-        leadingIconComponent = (
-          <span className="dropdown-item-leading-icon" aria-hidden="true">
-            {leadingIcon}
-          </span>
-        )
-      }
+function DropdownItem(
+  {
+    active,
+    children,
+    description,
+    leadingIcon,
+    trailingIcon,
+    ...props
+  }: DropdownItemProps,
+  ref: React.ForwardedRef<typeof BS5DropdownItem>
+) {
+  let leadingIconComponent = null
+  if (leadingIcon) {
+    if (typeof leadingIcon === 'string') {
+      leadingIconComponent = (
+        <MaterialIcon
+          className="dropdown-item-leading-icon"
+          type={leadingIcon}
+        />
+      )
+    } else {
+      leadingIconComponent = (
+        <span className="dropdown-item-leading-icon" aria-hidden="true">
+          {leadingIcon}
+        </span>
+      )
     }
+  }
 
-    let trailingIconComponent = null
-    if (trailingIcon) {
-      if (typeof trailingIcon === 'string') {
-        const trailingIconType = active ? 'check' : trailingIcon
+  let trailingIconComponent = null
+  if (trailingIcon) {
+    if (typeof trailingIcon === 'string') {
+      const trailingIconType = active ? 'check' : trailingIcon
 
-        trailingIconComponent = (
-          <MaterialIcon
-            className="dropdown-item-trailing-icon"
-            type={trailingIconType}
-          />
-        )
-      } else {
-        trailingIconComponent = (
-          <span className="dropdown-item-leading-icon" aria-hidden="true">
-            {trailingIcon}
-          </span>
-        )
-      }
+      trailingIconComponent = (
+        <MaterialIcon
+          className="dropdown-item-trailing-icon"
+          type={trailingIconType}
+        />
+      )
+    } else {
+      trailingIconComponent = (
+        <span className="dropdown-item-trailing-icon" aria-hidden="true">
+          {trailingIcon}
+        </span>
+      )
     }
-
-    return (
-      <BS5DropdownItem
-        active={active}
-        className={description ? 'dropdown-item-description-container' : ''}
-        role="menuitem"
-        {...props}
-        ref={ref}
-      >
-        {leadingIconComponent}
-        {children}
-        {trailingIconComponent}
-        {description && (
-          <span className="dropdown-item-description">{description}</span>
-        )}
-      </BS5DropdownItem>
-    )
   }
-)
-DropdownItem.displayName = 'DropdownItem'
+
+  return (
+    <BS5DropdownItem
+      active={active}
+      className={description ? 'dropdown-item-description-container' : ''}
+      role="menuitem"
+      {...props}
+      ref={ref}
+    >
+      {leadingIconComponent}
+      {children}
+      {trailingIconComponent}
+      {description && (
+        <span className="dropdown-item-description">{description}</span>
+      )}
+    </BS5DropdownItem>
+  )
+}
+
+function EmptyLeadingIcon() {
+  return <span className="dropdown-item-leading-icon-empty" />
+}
+
+const ForwardReferredDropdownItem = fixedForwardRef(DropdownItem, {
+  EmptyLeadingIcon,
+})
+
+export { ForwardReferredDropdownItem as DropdownItem }
 
 export function DropdownToggle({ ...props }: DropdownToggleProps) {
   return <BS5DropdownToggle {...props} />

+ 27 - 22
services/web/frontend/js/features/ui/components/bootstrap-5/icon-button.tsx

@@ -1,28 +1,33 @@
+import { forwardRef } from 'react'
 import classNames from 'classnames'
 import MaterialIcon from '@/shared/components/material-icon'
 import Button from './button'
 import type { IconButtonProps } from '@/features/ui/components/types/icon-button-props'
 
-export default function IconButton({
-  accessibilityLabel,
-  icon,
-  isLoading = false,
-  size = 'default',
-  ...props
-}: IconButtonProps) {
-  const iconButtonClassName = `icon-button-${size}`
-  const iconSizeClassName = size === 'large' ? 'icon-large' : 'icon-small'
-  const materialIconClassName = classNames(iconSizeClassName, {
-    'button-content-hidden': isLoading,
-  })
+const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
+  (
+    { accessibilityLabel, icon, isLoading = false, size = 'default', ...props },
+    ref
+  ) => {
+    const iconButtonClassName = `icon-button-${size}`
+    const iconSizeClassName = size === 'large' ? 'icon-large' : 'icon-small'
+    const materialIconClassName = classNames(iconSizeClassName, {
+      'button-content-hidden': isLoading,
+    })
 
-  return (
-    <Button className={iconButtonClassName} isLoading={isLoading} {...props}>
-      <MaterialIcon
-        accessibilityLabel={accessibilityLabel}
-        className={materialIconClassName}
-        type={icon}
-      />
-    </Button>
-  )
-}
+    return (
+      <Button
+        className={iconButtonClassName}
+        isLoading={isLoading}
+        aria-label={accessibilityLabel}
+        {...props}
+        ref={ref}
+      >
+        <MaterialIcon className={materialIconClassName} type={icon} />
+      </Button>
+    )
+  }
+)
+IconButton.displayName = 'IconButton'
+
+export default IconButton

+ 8 - 6
services/web/frontend/js/features/ui/components/bootstrap-5/tooltip.tsx

@@ -1,11 +1,13 @@
 import { cloneElement, useEffect, forwardRef } from 'react'
-import { OverlayTrigger, Tooltip as BSTooltip } from 'react-bootstrap-5'
+import {
+  OverlayTrigger,
+  OverlayTriggerProps,
+  Tooltip as BSTooltip,
+  TooltipProps as BSTooltipProps,
+} from 'react-bootstrap-5'
 import { callFnsInSequence } from '@/utils/functions'
 
-type OverlayProps = Omit<
-  React.ComponentProps<typeof OverlayTrigger>,
-  'overlay' | 'children'
->
+type OverlayProps = Omit<OverlayTriggerProps, 'overlay' | 'children'>
 
 type UpdatingTooltipProps = {
   popper: {
@@ -34,7 +36,7 @@ export type TooltipProps = {
   description: React.ReactNode
   id: string
   overlayProps?: OverlayProps
-  tooltipProps?: React.ComponentProps<typeof BSTooltip>
+  tooltipProps?: BSTooltipProps
   hidden?: boolean
   children: React.ReactElement
 }

+ 30 - 0
services/web/frontend/js/features/ui/components/ol/ol-button-group.tsx

@@ -0,0 +1,30 @@
+import { ButtonGroup, ButtonGroupProps } from 'react-bootstrap-5'
+import {
+  ButtonGroup as BS3ButtonGroup,
+  ButtonGroupProps as BS3ButtonGroupProps,
+} from 'react-bootstrap'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
+
+type OLButtonGroupProps = ButtonGroupProps & {
+  bs3Props?: Record<string, unknown>
+}
+
+function OlButtonGroup({ bs3Props, as, ...rest }: OLButtonGroupProps) {
+  const bs3ButtonGroupProps: BS3ButtonGroupProps = {
+    children: rest.children,
+    className: rest.className,
+    vertical: rest.vertical,
+    ...getAriaAndDataProps(rest),
+    ...bs3Props,
+  }
+
+  return (
+    <BootstrapVersionSwitcher
+      bs3={<BS3ButtonGroup {...bs3ButtonGroupProps} />}
+      bs5={<ButtonGroup {...rest} as={as} />}
+    />
+  )
+}
+
+export default OlButtonGroup

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

@@ -0,0 +1,31 @@
+import { ButtonToolbar, ButtonToolbarProps } from 'react-bootstrap-5'
+import {
+  ButtonToolbar as BS3ButtonToolbar,
+  ButtonToolbarProps as BS3ButtonToolbarProps,
+} from 'react-bootstrap'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
+
+type OLButtonToolbarProps = ButtonToolbarProps & {
+  bs3Props?: Record<string, unknown>
+}
+
+function OlButtonToolbar(props: OLButtonToolbarProps) {
+  const { bs3Props, ...rest } = props
+
+  const bs3ButtonToolbarProps: BS3ButtonToolbarProps = {
+    children: rest.children,
+    className: rest.className,
+    ...getAriaAndDataProps(rest),
+    ...bs3Props,
+  }
+
+  return (
+    <BootstrapVersionSwitcher
+      bs3={<BS3ButtonToolbar {...bs3ButtonToolbarProps} />}
+      bs5={<ButtonToolbar {...rest} />}
+    />
+  )
+}
+
+export default OlButtonToolbar

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

@@ -0,0 +1,27 @@
+import { MenuItem, MenuItemProps } from 'react-bootstrap'
+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 & {
+  bs3Props?: MenuItemProps
+}
+
+function OlDropdownMenuItem(props: OlDropdownMenuItemProps) {
+  const { bs3Props, ...rest } = props
+
+  const bs3MenuItemProps: MenuItemProps = {
+    children: rest.children,
+    onClick: rest.onClick,
+    ...bs3Props,
+  }
+
+  return (
+    <BootstrapVersionSwitcher
+      bs3={<MenuItem {...bs3MenuItemProps} />}
+      bs5={<DropdownItem {...rest} />}
+    />
+  )
+}
+
+export default OlDropdownMenuItem

+ 38 - 20
services/web/frontend/js/features/ui/components/ol/ol-icon-button.tsx

@@ -1,9 +1,11 @@
+import { forwardRef } from 'react'
 import { bs3ButtonProps, BS3ButtonSize } from './ol-button'
 import { Button as BS3Button } from 'react-bootstrap'
 import type { IconButtonProps } from '@/features/ui/components/types/icon-button-props'
 import BootstrapVersionSwitcher from '../bootstrap-5/bootstrap-version-switcher'
 import Icon, { IconProps } from '@/shared/components/icon'
 import IconButton from '../bootstrap-5/icon-button'
+import { callFnsInSequence } from '@/utils/functions'
 
 export type OLIconButtonProps = IconButtonProps & {
   bs3Props?: {
@@ -11,28 +13,44 @@ export type OLIconButtonProps = IconButtonProps & {
     fw?: IconProps['fw']
     className?: string
     bsSize?: BS3ButtonSize
+    onMouseOver?: React.MouseEventHandler<HTMLButtonElement>
+    onMouseOut?: React.MouseEventHandler<HTMLButtonElement>
+    onFocus?: React.FocusEventHandler<HTMLButtonElement>
+    onBlur?: React.FocusEventHandler<HTMLButtonElement>
   }
 }
 
-export default function OLIconButton(props: OLIconButtonProps) {
-  const { bs3Props, ...rest } = props
+const OLIconButton = forwardRef<HTMLButtonElement, OLIconButtonProps>(
+  (props, ref) => {
+    const { bs3Props, ...rest } = props
 
-  const { fw, loading, ...bs3Rest } = bs3Props || {}
+    const { fw, loading, ...bs3Rest } = bs3Props || {}
 
-  return (
-    <BootstrapVersionSwitcher
-      bs3={
-        <BS3Button {...bs3ButtonProps(rest)} {...bs3Rest}>
-          {loading || (
-            <Icon
-              type={rest.icon}
-              fw={fw}
-              accessibilityLabel={rest.accessibilityLabel}
-            />
-          )}
-        </BS3Button>
-      }
-      bs5={<IconButton {...rest} />}
-    />
-  )
-}
+    // BS3 OverlayTrigger automatically provides 'onMouseOver', 'onMouseOut', 'onFocus', 'onBlur' event handlers
+    const bs3FinalProps = {
+      'aria-label': rest.accessibilityLabel,
+      ...bs3ButtonProps(rest),
+      ...bs3Rest,
+      onMouseOver: callFnsInSequence(bs3Props?.onMouseOver, rest.onMouseOver),
+      onMouseOut: callFnsInSequence(bs3Props?.onMouseOut, rest.onMouseOut),
+      onFocus: callFnsInSequence(bs3Props?.onFocus, rest.onFocus),
+      onBlur: callFnsInSequence(bs3Props?.onBlur, rest.onBlur),
+    }
+
+    // BS3 tooltip relies on the 'onMouseOver', 'onMouseOut', 'onFocus', 'onBlur' props
+    // BS5 tooltip relies on the ref
+    return (
+      <BootstrapVersionSwitcher
+        bs3={
+          <BS3Button {...bs3FinalProps}>
+            {loading || <Icon type={rest.icon} fw={fw} />}
+          </BS3Button>
+        }
+        bs5={<IconButton {...rest} ref={ref} />}
+      />
+    )
+  }
+)
+OLIconButton.displayName = 'OLIconButton'
+
+export default OLIconButton

+ 6 - 2
services/web/frontend/js/features/ui/components/types/button-props.ts

@@ -1,4 +1,4 @@
-import type { MouseEventHandler, ReactNode } from 'react'
+import type { ReactNode } from 'react'
 
 export type ButtonProps = {
   children?: ReactNode
@@ -11,7 +11,11 @@ export type ButtonProps = {
   rel?: string
   isLoading?: boolean
   loadingLabel?: string
-  onClick?: MouseEventHandler<HTMLButtonElement>
+  onClick?: React.MouseEventHandler<HTMLButtonElement>
+  onMouseOver?: React.MouseEventHandler<HTMLButtonElement>
+  onMouseOut?: React.MouseEventHandler<HTMLButtonElement>
+  onFocus?: React.FocusEventHandler<HTMLButtonElement>
+  onBlur?: React.FocusEventHandler<HTMLButtonElement>
   size?: 'small' | 'default' | 'large'
   trailingIcon?: string
   type?: 'button' | 'reset' | 'submit'

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

@@ -16,6 +16,7 @@ export type DropdownProps = {
   onSelect?: (eventKey: any, event: object) => any
   onToggle?: (show: boolean) => void
   show?: boolean
+  autoClose?: boolean | 'inside' | 'outside'
 }
 
 export type DropdownItemProps = PropsWithChildren<{
@@ -43,6 +44,7 @@ export type DropdownToggleProps = PropsWithChildren<{
   id?: string // necessary for assistive technologies
   variant?: SplitButtonVariants
   as?: ElementType
+  size?: 'sm' | 'lg'
 }>
 
 export type DropdownMenuProps = PropsWithChildren<{
@@ -60,4 +62,5 @@ export type DropdownDividerProps = PropsWithChildren<{
 
 export type DropdownHeaderProps = PropsWithChildren<{
   as?: ElementType
+  className?: string
 }>

+ 3 - 3
services/web/frontend/js/utils/functions.ts

@@ -1,6 +1,6 @@
 export function callFnsInSequence<
-  Args,
-  Fn extends ((...args: Args[]) => void) | void,
+  Args extends Array<any>,
+  Fn extends ((...args: Args) => void) | void,
 >(...fns: Fn[]) {
-  return (...args: Args[]) => fns.forEach(fn => fn?.(...args))
+  return (...args: Args) => fns.forEach(fn => fn?.(...args))
 }

+ 21 - 0
services/web/frontend/js/utils/react.ts

@@ -0,0 +1,21 @@
+import { forwardRef } from 'react'
+
+export const fixedForwardRef = <
+  T,
+  P = object,
+  A extends Record<string, React.FunctionComponent> = Record<
+    string,
+    React.FunctionComponent
+  >,
+>(
+  render: (props: P, ref: React.Ref<T>) => React.ReactElement | null,
+  propsToAttach: A = {} as A
+): ((props: P & React.RefAttributes<T>) => React.ReactElement | null) & A => {
+  const ForwardReferredComponent = forwardRef(render) as any
+
+  for (const i in propsToAttach) {
+    ForwardReferredComponent[i] = propsToAttach[i]
+  }
+
+  return ForwardReferredComponent
+}

+ 30 - 0
services/web/frontend/stories/ui/dropdown-menu.stories.tsx

@@ -2,6 +2,7 @@ import {
   DropdownMenu,
   DropdownItem,
   DropdownDivider,
+  DropdownHeader,
 } from '@/features/ui/components/bootstrap-5/dropdown-menu'
 import type { Meta } from '@storybook/react'
 
@@ -58,6 +59,35 @@ export const Active = (args: Args) => {
   )
 }
 
+export const MultipleSelection = (args: Args) => {
+  console.log('DropdownItem.EmptyLeadingIcon', DropdownItem.EmptyLeadingIcon)
+
+  return (
+    <DropdownMenu show>
+      <DropdownHeader>Header</DropdownHeader>
+      <li>
+        <DropdownItem
+          eventKey="1"
+          href="#/action-1"
+          leadingIcon={<DropdownItem.EmptyLeadingIcon />}
+        >
+          Example
+        </DropdownItem>
+      </li>
+      <li>
+        <DropdownItem eventKey="2" href="#/action-2" leadingIcon="check">
+          Example
+        </DropdownItem>
+      </li>
+      <li>
+        <DropdownItem eventKey="3" href="#/action-3" leadingIcon="check">
+          Example
+        </DropdownItem>
+      </li>
+    </DropdownMenu>
+  )
+}
+
 export const Danger = (args: Args) => {
   return (
     <DropdownMenu show>

+ 0 - 2
services/web/frontend/stylesheets/app/project-list-react.less

@@ -205,8 +205,6 @@
     font-size: @font-size-large;
     line-height: 28px;
     font-weight: bold;
-    overflow: hidden;
-    text-overflow: ellipsis;
   }
 
   ul.project-list-filters {

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

@@ -31,6 +31,7 @@
 @import 'bootstrap-5/scss/forms';
 @import 'bootstrap-5/scss/buttons';
 @import 'bootstrap-5/scss/dropdown';
+@import 'bootstrap-5/scss/button-group';
 @import 'bootstrap-5/scss/badge';
 @import 'bootstrap-5/scss/modal';
 @import 'bootstrap-5/scss/tooltip';

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

@@ -1,4 +1,5 @@
 @import 'button';
+@import 'button-group';
 @import 'dropdown-menu';
 @import 'split-button';
 @import 'notifications';

+ 15 - 0
services/web/frontend/stylesheets/bootstrap-5/components/button-group.scss

@@ -0,0 +1,15 @@
+.btn-group {
+  > .btn {
+    &:first-child {
+      padding-left: var(--spacing-05);
+    }
+
+    &:last-child {
+      padding-right: var(--spacing-05);
+    }
+  }
+}
+
+.btn-toolbar {
+  gap: var(--spacing-03);
+}

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

@@ -221,3 +221,19 @@ a.btn:visited {
 .copy-button {
   text-decoration: none;
 }
+
+.btn-reset {
+  @include reset-button;
+}
+
+.btn-transparent {
+  background: none !important;
+  border-radius: 0 !important;
+  color: inherit !important;
+  font-weight: 400;
+
+  &:hover {
+    background: none !important;
+    color: inherit !important;
+  }
+}

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

@@ -104,6 +104,18 @@
   }
 }
 
+.dropdown-item-leading-icon,
+.dropdown-item-trailing-icon {
+  .material-symbols {
+    vertical-align: top;
+  }
+}
+
+.dropdown-item-leading-icon-empty {
+  display: inline-block;
+  width: 20px;
+}
+
 // description text should look disabled when the dropdown item is disabled
 .dropdown-item.disabled .dropdown-item-description,
 .dropdown-item[aria-disabled='true'] .dropdown-item-description {

+ 74 - 3
services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss

@@ -164,6 +164,65 @@
     padding: var(--spacing-08) var(--spacing-06);
   }
 
+  .project-list-header-row {
+    display: flex;
+    align-items: center;
+    margin-bottom: var(--spacing-05);
+    min-height: 36px;
+  }
+
+  .project-list-title {
+    min-width: 0;
+    color: $content-secondary;
+
+    @include heading-sm;
+
+    font-weight: bold;
+  }
+
+  .project-tools {
+    flex-shrink: 0;
+    margin-left: auto;
+  }
+
+  @include media-breakpoint-down(md) {
+    .project-tools {
+      float: left;
+      margin-left: initial;
+    }
+  }
+
+  .projects-toolbar {
+    display: flex;
+    align-items: center;
+
+    .dropdown,
+    .dropdown-toggle {
+      max-width: 100%;
+    }
+
+    .dropdown {
+      min-width: 0;
+    }
+  }
+
+  .projects-sort-dropdown {
+    flex-shrink: 0;
+    margin-left: auto;
+  }
+
+  .project-menu-item-edit-btn {
+    position: absolute;
+    top: 0;
+    right: var(--spacing-09);
+    width: initial;
+    background-color: transparent;
+  }
+
+  .project-menu-item-tag-name {
+    padding-right: var(--spacing-13);
+  }
+
   ul.project-list-filters {
     margin: var(--spacing-05) calc(-1 * var(--spacing-06));
 
@@ -609,6 +668,18 @@
   }
 }
 
+.current-plan {
+  a.current-plan-label {
+    text-decoration: none;
+    color: $content-secondary;
+  }
+
+  .current-plan-label-icon {
+    vertical-align: text-bottom;
+    color: var(--bg-info-01);
+  }
+}
+
 /* stylelint-disable selector-class-pattern */
 .project-list-upload-project-modal-uppy-dashboard .uppy-Root {
   .uppy-Dashboard-AddFiles-title {
@@ -747,7 +818,7 @@ form.project-search {
       margin: 3px; // it's centered, no matching spacing variable
       font-weight: bold;
 
-      @include media-breakpoint-down(sm) {
+      @include media-breakpoint-down(md) {
         margin: 5px; // it's centered, no matching spacing variable
       }
     }
@@ -757,7 +828,7 @@ form.project-search {
       margin: 3px;
       font-weight: bold;
 
-      @include media-breakpoint-down(sm) {
+      @include media-breakpoint-down(md) {
         margin: 5px;
       }
     }
@@ -769,7 +840,7 @@ form.project-search {
     font-weight: bold;
   }
 
-  @include media-breakpoint-down(sm) {
+  @include media-breakpoint-down(md) {
     height: 32px;
     width: 32px;
     margin: var(--spacing-08);

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

@@ -653,6 +653,7 @@
   "files_cannot_include_invalid_characters": "File name is empty or contains invalid characters",
   "files_selected": "files selected.",
   "fill_in_our_quick_survey": "Fill in our quick survey.",
+  "filter_projects": "Filter projects",
   "filters": "Filters",
   "find_out_more": "Find out More",
   "find_out_more_about_institution_login": "Find out more about institutional login",
@@ -1863,6 +1864,7 @@
   "sorry_your_token_expired": "Sorry, your token expired",
   "sort_by": "Sort by",
   "sort_by_x": "Sort by __x__",
+  "sort_projects": "Sort projects",
   "source": "Source",
   "spell_check": "Spell check",
   "sso": "SSO",
@@ -2117,6 +2119,10 @@
   "toolbar_insert_table": "Insert Table",
   "toolbar_numbered_list": "Numbered List",
   "toolbar_redo": "Redo",
+  "toolbar_selected_projects": "Selected projects",
+  "toolbar_selected_projects_management_actions": "Selected projects management actions",
+  "toolbar_selected_projects_remove": "Remove selected projects",
+  "toolbar_selected_projects_restore": "Restore selected projects",
   "toolbar_table_insert_size_table": "Insert __size__ table",
   "toolbar_table_insert_table_lowercase": "Insert table",
   "toolbar_toggle_symbol_palette": "Toggle Symbol Palette",

+ 1 - 1
services/web/test/frontend/features/project-list/components/table/project-tools/project-tools.test.tsx

@@ -17,7 +17,7 @@ describe('<ProjectTools />', function () {
     screen.getByLabelText('Download')
     screen.getByLabelText('Archive')
     screen.getByLabelText('Trash')
-    screen.getByTitle('Tags')
+    screen.getByLabelText('Tags')
     screen.getByRole('button', { name: 'Create new tag' })
   })
 })