Răsfoiți Sursa

Merge pull request #9700 from overleaf/ii-dashboard-mobile-view

[web] Projects dashboard mobile view

GitOrigin-RevId: 84894e19c814a2cc1ce751181952c0ade6b62044
ilkin-overleaf 3 ani în urmă
părinte
comite
e12c93c537
67 a modificat fișierele cu 2087 adăugiri și 637 ștergeri
  1. 16 5
      services/web/app/views/layout/navbar-marketing.pug
  2. 9 5
      services/web/frontend/extracted-translations.json
  3. 5 1
      services/web/frontend/js/features/project-list/components/current-plan-widget/free-plan.tsx
  4. 211 0
      services/web/frontend/js/features/project-list/components/dropdown/actions-dropdown.tsx
  5. 24 0
      services/web/frontend/js/features/project-list/components/dropdown/menu-item-button.tsx
  6. 108 0
      services/web/frontend/js/features/project-list/components/dropdown/projects-dropdown.tsx
  7. 83 0
      services/web/frontend/js/features/project-list/components/dropdown/sort-by-dropdown.tsx
  8. 42 0
      services/web/frontend/js/features/project-list/components/modals/archive-project-modal.tsx
  9. 3 7
      services/web/frontend/js/features/project-list/components/modals/create-tag-modal.tsx
  10. 37 0
      services/web/frontend/js/features/project-list/components/modals/delete-project-modal.tsx
  11. 7 11
      services/web/frontend/js/features/project-list/components/modals/delete-tag-modal.tsx
  12. 128 0
      services/web/frontend/js/features/project-list/components/modals/edit-tag-modal.tsx
  13. 37 0
      services/web/frontend/js/features/project-list/components/modals/leave-project-modal.tsx
  14. 6 52
      services/web/frontend/js/features/project-list/components/modals/projects-action-modal.tsx
  15. 9 15
      services/web/frontend/js/features/project-list/components/modals/rename-tag-modal.tsx
  16. 42 0
      services/web/frontend/js/features/project-list/components/modals/trash-project-modal.tsx
  17. 13 2
      services/web/frontend/js/features/project-list/components/new-project-button.tsx
  18. 48 15
      services/web/frontend/js/features/project-list/components/project-list-root.tsx
  19. 15 0
      services/web/frontend/js/features/project-list/components/projects-filter-menu.ts
  20. 36 15
      services/web/frontend/js/features/project-list/components/search-form.tsx
  21. 12 15
      services/web/frontend/js/features/project-list/components/sidebar/sidebar-filters.tsx
  22. 18 92
      services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx
  23. 45 0
      services/web/frontend/js/features/project-list/components/sort/with-content.tsx
  24. 35 20
      services/web/frontend/js/features/project-list/components/table/cells/action-buttons/archive-project-button.tsx
  25. 31 18
      services/web/frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button.tsx
  26. 33 21
      services/web/frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button.tsx
  27. 34 17
      services/web/frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button.tsx
  28. 34 22
      services/web/frontend/js/features/project-list/components/table/cells/action-buttons/leave-project-button.tsx
  29. 32 20
      services/web/frontend/js/features/project-list/components/table/cells/action-buttons/trash-project-button.tsx
  30. 37 17
      services/web/frontend/js/features/project-list/components/table/cells/action-buttons/unarchive-project-button.tsx
  31. 34 16
      services/web/frontend/js/features/project-list/components/table/cells/action-buttons/untrash-project-button.tsx
  32. 17 16
      services/web/frontend/js/features/project-list/components/table/cells/actions-cell.tsx
  33. 29 3
      services/web/frontend/js/features/project-list/components/table/cells/inline-tags.tsx
  34. 12 3
      services/web/frontend/js/features/project-list/components/table/cells/owner-cell.tsx
  35. 27 7
      services/web/frontend/js/features/project-list/components/table/project-list-table-row.tsx
  36. 99 134
      services/web/frontend/js/features/project-list/components/table/project-list-table.tsx
  37. 3 4
      services/web/frontend/js/features/project-list/components/table/project-tools/buttons/archive-projects-button.tsx
  38. 3 4
      services/web/frontend/js/features/project-list/components/table/project-tools/buttons/trash-projects-button.tsx
  39. 111 0
      services/web/frontend/js/features/project-list/components/tags-list.tsx
  40. 4 1
      services/web/frontend/js/features/project-list/components/welcome-message.tsx
  41. 3 0
      services/web/frontend/js/features/project-list/context/project-list-context.tsx
  42. 22 0
      services/web/frontend/js/features/project-list/hooks/use-sort.ts
  43. 161 0
      services/web/frontend/js/features/project-list/hooks/use-tag.tsx
  44. 1 0
      services/web/frontend/js/shared/components/controlled-dropdown.js
  45. 2 2
      services/web/frontend/stories/project-list/new-project-button.stories.tsx
  46. 235 0
      services/web/frontend/stylesheets/app/project-list-react.less
  47. 1 0
      services/web/frontend/stylesheets/app/project-list.less
  48. 6 1
      services/web/frontend/stylesheets/components/navbar.less
  49. 16 0
      services/web/frontend/stylesheets/core/spacing.less
  50. 4 1
      services/web/frontend/stylesheets/core/type.less
  51. 14 0
      services/web/frontend/stylesheets/core/utilities.less
  52. 5 0
      services/web/locales/en.json
  53. 1 1
      services/web/test/frontend/features/project-list/components/new-project-button.test.tsx
  54. 8 8
      services/web/test/frontend/features/project-list/components/project-list-root.test.tsx
  55. 12 15
      services/web/test/frontend/features/project-list/components/project-search.test.tsx
  56. 2 2
      services/web/test/frontend/features/project-list/components/sidebar/tags-list.test.tsx
  57. 7 5
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/archive-project-button.test.tsx
  58. 7 5
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx
  59. 7 5
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/delete-project-button.test.tsx
  60. 2 2
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx
  61. 8 6
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/leave-project-button.test.tsx
  62. 6 4
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/trash-project-button.test.tsx
  63. 7 5
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/unarchive-project-button.test.tsx
  64. 6 4
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/untrash-project-button.test.tsx
  65. 5 5
      services/web/test/frontend/features/project-list/components/table/project-list-table.test.tsx
  66. 1 1
      services/web/test/frontend/features/project-list/components/table/projects-action-modal.test.tsx
  67. 9 7
      services/web/test/frontend/features/project-list/helpers/render-with-context.tsx

+ 16 - 5
services/web/app/views/layout/navbar-marketing.pug

@@ -9,6 +9,17 @@ nav.navbar.navbar-default.navbar-main
 					aria-label="Toggle " + translate('navigation')
 				)
 					i.fa.fa-bars(aria-hidden="true")
+			if (usersBestSubscription && usersBestSubscription.type === 'free')
+				a.btn.btn-primary.pull-right.me-2.visible-xs(
+					href="/user/subscription/plans"
+					event-tracking="upgrade-button-click"
+					event-tracking-mb="true"
+					event-tracking-ga="subscription-funnel"
+					event-tracking-action="dashboard-top"
+					event-tracking-label="upgrade"
+					event-tracking-trigger="click"
+					event-segmentation='{"source": "dashboard-top"}'
+				) #{translate("upgrade")}
 			if settings.nav.custom_logo
 				a(href='/', aria-label=settings.appName, style='background-image:url("'+settings.nav.custom_logo+'")').navbar-brand
 			else if (nav.title)
@@ -57,9 +68,9 @@ nav.navbar.navbar-default.navbar-main
 					each item in ((splitTestVariants && (splitTestVariants['unified-navigation'] === 'show-unified-navigation')) ? nav.header_extras_unified : nav.header_extras)
 						-
 							if ((item.only_when_logged_in && getSessionUser())
-								|| (item.only_when_logged_out && (!getSessionUser()))
-								|| (!item.only_when_logged_out && !item.only_when_logged_in && !item.only_content_pages)
-								|| (item.only_content_pages && (typeof(suppressNavContentLinks) == "undefined" || !suppressNavContentLinks))
+											|| (item.only_when_logged_out && (!getSessionUser()))
+											|| (!item.only_when_logged_out && !item.only_when_logged_in && !item.only_content_pages)
+											|| (item.only_content_pages && (typeof(suppressNavContentLinks) == "undefined" || !suppressNavContentLinks))
 							){
 								var showNavItem = true
 							} else {
@@ -82,7 +93,7 @@ nav.navbar.navbar-default.navbar-main
 										each child in item.dropdown
 											if child.divider
 												li.divider
-											if child.splitTest 
+											if child.splitTest
 												if (splitTestVariants && (splitTestVariants[child.splitTest.name] === child.splitTest.variant))
 													li
 														if child.url
@@ -99,7 +110,7 @@ nav.navbar.navbar-default.navbar-main
 												li
 													if child.url
 														a(
-															href=child.url, 
+															href=child.url,
 															class=child.class,
 															event-tracking=child.event
 															event-tracking-mb="true"

+ 9 - 5
services/web/frontend/extracted-translations.json

@@ -153,6 +153,7 @@
   "edit_dictionary": "",
   "edit_dictionary_empty": "",
   "edit_dictionary_remove": "",
+  "edit_folder": "",
   "editing": "",
   "editor_and_pdf": "",
   "editor_only_hide_pdf": "",
@@ -209,6 +210,7 @@
   "galileo_insert_instruction_button": "",
   "galileo_insert_math_button": "",
   "galileo_is": "",
+  "galileo_only_available_in_cm6": "",
   "galileo_promo_autocomplete_content": "",
   "galileo_promo_autocomplete_title": "",
   "galileo_promo_shadow_text_content": "",
@@ -218,7 +220,6 @@
   "galileo_suggestion_feedback_button": "",
   "galileo_suggestions_loading_error": "",
   "galileo_toggle_description": "",
-  "galileo_only_available_in_cm6": "",
   "generic_linked_file_compile_error": "",
   "generic_something_went_wrong": "",
   "get_collaborative_benefits": "",
@@ -303,6 +304,9 @@
   "is_email_affiliated": "",
   "join_project": "",
   "joining": "",
+  "labs_program_already_participating": "",
+  "labs_program_benefits": "<0></0>",
+  "labs_program_not_participating": "",
   "last_modified": "",
   "last_name": "",
   "last_updated_date_by_x": "",
@@ -353,6 +357,7 @@
   "make_private": "",
   "manage_beta_program_membership": "",
   "manage_files_from_your_dropbox_folder": "",
+  "manage_labs_program_membership": "",
   "manage_newsletter": "",
   "manage_sessions": "",
   "math_display": "",
@@ -410,10 +415,7 @@
   "other_logs_and_files": "",
   "other_output_files": "",
   "overleaf_labs": "",
-  "labs_program_benefits": "",
-  "labs_program_already_participating": "",
-  "labs_program_not_participating": "",
-  "manage_labs_program_membership": "",
+  "owned_by": "",
   "owner": "",
   "page_current": "",
   "pagination_navigation": "",
@@ -511,6 +513,7 @@
   "revoke": "",
   "revoke_invite": "",
   "role": "",
+  "save_changes": "",
   "save_or_cancel-cancel": "",
   "save_or_cancel-or": "",
   "save_or_cancel-save": "",
@@ -569,6 +572,7 @@
   "something_went_wrong_rendering_pdf": "",
   "something_went_wrong_server": "",
   "somthing_went_wrong_compiling": "",
+  "sort_by": "",
   "sort_by_x": "",
   "sso_link_error": "",
   "start_by_adding_your_email": "",

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

@@ -13,6 +13,9 @@ function FreePlan() {
 
   return (
     <>
+      <span className="current-plan-label visible-xs">
+        <Trans i18nKey="free_plan_label" components={{ b: <strong /> }} />
+      </span>
       <Tooltip
         description={t('free_plan_tooltip')}
         id="free-plan"
@@ -20,7 +23,7 @@ function FreePlan() {
       >
         <a
           href="/learn/how-to/Overleaf_premium_features"
-          className="current-plan-label"
+          className="current-plan-label hidden-xs"
         >
           <Trans i18nKey="free_plan_label" components={{ b: <strong /> }} />{' '}
           <span className="info-badge" />
@@ -28,6 +31,7 @@ function FreePlan() {
       </Tooltip>{' '}
       <Button
         bsStyle="primary"
+        className="hidden-xs"
         href="/user/subscription/plans"
         onClick={handleClick}
       >

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

@@ -0,0 +1,211 @@
+import { useState, useCallback } from 'react'
+import { Dropdown } from 'react-bootstrap'
+import MenuItemButton from './menu-item-button'
+import Icon from '../../../../shared/components/icon'
+import CopyProjectButton from '../table/cells/action-buttons/copy-project-button'
+import DownloadProjectButton from '../table/cells/action-buttons/download-project-button'
+import ArchiveProjectButton from '../table/cells/action-buttons/archive-project-button'
+import TrashProjectButton from '../table/cells/action-buttons/trash-project-button'
+import UnarchiveProjectButton from '../table/cells/action-buttons/unarchive-project-button'
+import UntrashProjectButton from '../table/cells/action-buttons/untrash-project-button'
+import LeaveProjectButton from '../table/cells/action-buttons/leave-project-button'
+import DeleteProjectButton from '../table/cells/action-buttons/delete-project-button'
+import { Project } from '../../../../../../types/project/dashboard/api'
+
+type ActionButtonProps = {
+  project: Project
+  onClick: () => void // eslint-disable-line react/no-unused-prop-types
+}
+
+function CopyProjectButtonMenuItem({ project, onClick }: ActionButtonProps) {
+  return (
+    <CopyProjectButton project={project}>
+      {text => (
+        <MenuItemButton onClick={onClick} className="projects-action-menu-item">
+          <Icon type="files-o" className="menu-item-button-icon" />{' '}
+          <span className="menu-item-button-text">{text}</span>
+        </MenuItemButton>
+      )}
+    </CopyProjectButton>
+  )
+}
+
+function DownloadProjectButtonMenuItem({
+  project,
+  onClick,
+}: ActionButtonProps) {
+  const handleClick = (downloadProject: () => void) => {
+    downloadProject()
+    onClick()
+  }
+
+  return (
+    <DownloadProjectButton project={project}>
+      {(text, downloadProject) => (
+        <MenuItemButton
+          onClick={() => handleClick(downloadProject)}
+          className="projects-action-menu-item"
+        >
+          <Icon type="cloud-download" className="menu-item-button-icon" />{' '}
+          <span className="menu-item-button-text">{text}</span>
+        </MenuItemButton>
+      )}
+    </DownloadProjectButton>
+  )
+}
+
+function ArchiveProjectButtonMenuItem({ project, onClick }: ActionButtonProps) {
+  const handleClick = (handleOpenModal: () => void) => {
+    handleOpenModal()
+    onClick()
+  }
+
+  return (
+    <ArchiveProjectButton project={project}>
+      {(text, handleOpenModal) => (
+        <MenuItemButton
+          onClick={() => handleClick(handleOpenModal)}
+          className="projects-action-menu-item"
+        >
+          <Icon type="inbox" className="menu-item-button-icon" />{' '}
+          <span className="menu-item-button-text">{text}</span>
+        </MenuItemButton>
+      )}
+    </ArchiveProjectButton>
+  )
+}
+
+function TrashProjectButtonMenuItem({ project, onClick }: ActionButtonProps) {
+  const handleClick = (handleOpenModal: () => void) => {
+    handleOpenModal()
+    onClick()
+  }
+
+  return (
+    <TrashProjectButton project={project}>
+      {(text, handleOpenModal) => (
+        <MenuItemButton
+          onClick={() => handleClick(handleOpenModal)}
+          className="projects-action-menu-item"
+        >
+          <Icon type="trash" className="menu-item-button-icon" />{' '}
+          <span className="menu-item-button-text">{text}</span>
+        </MenuItemButton>
+      )}
+    </TrashProjectButton>
+  )
+}
+
+function UnarchiveProjectButtonMenuItem({
+  project,
+  onClick,
+}: ActionButtonProps) {
+  const handleClick = (unarchiveProject: () => Promise<void>) => {
+    unarchiveProject()
+    onClick()
+  }
+
+  return (
+    <UnarchiveProjectButton project={project}>
+      {(text, unarchiveProject) => (
+        <MenuItemButton
+          onClick={() => handleClick(unarchiveProject)}
+          className="projects-action-menu-item"
+        >
+          <Icon type="reply" className="menu-item-button-icon" />{' '}
+          <span className="menu-item-button-text">{text}</span>
+        </MenuItemButton>
+      )}
+    </UnarchiveProjectButton>
+  )
+}
+
+function UntrashProjectButtonMenuItem({ project, onClick }: ActionButtonProps) {
+  const handleClick = (untrashProject: () => Promise<void>) => {
+    untrashProject()
+    onClick()
+  }
+
+  return (
+    <UntrashProjectButton project={project}>
+      {(text, untrashProject) => (
+        <MenuItemButton
+          onClick={() => handleClick(untrashProject)}
+          className="projects-action-menu-item"
+        >
+          <Icon type="reply" className="menu-item-button-icon" />{' '}
+          <span className="menu-item-button-text">{text}</span>
+        </MenuItemButton>
+      )}
+    </UntrashProjectButton>
+  )
+}
+
+function LeaveProjectButtonMenuItem({ project, onClick }: ActionButtonProps) {
+  return (
+    <LeaveProjectButton project={project}>
+      {text => (
+        <MenuItemButton onClick={onClick} className="projects-action-menu-item">
+          <Icon type="sign-out" className="menu-item-button-icon" />{' '}
+          <span className="menu-item-button-text">{text}</span>
+        </MenuItemButton>
+      )}
+    </LeaveProjectButton>
+  )
+}
+
+function DeleteProjectButtonMenuItem({ project, onClick }: ActionButtonProps) {
+  return (
+    <DeleteProjectButton project={project}>
+      {text => (
+        <MenuItemButton onClick={onClick} className="projects-action-menu-item">
+          <Icon type="ban" className="menu-item-button-icon" />{' '}
+          <span className="menu-item-button-text">{text}</span>
+        </MenuItemButton>
+      )}
+    </DeleteProjectButton>
+  )
+}
+
+type ActionDropdownProps = {
+  project: Project
+}
+
+function ActionsDropdown({ project }: ActionDropdownProps) {
+  const [isOpened, setIsOpened] = useState(false)
+
+  const handleClose = useCallback(() => {
+    setIsOpened(false)
+  }, [setIsOpened])
+
+  return (
+    <Dropdown
+      id={`project-actions-dropdown-${project.id}`}
+      pullRight
+      open={isOpened}
+      onToggle={open => setIsOpened(open)}
+    >
+      <Dropdown.Toggle noCaret className="btn-transparent">
+        <Icon type="ellipsis-h" fw />
+      </Dropdown.Toggle>
+      <Dropdown.Menu className="projects-dropdown-menu text-left">
+        <CopyProjectButtonMenuItem project={project} onClick={handleClose} />
+        <DownloadProjectButtonMenuItem
+          project={project}
+          onClick={handleClose}
+        />
+        <ArchiveProjectButtonMenuItem project={project} onClick={handleClose} />
+        <TrashProjectButtonMenuItem project={project} onClick={handleClose} />
+        <UnarchiveProjectButtonMenuItem
+          project={project}
+          onClick={handleClose}
+        />
+        <UntrashProjectButtonMenuItem project={project} onClick={handleClose} />
+        <LeaveProjectButtonMenuItem project={project} onClick={handleClose} />
+        <DeleteProjectButtonMenuItem project={project} onClick={handleClose} />
+      </Dropdown.Menu>
+    </Dropdown>
+  )
+}
+
+export default ActionsDropdown

+ 24 - 0
services/web/frontend/js/features/project-list/components/dropdown/menu-item-button.tsx

@@ -0,0 +1,24 @@
+import { ReactNode } from 'react'
+
+type MenuItemButtonProps = {
+  children: ReactNode
+  onClick?: (...args: unknown[]) => void
+  className?: string
+  afterNode?: React.ReactNode
+}
+
+export default function MenuItemButton({
+  children,
+  onClick,
+  className,
+  afterNode,
+}: MenuItemButtonProps) {
+  return (
+    <li role="presentation" className={className}>
+      <button className="menu-item-button" role="menuitem" onClick={onClick}>
+        {children}
+      </button>
+      {afterNode}
+    </li>
+  )
+}

+ 108 - 0
services/web/frontend/js/features/project-list/components/dropdown/projects-dropdown.tsx

@@ -0,0 +1,108 @@
+import { useState, useEffect, useRef } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Dropdown, MenuItem } from 'react-bootstrap'
+import Icon from '../../../../shared/components/icon'
+import {
+  Filter,
+  UNCATEGORIZED_KEY,
+  useProjectListContext,
+} from '../../context/project-list-context'
+import ProjectsFilterMenu from '../projects-filter-menu'
+import TagsList from '../tags-list'
+import MenuItemButton from './menu-item-button'
+
+type ItemProps = {
+  filter: Filter
+  text: string
+  onClick: () => void
+}
+
+export function Item({ filter, text, onClick }: ItemProps) {
+  const { selectFilter } = useProjectListContext()
+  const handleClick = () => {
+    selectFilter(filter)
+    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>
+      )}
+    </ProjectsFilterMenu>
+  )
+}
+
+function ProjectsDropdown() {
+  const { t } = useTranslation()
+  const [title, setTitle] = useState(() => t('all_projects'))
+  const [isOpened, setIsOpened] = useState(false)
+  const { filter, selectedTagId, tags } = useProjectListContext()
+  const filterTranslations = useRef<Record<Filter, string>>({
+    all: t('all_projects'),
+    owned: t('your_projects'),
+    shared: t('shared_with_you'),
+    archived: t('archived_projects'),
+    trashed: t('trashed_projects'),
+  })
+  const handleClose = () => setIsOpened(false)
+
+  useEffect(() => {
+    if (selectedTagId === undefined) {
+      setTitle(filterTranslations.current[filter])
+    }
+
+    if (selectedTagId === UNCATEGORIZED_KEY) {
+      setTitle(t('uncategorized'))
+    } else {
+      const tag = tags.find(({ _id: id }) => id === selectedTagId)
+
+      if (tag) {
+        setTitle(tag.name)
+      }
+    }
+  }, [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">
+        {title} <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_slash_folders')}:</MenuItem>
+        <TagsList onTagClick={handleClose} onEditClick={handleClose} />
+      </Dropdown.Menu>
+    </Dropdown>
+  )
+}
+
+export default ProjectsDropdown

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

@@ -0,0 +1,83 @@
+import { useState, useEffect, useRef } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Dropdown, MenuItem } 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'
+
+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>
+  )
+}
+
+const ItemWithContent = withContent(Item)
+
+function SortByDropdown() {
+  const { t } = useTranslation()
+  const [title, setTitle] = useState(() => t('last_modified'))
+  const [isOpened, setIsOpened] = useState(false)
+  const { sort } = useProjectListContext()
+  const { handleSort } = useSort()
+  const sortByTranslations = useRef<Record<Sort['by'], string>>({
+    title: t('title'),
+    owner: t('owner'),
+    lastUpdated: t('last_modified'),
+  })
+
+  const handleClick = (by: Sort['by']) => {
+    setTitle(sortByTranslations.current[by])
+    setIsOpened(false)
+    handleSort(by)
+  }
+
+  useEffect(() => {
+    setTitle(sortByTranslations.current[sort.by])
+  }, [sort.by])
+
+  return (
+    <Dropdown
+      id="projects-sort-dropdown"
+      className="ms-auto"
+      pullRight
+      open={isOpened}
+      onToggle={open => setIsOpened(open)}
+    >
+      <Dropdown.Toggle bsSize="small" noCaret className="pe-0 btn-transparent">
+        {title} <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>
+  )
+}
+
+export default SortByDropdown

+ 42 - 0
services/web/frontend/js/features/project-list/components/modals/archive-project-modal.tsx

@@ -0,0 +1,42 @@
+import { useTranslation } from 'react-i18next'
+import ProjectsActionModal from './projects-action-modal'
+
+type ArchiveProjectModalProps = Pick<
+  React.ComponentProps<typeof ProjectsActionModal>,
+  'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal'
+>
+
+function ArchiveProjectModal({
+  projects,
+  actionHandler,
+  showModal,
+  handleCloseModal,
+}: ArchiveProjectModalProps) {
+  const { t } = useTranslation()
+
+  return (
+    <ProjectsActionModal
+      action="archive"
+      actionHandler={actionHandler}
+      title={t('archive_projects')}
+      bodyTop={<p>{t('about_to_archive_projects')}</p>}
+      bodyBottom={
+        <p>
+          {t('archiving_projects_wont_affect_collaborators')}{' '}
+          <a
+            href="https://www.overleaf.com/blog/new-feature-using-archive-and-trash-to-keep-your-projects-organized"
+            target="_blank"
+            rel="noreferrer"
+          >
+            {t('find_out_more_nt')}
+          </a>
+        </p>
+      }
+      showModal={showModal}
+      handleCloseModal={handleCloseModal}
+      projects={projects}
+    />
+  )
+}
+
+export default ArchiveProjectModal

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

@@ -8,12 +8,14 @@ import { createTag } from '../../util/api'
 import { MAX_TAG_LENGTH } from '../../util/tag'
 
 type CreateTagModalProps = {
+  id: string
   show: boolean
   onCreate: (tag: Tag) => void
   onClose: () => void
 }
 
 export default function CreateTagModal({
+  id,
   show,
   onCreate,
   onClose,
@@ -55,13 +57,7 @@ export default function CreateTagModal({
   }
 
   return (
-    <AccessibleModal
-      show
-      animation
-      onHide={onClose}
-      id="rename-tag-modal"
-      backdrop="static"
-    >
+    <AccessibleModal show animation onHide={onClose} id={id} backdrop="static">
       <Modal.Header closeButton>
         <Modal.Title>{t('create_new_folder')}</Modal.Title>
       </Modal.Header>

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

@@ -0,0 +1,37 @@
+import { useTranslation } from 'react-i18next'
+import ProjectsActionModal from './projects-action-modal'
+import Icon from '../../../../shared/components/icon'
+
+type DeleteProjectModalProps = Pick<
+  React.ComponentProps<typeof ProjectsActionModal>,
+  'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal'
+>
+
+function DeleteProjectModal({
+  projects,
+  actionHandler,
+  showModal,
+  handleCloseModal,
+}: DeleteProjectModalProps) {
+  const { t } = useTranslation()
+
+  return (
+    <ProjectsActionModal
+      action="delete"
+      actionHandler={actionHandler}
+      title={t('delete_projects')}
+      bodyTop={<p>{t('about_to_delete_projects')}</p>}
+      bodyBottom={
+        <div className="project-action-alert alert alert-warning">
+          <Icon type="exclamation-triangle" fw />{' '}
+          {t('this_action_cannot_be_undone')}
+        </div>
+      }
+      showModal={showModal}
+      handleCloseModal={handleCloseModal}
+      projects={projects}
+    />
+  )
+}
+
+export default DeleteProjectModal

+ 7 - 11
services/web/frontend/js/features/project-list/components/sidebar/delete-tag-modal.tsx → services/web/frontend/js/features/project-list/components/modals/delete-tag-modal.tsx

@@ -7,18 +7,20 @@ import useAsync from '../../../../shared/hooks/use-async'
 import { deleteTag } from '../../util/api'
 
 type DeleteTagModalProps = {
+  id: string
   tag?: Tag
   onDelete: (tagId: string) => void
   onClose: () => void
 }
 
 export default function DeleteTagModal({
+  id,
   tag,
   onDelete,
   onClose,
 }: DeleteTagModalProps) {
   const { t } = useTranslation()
-  const { isError, runAsync, status } = useAsync()
+  const { isLoading, isError, runAsync } = useAsync()
 
   const runDeleteTag = useCallback(
     (tagId: string) => {
@@ -36,13 +38,7 @@ export default function DeleteTagModal({
   }
 
   return (
-    <AccessibleModal
-      show
-      animation
-      onHide={onClose}
-      id="delete-tag-modal"
-      backdrop="static"
-    >
+    <AccessibleModal show animation onHide={onClose} id={id} backdrop="static">
       <Modal.Header closeButton>
         <Modal.Title>{t('delete_folder')}</Modal.Title>
       </Modal.Header>
@@ -62,15 +58,15 @@ export default function DeleteTagModal({
             </span>
           </div>
         )}
-        <Button onClick={onClose} disabled={status === 'pending'}>
+        <Button onClick={onClose} disabled={isLoading}>
           {t('cancel')}
         </Button>
         <Button
           onClick={() => runDeleteTag(tag._id)}
           bsStyle="primary"
-          disabled={status === 'pending'}
+          disabled={isLoading}
         >
-          {status === 'pending' ? t('deleting') + '…' : t('delete')}
+          {isLoading ? <>{t('deleting')} &hellip;</> : t('delete')}
         </Button>
       </Modal.Footer>
     </AccessibleModal>

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

@@ -0,0 +1,128 @@
+import { useCallback, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Button, Form, Modal } from 'react-bootstrap'
+import AccessibleModal from '../../../../shared/components/accessible-modal'
+import useAsync from '../../../../shared/hooks/use-async'
+import { deleteTag, renameTag } from '../../util/api'
+import { Tag } from '../../../../../../app/src/Features/Tags/types'
+
+type EditTagModalProps = {
+  id: string
+  tag?: Tag
+  onRename: (tagId: string, newTagName: string) => void
+  onDelete: (tagId: string) => void
+  onClose: () => void
+}
+
+export default function EditTagModal({
+  id,
+  tag,
+  onRename,
+  onDelete,
+  onClose,
+}: EditTagModalProps) {
+  const { t } = useTranslation()
+  const {
+    isLoading: isDeleteLoading,
+    isError: isDeleteError,
+    runAsync: runDeleteAsync,
+  } = useAsync()
+  const {
+    isLoading: isRenameLoading,
+    isError: isRenameError,
+    runAsync: runRenameAsync,
+  } = useAsync()
+  const [newTagName, setNewTagName] = useState<string>()
+
+  const runDeleteTag = useCallback(
+    (tagId: string) => {
+      runDeleteAsync(deleteTag(tagId))
+        .then(() => {
+          onDelete(tagId)
+        })
+        .catch(console.error)
+    },
+    [runDeleteAsync, onDelete]
+  )
+
+  const runRenameTag = useCallback(
+    (tagId: string) => {
+      if (newTagName) {
+        runRenameAsync(renameTag(tagId, newTagName))
+          .then(() => onRename(tagId, newTagName))
+          .catch(console.error)
+      }
+    },
+    [runRenameAsync, newTagName, onRename]
+  )
+
+  const handleSubmit = useCallback(
+    e => {
+      e.preventDefault()
+      if (tag) {
+        runRenameTag(tag._id)
+      }
+    },
+    [tag, runRenameTag]
+  )
+
+  if (!tag) {
+    return null
+  }
+
+  return (
+    <AccessibleModal show animation onHide={onClose} id={id} backdrop="static">
+      <Modal.Header closeButton>
+        <Modal.Title>{t('edit_folder')}</Modal.Title>
+      </Modal.Header>
+
+      <Modal.Body>
+        <Form name="editTagRenameForm" onSubmit={handleSubmit}>
+          <input
+            className="form-control"
+            type="text"
+            placeholder="Tag Name"
+            name="new-tag-name"
+            value={newTagName === undefined ? tag.name : newTagName}
+            required
+            onChange={e => setNewTagName(e.target.value)}
+          />
+        </Form>
+      </Modal.Body>
+
+      <Modal.Footer>
+        <div className="clearfix">
+          <div className="modal-footer-left">
+            <Button
+              onClick={() => runDeleteTag(tag._id)}
+              bsStyle="primary"
+              disabled={isDeleteLoading || isRenameLoading}
+            >
+              {isDeleteLoading ? <>{t('deleting')} &hellip;</> : t('delete')}
+            </Button>
+          </div>
+          <Button
+            onClick={onClose}
+            disabled={isDeleteLoading || isRenameLoading}
+          >
+            {t('cancel')}
+          </Button>
+          <Button
+            onClick={() => runRenameTag(tag._id)}
+            bsStyle="primary"
+            disabled={isRenameLoading || isDeleteLoading || !newTagName?.length}
+          >
+            {isRenameLoading ? <>{t('saving')} &hellip;</> : t('save_changes')}
+          </Button>
+        </div>
+        {(isDeleteError || isRenameError) && (
+          <div className="modal-footer-left mt-2">
+            <span className="text-danger error">
+              {t('generic_something_went_wrong')}
+            </span>
+          </div>
+        )}
+      </Modal.Footer>
+    </AccessibleModal>
+  )
+}

+ 37 - 0
services/web/frontend/js/features/project-list/components/modals/leave-project-modal.tsx

@@ -0,0 +1,37 @@
+import { useTranslation } from 'react-i18next'
+import ProjectsActionModal from './projects-action-modal'
+import Icon from '../../../../shared/components/icon'
+
+type LeaveProjectModalProps = Pick<
+  React.ComponentProps<typeof ProjectsActionModal>,
+  'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal'
+>
+
+function LeaveProjectModal({
+  projects,
+  actionHandler,
+  showModal,
+  handleCloseModal,
+}: LeaveProjectModalProps) {
+  const { t } = useTranslation()
+
+  return (
+    <ProjectsActionModal
+      action="leave"
+      actionHandler={actionHandler}
+      title={t('leave_projects')}
+      bodyTop={<p>{t('about_to_leave_projects')}</p>}
+      bodyBottom={
+        <div className="project-action-alert alert alert-warning">
+          <Icon type="exclamation-triangle" fw />{' '}
+          {t('this_action_cannot_be_undone')}
+        </div>
+      }
+      showModal={showModal}
+      handleCloseModal={handleCloseModal}
+      projects={projects}
+    />
+  )
+}
+
+export default LeaveProjectModal

+ 6 - 52
services/web/frontend/js/features/project-list/components/table/projects-action-modal.tsx → services/web/frontend/js/features/project-list/components/modals/projects-action-modal.tsx

@@ -6,24 +6,28 @@ import AccessibleModal from '../../../../shared/components/accessible-modal'
 import { getUserFacingMessage } from '../../../../infrastructure/fetch-json'
 import useIsMounted from '../../../../shared/hooks/use-is-mounted'
 import * as eventTracking from '../../../../infrastructure/event-tracking'
-import Icon from '../../../../shared/components/icon'
 
 type ProjectsActionModalProps = {
+  title?: string
   action: 'archive' | 'trash' | 'delete' | 'leave'
   actionHandler: (project: Project) => Promise<void>
   handleCloseModal: () => void
+  bodyTop?: React.ReactNode
+  bodyBottom?: React.ReactNode
   projects: Array<Project>
   showModal: boolean
 }
 
 function ProjectsActionModal({
+  title,
   action,
   actionHandler,
   handleCloseModal,
+  bodyTop,
+  bodyBottom,
   showModal,
   projects,
 }: ProjectsActionModalProps) {
-  let bodyTop, bodyBottom, title
   const { t } = useTranslation()
   const [errors, setErrors] = useState<Array<any>>([])
   const [isProcessing, setIsProcessing] = useState(false)
@@ -63,56 +67,6 @@ function ProjectsActionModal({
     }
   }, [action, showModal])
 
-  if (action === 'archive') {
-    title = t('archive_projects')
-    bodyTop = <p>{t('about_to_archive_projects')}</p>
-    bodyBottom = (
-      <p>
-        {t('archiving_projects_wont_affect_collaborators')}{' '}
-        <a
-          href="https://www.overleaf.com/blog/new-feature-using-archive-and-trash-to-keep-your-projects-organized"
-          target="_blank"
-          rel="noreferrer"
-        >
-          {t('find_out_more_nt')}
-        </a>
-      </p>
-    )
-  } else if (action === 'leave') {
-    title = t('leave_projects')
-    bodyTop = <p>{t('about_to_leave_projects')}</p>
-    bodyBottom = (
-      <div className="project-action-alert alert alert-warning">
-        <Icon type="exclamation-triangle" fw />{' '}
-        {t('this_action_cannot_be_undone')}
-      </div>
-    )
-  } else if (action === 'trash') {
-    title = t('trash_projects')
-    bodyTop = <p>{t('about_to_trash_projects')}</p>
-    bodyBottom = (
-      <p>
-        {t('trashing_projects_wont_affect_collaborators')}{' '}
-        <a
-          href="https://www.overleaf.com/blog/new-feature-using-archive-and-trash-to-keep-your-projects-organized"
-          target="_blank"
-          rel="noreferrer"
-        >
-          {t('find_out_more_nt')}
-        </a>
-      </p>
-    )
-  } else if (action === 'delete') {
-    title = t('delete_projects')
-    bodyTop = <p>{t('about_to_delete_projects')}</p>
-    bodyBottom = (
-      <div className="project-action-alert alert alert-warning">
-        <Icon type="exclamation-triangle" fw />{' '}
-        {t('this_action_cannot_be_undone')}
-      </div>
-    )
-  }
-
   return (
     <AccessibleModal
       animation

+ 9 - 15
services/web/frontend/js/features/project-list/components/sidebar/rename-tag-modal.tsx → services/web/frontend/js/features/project-list/components/modals/rename-tag-modal.tsx

@@ -8,20 +8,22 @@ import { renameTag } from '../../util/api'
 import { MAX_TAG_LENGTH } from '../../util/tag'
 
 type RenameTagModalProps = {
+  id: string
   tag?: Tag
   onRename: (tagId: string, newTagName: string) => void
   onClose: () => void
 }
 
 export default function RenameTagModal({
+  id,
   tag,
   onRename,
   onClose,
 }: RenameTagModalProps) {
   const { t } = useTranslation()
-  const { isError, runAsync, status } = useAsync()
+  const { isLoading, isError, runAsync } = useAsync()
 
-  const [newTagName, setNewTageName] = useState<string>()
+  const [newTagName, setNewTagName] = useState<string>()
   const [validationError, setValidationError] = useState<string>()
 
   const runRenameTag = useCallback(
@@ -60,13 +62,7 @@ export default function RenameTagModal({
   }
 
   return (
-    <AccessibleModal
-      show
-      animation
-      onHide={onClose}
-      id="rename-tag-modal"
-      backdrop="static"
-    >
+    <AccessibleModal show animation onHide={onClose} id={id} backdrop="static">
       <Modal.Header closeButton>
         <Modal.Title>{t('rename_folder')}</Modal.Title>
       </Modal.Header>
@@ -80,7 +76,7 @@ export default function RenameTagModal({
             name="new-tag-name"
             value={newTagName === undefined ? tag.name : newTagName}
             required
-            onChange={e => setNewTageName(e.target.value)}
+            onChange={e => setNewTagName(e.target.value)}
           />
         </Form>
       </Modal.Body>
@@ -98,17 +94,15 @@ export default function RenameTagModal({
             </span>
           </div>
         )}
-        <Button onClick={onClose} disabled={status === 'pending'}>
+        <Button onClick={onClose} disabled={isLoading}>
           {t('cancel')}
         </Button>
         <Button
           onClick={() => runRenameTag(tag._id)}
           bsStyle="primary"
-          disabled={
-            status === 'pending' || !newTagName?.length || !!validationError
-          }
+          disabled={isLoading || !newTagName?.length || !!validationError}
         >
-          {status === 'pending' ? t('renaming') + '…' : t('rename')}
+          {isLoading ? <>{t('renaming')} &hellip;</> : t('rename')}
         </Button>
       </Modal.Footer>
     </AccessibleModal>

+ 42 - 0
services/web/frontend/js/features/project-list/components/modals/trash-project-modal.tsx

@@ -0,0 +1,42 @@
+import { useTranslation } from 'react-i18next'
+import ProjectsActionModal from './projects-action-modal'
+
+type TrashProjectPropsModalProps = Pick<
+  React.ComponentProps<typeof ProjectsActionModal>,
+  'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal'
+>
+
+function TrashProjectModal({
+  projects,
+  actionHandler,
+  showModal,
+  handleCloseModal,
+}: TrashProjectPropsModalProps) {
+  const { t } = useTranslation()
+
+  return (
+    <ProjectsActionModal
+      action="trash"
+      actionHandler={actionHandler}
+      title={t('trash_projects')}
+      bodyTop={<p>{t('about_to_trash_projects')}</p>}
+      bodyBottom={
+        <p>
+          {t('trashing_projects_wont_affect_collaborators')}{' '}
+          <a
+            href="https://www.overleaf.com/blog/new-feature-using-archive-and-trash-to-keep-your-projects-organized"
+            target="_blank"
+            rel="noreferrer"
+          >
+            {t('find_out_more_nt')}
+          </a>
+        </p>
+      }
+      showModal={showModal}
+      handleCloseModal={handleCloseModal}
+      projects={projects}
+    />
+  )
+}
+
+export default TrashProjectModal

+ 13 - 2
services/web/frontend/js/features/project-list/components/new-project-button.tsx

@@ -7,8 +7,19 @@ import getMeta from '../../../utils/meta'
 import NewProjectButtonModal, {
   NewProjectButtonModalVariant,
 } from './new-project-button/new-project-button-modal'
+import { Nullable } from '../../../../../types/utils'
 
-function NewProjectButton({ buttonText }: { buttonText?: string }) {
+type NewProjectButtonProps = {
+  id: string
+  buttonText?: string
+  className?: string
+}
+
+function NewProjectButton({
+  id,
+  buttonText,
+  className,
+}: NewProjectButtonProps) {
   const { t } = useTranslation()
   const { templateLinks } = getMeta('ol-ExposedSettings') as ExposedSettings
   const [modal, setModal] =
@@ -16,7 +27,7 @@ function NewProjectButton({ buttonText }: { buttonText?: string }) {
 
   return (
     <>
-      <ControlledDropdown id="new-project-button">
+      <ControlledDropdown id={id} className={className}>
         <Dropdown.Toggle
           noCaret
           className="new-project-button"

+ 48 - 15
services/web/frontend/js/features/project-list/components/project-list-root.tsx

@@ -14,6 +14,8 @@ import WelcomeMessage from './welcome-message'
 import LoadingBranded from '../../../shared/components/loading-branded'
 import UserNotifications from './notifications/user-notifications'
 import SearchForm from './search-form'
+import ProjectsDropdown from './dropdown/projects-dropdown'
+import SortByDropdown from './dropdown/sort-by-dropdown'
 import ProjectTools from './table/project-tools/project-tools'
 
 function ProjectListRoot() {
@@ -32,6 +34,7 @@ function ProjectListPageContent() {
     error,
     isLoading,
     loadProgress,
+    searchText,
     setSearchText,
     selectedProjects,
   } = useProjectListContext()
@@ -41,44 +44,74 @@ function ProjectListPageContent() {
       <LoadingBranded loadProgress={loadProgress} />
     </div>
   ) : (
-    <div className="project-list-row row fill">
-      <div className="project-list-wrapper">
+    <div className="project-list-row fill">
+      <div className="project-list-wrapper clearfix">
         {error ? <DashApiError /> : ''}
         {totalProjectsCount > 0 ? (
           <>
-            <Col md={2} xs={3} className="project-list-sidebar-wrapper">
+            <div className="project-list-sidebar-wrapper hidden-xs">
               <aside className="project-list-sidebar">
-                <NewProjectButton />
+                <NewProjectButton id="new-project-button-sidebar" />
                 <SidebarFilters />
               </aside>
               <SurveyWidget />
-            </Col>
-            <Col md={10} xs={9} className="project-list-main">
+            </div>
+            <div className="project-list-main">
               <Row>
                 <Col xs={12}>
                   <UserNotifications />
                 </Col>
               </Row>
               <Row>
-                <Col md={7} xs={12}>
-                  <SearchForm onChange={setSearchText} />
+                <Col md={7} className="hidden-xs">
+                  <SearchForm
+                    inputValue={searchText}
+                    setInputValue={setSearchText}
+                  />
                 </Col>
-                <Col md={5} xs={12}>
+                <Col md={5}>
                   <div className="project-tools">
-                    {selectedProjects.length === 0 ? (
+                    <div className="hidden-xs">
+                      {selectedProjects.length === 0 ? (
+                        <CurrentPlanWidget />
+                      ) : (
+                        <ProjectTools />
+                      )}
+                    </div>
+                    <div className="visible-xs">
                       <CurrentPlanWidget />
-                    ) : (
-                      <ProjectTools />
-                    )}
+                    </div>
                   </div>
                 </Col>
               </Row>
+              <div className="visible-xs mt-1">
+                <div role="toolbar" className="projects-toolbar">
+                  <ProjectsDropdown />
+                  <SortByDropdown />
+                </div>
+              </div>
               <Row className="row-spaced">
                 <Col xs={12}>
-                  <ProjectListTable />
+                  <div className="card project-list-card">
+                    <div className="visible-xs pt-2 pb-3">
+                      <div className="clearfix">
+                        <NewProjectButton
+                          id="new-project-button-projects-table"
+                          className="pull-left me-2"
+                        />
+                        <SearchForm
+                          inputValue={searchText}
+                          setInputValue={setSearchText}
+                          className="overflow-hidden"
+                          formGroupProps={{ className: 'mb-0' }}
+                        />
+                      </div>
+                    </div>
+                    <ProjectListTable />
+                  </div>
                 </Col>
               </Row>
-            </Col>
+            </div>
           </>
         ) : (
           <Row className="row-spaced">

+ 15 - 0
services/web/frontend/js/features/project-list/components/projects-filter-menu.ts

@@ -0,0 +1,15 @@
+import { Filter, useProjectListContext } from '../context/project-list-context'
+
+type ProjectsMenuFilterType = {
+  children: (isActive: boolean) => React.ReactElement
+  filter: Filter
+}
+
+function ProjectsFilterMenu({ children, filter }: ProjectsMenuFilterType) {
+  const { filter: activeFilter, selectedTagId } = useProjectListContext()
+  const isActive = selectedTagId === undefined && filter === activeFilter
+
+  return children(isActive)
+}
+
+export default ProjectsFilterMenu

+ 36 - 15
services/web/frontend/js/features/project-list/components/search-form.tsx

@@ -1,21 +1,35 @@
-import { useState, useEffect } from 'react'
 import { useTranslation } from 'react-i18next'
-import { Form, FormGroup, Col, FormControl } from 'react-bootstrap'
+import {
+  Form,
+  FormGroup,
+  FormGroupProps,
+  Col,
+  FormControl,
+} from 'react-bootstrap'
 import Icon from '../../../shared/components/icon'
 import * as eventTracking from '../../../infrastructure/event-tracking'
+import classnames from 'classnames'
 
-type SearchFormProps = {
-  onChange: (input: string) => void
+type SearchFormOwnProps = {
+  inputValue: string
+  setInputValue: (input: string) => void
+  formGroupProps?: FormGroupProps &
+    Omit<React.ComponentProps<'div'>, keyof FormGroupProps>
 }
 
-function SearchForm({ onChange }: SearchFormProps) {
+type SearchFormProps = SearchFormOwnProps &
+  Omit<React.ComponentProps<typeof Form>, keyof SearchFormOwnProps>
+
+function SearchForm({
+  inputValue,
+  setInputValue,
+  formGroupProps,
+  ...props
+}: SearchFormProps) {
   const { t } = useTranslation()
-  const [input, setInput] = useState('')
   const placeholder = `${t('search_projects')}…`
-
-  useEffect(() => {
-    onChange(input)
-  }, [input, onChange])
+  const { className: formGroupClassName, ...restFormGroupProps } =
+    formGroupProps || {}
 
   const handleChange = (
     e: React.ChangeEvent<
@@ -27,10 +41,10 @@ function SearchForm({ onChange }: SearchFormProps) {
       'project-search',
       'keydown'
     )
-    setInput(e.target.value)
+    setInputValue(e.target.value)
   }
 
-  const handleClear = () => setInput('')
+  const handleClear = () => setInputValue('')
 
   return (
     <Form
@@ -38,18 +52,25 @@ function SearchForm({ onChange }: SearchFormProps) {
       className="project-search"
       role="search"
       onSubmit={e => e.preventDefault()}
+      {...props}
     >
-      <FormGroup className="has-feedback has-feedback-left">
+      <FormGroup
+        className={classnames(
+          'has-feedback has-feedback-left',
+          formGroupClassName
+        )}
+        {...restFormGroupProps}
+      >
         <Col xs={12}>
           <FormControl
             type="text"
-            value={input}
+            value={inputValue}
             onChange={handleChange}
             placeholder={placeholder}
             aria-label={placeholder}
           />
           <Icon type="search" className="form-control-feedback-left" />
-          {input.length ? (
+          {inputValue.length ? (
             <div className="form-control-feedback">
               <button
                 type="button"

+ 12 - 15
services/web/frontend/js/features/project-list/components/sidebar/sidebar-filters.tsx

@@ -1,4 +1,3 @@
-import { ReactNode } from 'react'
 import { useTranslation } from 'react-i18next'
 import { Button } from 'react-bootstrap'
 import {
@@ -6,26 +5,24 @@ import {
   useProjectListContext,
 } from '../../context/project-list-context'
 import TagsList from './tags-list'
+import ProjectsFilterMenu from '../projects-filter-menu'
 
 type SidebarFilterProps = {
   filter: Filter
-  text: ReactNode
+  text: React.ReactNode
 }
-function SidebarFilter({ filter, text }: SidebarFilterProps) {
-  const {
-    filter: activeFilter,
-    selectFilter,
-    selectedTagId,
-  } = useProjectListContext()
+
+export function SidebarFilter({ filter, text }: SidebarFilterProps) {
+  const { selectFilter } = useProjectListContext()
 
   return (
-    <li
-      className={
-        selectedTagId === undefined && filter === activeFilter ? 'active' : ''
-      }
-    >
-      <Button onClick={() => selectFilter(filter)}>{text}</Button>
-    </li>
+    <ProjectsFilterMenu filter={filter}>
+      {isActive => (
+        <li className={isActive ? 'active' : ''}>
+          <Button onClick={() => selectFilter(filter)}>{text}</Button>
+        </li>
+      )}
+    </ProjectsFilterMenu>
   )
 }
 

+ 18 - 92
services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx

@@ -1,95 +1,31 @@
 import _ from 'lodash'
-import { useCallback, useState } from 'react'
 import { Button } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
-import { Tag } from '../../../../../../app/src/Features/Tags/types'
 import ColorManager from '../../../../ide/colors/ColorManager'
 import Icon from '../../../../shared/components/icon'
 import {
   UNCATEGORIZED_KEY,
   useProjectListContext,
 } from '../../context/project-list-context'
-import CreateTagModal from './create-tag-modal'
-import DeleteTagModal from './delete-tag-modal'
-import RenameTagModal from './rename-tag-modal'
+import useTag from '../../hooks/use-tag'
 
 export default function TagsList() {
   const { t } = useTranslation()
+  const { tags, untaggedProjectsCount, selectedTagId, selectTag } =
+    useProjectListContext()
   const {
-    tags,
-    untaggedProjectsCount,
-    selectedTagId,
-    selectTag,
-    addTag,
-    renameTag,
-    deleteTag,
-  } = useProjectListContext()
-
-  const [creatingTag, setCreatingTag] = useState<boolean>(false)
-  const [renamingTag, setRenamingTag] = useState<Tag>()
-  const [deletingTag, setDeletingTag] = useState<Tag>()
-
-  const handleSelectTag = useCallback(
-    (e, tagId) => {
-      e.preventDefault()
-      selectTag(tagId)
-    },
-    [selectTag]
-  )
-
-  const openCreateTagModal = useCallback(() => {
-    setCreatingTag(true)
-  }, [setCreatingTag])
-
-  const onCreate = useCallback(
-    (tag: Tag) => {
-      setCreatingTag(false)
-      addTag(tag)
-    },
-    [addTag]
-  )
-
-  const handleRenameTag = useCallback(
-    (e, tagId) => {
-      e.preventDefault()
-      const tag = _.find(tags, ['_id', tagId])
-      if (tag) {
-        setRenamingTag(tag)
-      }
-    },
-    [tags, setRenamingTag]
-  )
-
-  const onRename = useCallback(
-    (tagId: string, newTagName: string) => {
-      renameTag(tagId, newTagName)
-      setRenamingTag(undefined)
-    },
-    [renameTag, setRenamingTag]
-  )
-
-  const handleDeleteTag = useCallback(
-    (e, tagId) => {
-      e.preventDefault()
-      const tag = _.find(tags, ['_id', tagId])
-      if (tag) {
-        setDeletingTag(tag)
-      }
-    },
-    [tags, setDeletingTag]
-  )
-
-  const onDelete = useCallback(
-    tagId => {
-      deleteTag(tagId)
-      setDeletingTag(undefined)
-    },
-    [deleteTag, setDeletingTag]
-  )
+    handleSelectTag,
+    openCreateTagModal,
+    handleRenameTag,
+    handleDeleteTag,
+    CreateTagModal,
+    RenameTagModal,
+    DeleteTagModal,
+  } = useTag()
 
   return (
     <>
-      <li className="separator">
+      <li role="separator" className="separator">
         <h2>{t('tags_slash_folders')}</h2>
       </li>
       <li className="tag">
@@ -106,7 +42,9 @@ export default function TagsList() {
           >
             <Button
               className="tag-name"
-              onClick={e => handleSelectTag(e, tag._id)}
+              onClick={e =>
+                handleSelectTag(e as unknown as React.MouseEvent, tag._id)
+              }
             >
               <span
                 style={{
@@ -169,21 +107,9 @@ export default function TagsList() {
           <span className="subdued"> ({untaggedProjectsCount})</span>
         </Button>
       </li>
-      <CreateTagModal
-        show={creatingTag}
-        onCreate={onCreate}
-        onClose={() => setCreatingTag(false)}
-      />
-      <RenameTagModal
-        tag={renamingTag}
-        onRename={onRename}
-        onClose={() => setRenamingTag(undefined)}
-      />
-      <DeleteTagModal
-        tag={deletingTag}
-        onDelete={onDelete}
-        onClose={() => setDeletingTag(undefined)}
-      />
+      <CreateTagModal id="create-tag-modal" />
+      <RenameTagModal id="delete-tag-modal" />
+      <DeleteTagModal id="rename-tag-modal" />
     </>
   )
 }

+ 45 - 0
services/web/frontend/js/features/project-list/components/sort/with-content.tsx

@@ -0,0 +1,45 @@
+import { useTranslation } from 'react-i18next'
+import { Sort } from '../../../../../../types/project/dashboard/api'
+
+type SortBtnOwnProps = {
+  column: string
+  sort: Sort
+  text: string
+  onClick: () => void
+}
+
+type WithContentProps = {
+  iconType?: string
+  screenReaderText: string
+}
+
+export type SortBtnProps = SortBtnOwnProps & WithContentProps
+
+function withContent<T extends SortBtnOwnProps>(
+  WrappedComponent: React.ComponentType<T & WithContentProps>
+) {
+  function WithContent(hocProps: T) {
+    const { t } = useTranslation()
+    const { column, text, sort } = hocProps
+    let iconType
+
+    let screenReaderText = t('sort_by_x', { x: text })
+
+    if (column === sort.by) {
+      iconType = sort.order === 'asc' ? 'caret-up' : 'caret-down'
+      screenReaderText = t('reverse_x_sort_order', { x: text })
+    }
+
+    return (
+      <WrappedComponent
+        {...hocProps}
+        iconType={iconType}
+        screenReaderText={screenReaderText}
+      />
+    )
+  }
+
+  return WithContent
+}
+
+export default withContent

+ 35 - 20
services/web/frontend/js/features/project-list/components/table/cells/action-buttons/archive-project-button.tsx

@@ -3,16 +3,20 @@ import { Project } from '../../../../../../../../types/project/dashboard/api'
 import { memo, useCallback, useState } from 'react'
 import Icon from '../../../../../../shared/components/icon'
 import Tooltip from '../../../../../../shared/components/tooltip'
-import ProjectsActionModal from '../../projects-action-modal'
+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'
 
 type ArchiveProjectButtonProps = {
   project: Project
+  children: (text: string, handleOpenModal: () => void) => React.ReactElement
 }
 
-function ArchiveProjectButton({ project }: ArchiveProjectButtonProps) {
+function ArchiveProjectButton({
+  project,
+  children,
+}: ArchiveProjectButtonProps) {
   const { updateProjectViewData } = useProjectListContext()
   const { t } = useTranslation()
   const text = t('archive')
@@ -41,30 +45,41 @@ function ArchiveProjectButton({ project }: ArchiveProjectButtonProps) {
 
   return (
     <>
-      <Tooltip
-        key={`tooltip-archive-project-${project.id}`}
-        id={`tooltip-archive-project-${project.id}`}
-        description={text}
-        overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
-      >
-        <button
-          className="btn btn-link action-btn"
-          aria-label={text}
-          onClick={handleOpenModal}
-        >
-          <Icon type="inbox" />
-        </button>
-      </Tooltip>
-
-      <ProjectsActionModal
-        action="archive"
+      {children(text, handleOpenModal)}
+      <ArchiveProjectModal
+        projects={[project]}
         actionHandler={handleArchiveProject}
         showModal={showModal}
         handleCloseModal={handleCloseModal}
-        projects={[project]}
       />
     </>
   )
 }
 
+const ArchiveProjectButtonTooltip = memo(function ArchiveProjectButtonTooltip({
+  project,
+}: Pick<ArchiveProjectButtonProps, 'project'>) {
+  return (
+    <ArchiveProjectButton project={project}>
+      {(text, handleOpenModal) => (
+        <Tooltip
+          key={`tooltip-archive-project-${project.id}`}
+          id={`archive-project-${project.id}`}
+          description={text}
+          overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
+        >
+          <button
+            className="btn btn-link action-btn"
+            aria-label={text}
+            onClick={handleOpenModal}
+          >
+            <Icon type="inbox" />
+          </button>
+        </Tooltip>
+      )}
+    </ArchiveProjectButton>
+  )
+})
+
 export default memo(ArchiveProjectButton)
+export { ArchiveProjectButtonTooltip }

+ 31 - 18
services/web/frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button.tsx

@@ -1,18 +1,19 @@
-import { useTranslation } from 'react-i18next'
 import { memo, useCallback, useState } from 'react'
-import { Project } from '../../../../../../../../types/project/dashboard/api'
+import { useTranslation } from 'react-i18next'
 import Icon from '../../../../../../shared/components/icon'
 import Tooltip from '../../../../../../shared/components/tooltip'
 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'
 import * as eventTracking from '../../../../../../infrastructure/event-tracking'
+import { Project } from '../../../../../../../../types/project/dashboard/api'
 
 type CopyButtonProps = {
   project: Project
+  children: (text: string, handleOpenModal: () => void) => React.ReactElement
 }
 
-function CopyProjectButton({ project }: CopyButtonProps) {
+function CopyProjectButton({ project, children }: CopyButtonProps) {
   const { addClonedProjectToViewData } = useProjectListContext()
   const { t } = useTranslation()
   const text = t('copy')
@@ -46,21 +47,7 @@ function CopyProjectButton({ project }: CopyButtonProps) {
 
   return (
     <>
-      <Tooltip
-        key={`tooltip-copy-project-${project.id}`}
-        id={`tooltip-copy-project-${project.id}`}
-        description={text}
-        overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
-      >
-        <button
-          className="btn btn-link action-btn"
-          aria-label={text}
-          onClick={handleOpenModal}
-        >
-          <Icon type="files-o" />
-        </button>
-      </Tooltip>
-
+      {children(text, handleOpenModal)}
       <CloneProjectModal
         show={showModal}
         handleHide={handleCloseModal}
@@ -72,4 +59,30 @@ function CopyProjectButton({ project }: CopyButtonProps) {
   )
 }
 
+const CopyProjectButtonTooltip = memo(function CopyProjectButtonTooltip({
+  project,
+}: Pick<CopyButtonProps, 'project'>) {
+  return (
+    <CopyProjectButton project={project}>
+      {(text, handleOpenModal) => (
+        <Tooltip
+          key={`tooltip-copy-project-${project.id}`}
+          id={`copy-project-${project.id}`}
+          description={text}
+          overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
+        >
+          <button
+            className="btn btn-link action-btn"
+            aria-label={text}
+            onClick={handleOpenModal}
+          >
+            <Icon type="files-o" />
+          </button>
+        </Tooltip>
+      )}
+    </CopyProjectButton>
+  )
+})
+
 export default memo(CopyProjectButton)
+export { CopyProjectButtonTooltip }

+ 33 - 21
services/web/frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button.tsx

@@ -1,18 +1,19 @@
-import { useTranslation } from 'react-i18next'
 import { memo, useCallback, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
 import { Project } from '../../../../../../../../types/project/dashboard/api'
 import Icon from '../../../../../../shared/components/icon'
 import Tooltip from '../../../../../../shared/components/tooltip'
-import ProjectsActionModal from '../../projects-action-modal'
+import DeleteProjectModal from '../../../modals/delete-project-modal'
 import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
 import { deleteProject } from '../../../../util/api'
 import { useProjectListContext } from '../../../../context/project-list-context'
 
 type DeleteProjectButtonProps = {
   project: Project
+  children: (text: string, handleOpenModal: () => void) => React.ReactElement
 }
 
-function DeleteProjectButton({ project }: DeleteProjectButtonProps) {
+function DeleteProjectButton({ project, children }: DeleteProjectButtonProps) {
   const { removeProjectFromView } = useProjectListContext()
   const { t } = useTranslation()
   const text = t('delete')
@@ -44,30 +45,41 @@ function DeleteProjectButton({ project }: DeleteProjectButtonProps) {
 
   return (
     <>
-      <Tooltip
-        key={`tooltip-delete-project-${project.id}`}
-        id={`tooltip-delete-project-${project.id}`}
-        description={text}
-        overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
-      >
-        <button
-          className="btn btn-link action-btn"
-          aria-label={text}
-          onClick={handleOpenModal}
-        >
-          <Icon type="ban" />
-        </button>
-      </Tooltip>
-
-      <ProjectsActionModal
-        action="delete"
+      {children(text, handleOpenModal)}
+      <DeleteProjectModal
+        projects={[project]}
         actionHandler={handleDeleteProject}
         showModal={showModal}
         handleCloseModal={handleCloseModal}
-        projects={[project]}
       />
     </>
   )
 }
 
+const DeleteProjectButtonTooltip = memo(function DeleteProjectButtonTooltip({
+  project,
+}: Pick<DeleteProjectButtonProps, 'project'>) {
+  return (
+    <DeleteProjectButton project={project}>
+      {(text, handleOpenModal) => (
+        <Tooltip
+          key={`tooltip-delete-project-${project.id}`}
+          id={`delete-project-${project.id}`}
+          description={text}
+          overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
+        >
+          <button
+            className="btn btn-link action-btn"
+            aria-label={text}
+            onClick={handleOpenModal}
+          >
+            <Icon type="ban" />
+          </button>
+        </Tooltip>
+      )}
+    </DeleteProjectButton>
+  )
+})
+
 export default memo(DeleteProjectButton)
+export { DeleteProjectButtonTooltip }

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

@@ -7,9 +7,13 @@ import * as eventTracking from '../../../../../../infrastructure/event-tracking'
 
 type DownloadProjectButtonProps = {
   project: Project
+  children: (text: string, downloadProject: () => void) => React.ReactElement
 }
 
-function DownloadProjectButton({ project }: DownloadProjectButtonProps) {
+function DownloadProjectButton({
+  project,
+  children,
+}: DownloadProjectButtonProps) {
   const { t } = useTranslation()
   const text = t('download')
 
@@ -22,22 +26,35 @@ function DownloadProjectButton({ project }: DownloadProjectButtonProps) {
     window.location.assign(`/project/${project.id}/download/zip`)
   }, [project])
 
-  return (
-    <Tooltip
-      key={`tooltip-download-project-${project.id}`}
-      id={`tooltip-download-project-${project.id}`}
-      description={text}
-      overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
-    >
-      <button
-        className="btn btn-link action-btn"
-        aria-label={text}
-        onClick={downloadProject}
-      >
-        <Icon type="cloud-download" />
-      </button>
-    </Tooltip>
-  )
+  return children(text, downloadProject)
 }
 
+const DownloadProjectButtonTooltip = memo(
+  function DownloadProjectButtonTooltip({
+    project,
+  }: Pick<DownloadProjectButtonProps, 'project'>) {
+    return (
+      <DownloadProjectButton project={project}>
+        {(text, downloadProject) => (
+          <Tooltip
+            key={`tooltip-download-project-${project.id}`}
+            id={`download-project-${project.id}`}
+            description={text}
+            overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
+          >
+            <button
+              className="btn btn-link action-btn"
+              aria-label={text}
+              onClick={downloadProject}
+            >
+              <Icon type="cloud-download" />
+            </button>
+          </Tooltip>
+        )}
+      </DownloadProjectButton>
+    )
+  }
+)
+
 export default memo(DownloadProjectButton)
+export { DownloadProjectButtonTooltip }

+ 34 - 22
services/web/frontend/js/features/project-list/components/table/cells/action-buttons/leave-project-buttton.tsx → services/web/frontend/js/features/project-list/components/table/cells/action-buttons/leave-project-button.tsx

@@ -1,18 +1,19 @@
-import { useTranslation } from 'react-i18next'
 import { memo, useCallback, useMemo, useState } from 'react'
-import { Project } from '../../../../../../../../types/project/dashboard/api'
+import { useTranslation } from 'react-i18next'
 import Icon from '../../../../../../shared/components/icon'
 import Tooltip from '../../../../../../shared/components/tooltip'
+import LeaveProjectModal from '../../../modals/leave-project-modal'
 import { useProjectListContext } from '../../../../context/project-list-context'
 import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
-import ProjectsActionModal from '../../projects-action-modal'
 import { leaveProject } from '../../../../util/api'
+import { Project } from '../../../../../../../../types/project/dashboard/api'
 
 type LeaveProjectButtonProps = {
   project: Project
+  children: (text: string, handleOpenModal: () => void) => React.ReactElement
 }
 
-function LeaveProjectButton({ project }: LeaveProjectButtonProps) {
+function LeaveProjectButton({ project, children }: LeaveProjectButtonProps) {
   const { removeProjectFromView } = useProjectListContext()
   const { t } = useTranslation()
   const text = t('leave')
@@ -43,30 +44,41 @@ function LeaveProjectButton({ project }: LeaveProjectButtonProps) {
 
   return (
     <>
-      <Tooltip
-        key={`tooltip-leave-project-${project.id}`}
-        id={`tooltip-leave-project-${project.id}`}
-        description={text}
-        overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
-      >
-        <button
-          className="btn btn-link action-btn"
-          aria-label={text}
-          onClick={handleOpenModal}
-        >
-          <Icon type="sign-out" />
-        </button>
-      </Tooltip>
-
-      <ProjectsActionModal
-        action="leave"
+      {children(text, handleOpenModal)}
+      <LeaveProjectModal
+        projects={[project]}
         actionHandler={handleLeaveProject}
         showModal={showModal}
         handleCloseModal={handleCloseModal}
-        projects={[project]}
       />
     </>
   )
 }
 
+const LeaveProjectButtonTooltip = memo(function LeaveProjectButtonTooltip({
+  project,
+}: Pick<LeaveProjectButtonProps, 'project'>) {
+  return (
+    <LeaveProjectButton project={project}>
+      {(text, handleOpenModal) => (
+        <Tooltip
+          key={`tooltip-leave-project-${project.id}`}
+          id={`leave-project-${project.id}`}
+          description={text}
+          overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
+        >
+          <button
+            className="btn btn-link action-btn"
+            aria-label={text}
+            onClick={handleOpenModal}
+          >
+            <Icon type="sign-out" />
+          </button>
+        </Tooltip>
+      )}
+    </LeaveProjectButton>
+  )
+})
+
 export default memo(LeaveProjectButton)
+export { LeaveProjectButtonTooltip }

+ 32 - 20
services/web/frontend/js/features/project-list/components/table/cells/action-buttons/trash-project-button.tsx

@@ -3,16 +3,17 @@ import { memo, useCallback, useState } from 'react'
 import { Project } from '../../../../../../../../types/project/dashboard/api'
 import Icon from '../../../../../../shared/components/icon'
 import Tooltip from '../../../../../../shared/components/tooltip'
-import ProjectsActionModal from '../../projects-action-modal'
+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'
 
 type TrashProjectButtonProps = {
   project: Project
+  children: (text: string, handleOpenModal: () => void) => React.ReactElement
 }
 
-function TrashProjectButton({ project }: TrashProjectButtonProps) {
+function TrashProjectButton({ project, children }: TrashProjectButtonProps) {
   const { updateProjectViewData } = useProjectListContext()
   const { t } = useTranslation()
   const text = t('trash')
@@ -42,30 +43,41 @@ function TrashProjectButton({ project }: TrashProjectButtonProps) {
 
   return (
     <>
-      <Tooltip
-        key={`tooltip-trash-project-${project.id}`}
-        id={`tooltip-trash-project-${project.id}`}
-        description={text}
-        overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
-      >
-        <button
-          className="btn btn-link action-btn"
-          aria-label={text}
-          onClick={handleOpenModal}
-        >
-          <Icon type="trash" />
-        </button>
-      </Tooltip>
-
-      <ProjectsActionModal
-        action="trash"
+      {children(text, handleOpenModal)}
+      <TrashProjectModal
+        projects={[project]}
         actionHandler={handleTrashProject}
         showModal={showModal}
         handleCloseModal={handleCloseModal}
-        projects={[project]}
       />
     </>
   )
 }
 
+const TrashProjectButtonTooltip = memo(function TrashProjectButtonTooltip({
+  project,
+}: Pick<TrashProjectButtonProps, 'project'>) {
+  return (
+    <TrashProjectButton project={project}>
+      {(text, handleOpenModal) => (
+        <Tooltip
+          key={`tooltip-trash-project-${project.id}`}
+          id={`trash-project-${project.id}`}
+          description={text}
+          overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
+        >
+          <button
+            className="btn btn-link action-btn"
+            aria-label={text}
+            onClick={handleOpenModal}
+          >
+            <Icon type="trash" />
+          </button>
+        </Tooltip>
+      )}
+    </TrashProjectButton>
+  )
+})
+
 export default memo(TrashProjectButton)
+export { TrashProjectButtonTooltip }

+ 37 - 17
services/web/frontend/js/features/project-list/components/table/cells/action-buttons/unarchive-project-button.tsx

@@ -8,9 +8,16 @@ import { unarchiveProject } from '../../../../util/api'
 
 type UnarchiveProjectButtonProps = {
   project: Project
+  children: (
+    text: string,
+    handleUnarchiveProject: () => Promise<void>
+  ) => React.ReactElement
 }
 
-function UnarchiveProjectButton({ project }: UnarchiveProjectButtonProps) {
+function UnarchiveProjectButton({
+  project,
+  children,
+}: UnarchiveProjectButtonProps) {
   const { t } = useTranslation()
   const text = t('unarchive')
   const { updateProjectViewData } = useProjectListContext()
@@ -25,22 +32,35 @@ function UnarchiveProjectButton({ project }: UnarchiveProjectButtonProps) {
 
   if (!project.archived) return null
 
-  return (
-    <Tooltip
-      key={`tooltip-unarchive-project-${project.id}`}
-      id={`tooltip-unarchive-project-${project.id}`}
-      description={text}
-      overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
-    >
-      <button
-        className="btn btn-link action-btn"
-        aria-label={text}
-        onClick={handleUnarchiveProject}
-      >
-        <Icon type="reply" />
-      </button>
-    </Tooltip>
-  )
+  return children(text, handleUnarchiveProject)
 }
 
+const UnarchiveProjectButtonTooltip = memo(
+  function UnarchiveProjectButtonTooltip({
+    project,
+  }: Pick<UnarchiveProjectButtonProps, 'project'>) {
+    return (
+      <UnarchiveProjectButton project={project}>
+        {(text, handleUnarchiveProject) => (
+          <Tooltip
+            key={`tooltip-unarchive-project-${project.id}`}
+            id={`unarchive-project-${project.id}`}
+            description={text}
+            overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
+          >
+            <button
+              className="btn btn-link action-btn"
+              aria-label={text}
+              onClick={handleUnarchiveProject}
+            >
+              <Icon type="reply" />
+            </button>
+          </Tooltip>
+        )}
+      </UnarchiveProjectButton>
+    )
+  }
+)
+
 export default memo(UnarchiveProjectButton)
+export { UnarchiveProjectButtonTooltip }

+ 34 - 16
services/web/frontend/js/features/project-list/components/table/cells/action-buttons/untrash-project-button.tsx

@@ -8,9 +8,16 @@ import { untrashProject } from '../../../../util/api'
 
 type UntrashProjectButtonProps = {
   project: Project
+  children: (
+    text: string,
+    untrashProject: () => Promise<void>
+  ) => React.ReactElement
 }
 
-function UntrashProjectButton({ project }: UntrashProjectButtonProps) {
+function UntrashProjectButton({
+  project,
+  children,
+}: UntrashProjectButtonProps) {
   const { t } = useTranslation()
   const text = t('untrash')
   const { updateProjectViewData } = useProjectListContext()
@@ -24,22 +31,33 @@ function UntrashProjectButton({ project }: UntrashProjectButtonProps) {
 
   if (!project.trashed) return null
 
+  return children(text, handleUntrashProject)
+}
+
+const UntrashProjectButtonTooltip = memo(function UntrashProjectButtonTooltip({
+  project,
+}: Pick<UntrashProjectButtonProps, 'project'>) {
   return (
-    <Tooltip
-      key={`tooltip-untrash-project-${project.id}`}
-      id={`tooltip-untrash-project-${project.id}`}
-      description={text}
-      overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
-    >
-      <button
-        className="btn btn-link action-btn"
-        aria-label={text}
-        onClick={handleUntrashProject}
-      >
-        <Icon type="reply" />
-      </button>
-    </Tooltip>
+    <UntrashProjectButton project={project}>
+      {(text, handleUntrashProject) => (
+        <Tooltip
+          key={`tooltip-untrash-project-${project.id}`}
+          id={`untrash-project-${project.id}`}
+          description={text}
+          overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
+        >
+          <button
+            className="btn btn-link action-btn"
+            aria-label={text}
+            onClick={handleUntrashProject}
+          >
+            <Icon type="reply" />
+          </button>
+        </Tooltip>
+      )}
+    </UntrashProjectButton>
   )
-}
+})
 
 export default memo(UntrashProjectButton)
+export { UntrashProjectButtonTooltip }

+ 17 - 16
services/web/frontend/js/features/project-list/components/table/cells/actions-cell.tsx

@@ -1,27 +1,28 @@
 import { Project } from '../../../../../../../types/project/dashboard/api'
-import CopyProjectButton from './action-buttons/copy-project-button'
-import ArchiveProjectButton from './action-buttons/archive-project-button'
-import TrashProjectButton from './action-buttons/trash-project-button'
-import UnarchiveProjectButton from './action-buttons/unarchive-project-button'
-import UntrashProjectButton from './action-buttons/untrash-project-button'
-import DownloadProjectButton from './action-buttons/download-project-button'
-import LeaveProjectButton from './action-buttons/leave-project-buttton'
-import DeleteProjectButton from './action-buttons/delete-project-button'
+import { CopyProjectButtonTooltip } from './action-buttons/copy-project-button'
+import { ArchiveProjectButtonTooltip } from './action-buttons/archive-project-button'
+import { TrashProjectButtonTooltip } from './action-buttons/trash-project-button'
+import { UnarchiveProjectButtonTooltip } from './action-buttons/unarchive-project-button'
+import { UntrashProjectButtonTooltip } from './action-buttons/untrash-project-button'
+import { DownloadProjectButtonTooltip } from './action-buttons/download-project-button'
+import { LeaveProjectButtonTooltip } from './action-buttons/leave-project-button'
+import { DeleteProjectButtonTooltip } from './action-buttons/delete-project-button'
 
 type ActionsCellProps = {
   project: Project
 }
+
 export default function ActionsCell({ project }: ActionsCellProps) {
   return (
     <>
-      <CopyProjectButton project={project} />
-      <DownloadProjectButton project={project} />
-      <ArchiveProjectButton project={project} />
-      <TrashProjectButton project={project} />
-      <UnarchiveProjectButton project={project} />
-      <UntrashProjectButton project={project} />
-      <LeaveProjectButton project={project} />
-      <DeleteProjectButton project={project} />
+      <CopyProjectButtonTooltip project={project} />
+      <DownloadProjectButtonTooltip project={project} />
+      <ArchiveProjectButtonTooltip project={project} />
+      <TrashProjectButtonTooltip project={project} />
+      <UnarchiveProjectButtonTooltip project={project} />
+      <UntrashProjectButtonTooltip project={project} />
+      <LeaveProjectButtonTooltip project={project} />
+      <DeleteProjectButtonTooltip project={project} />
     </>
   )
 }

+ 29 - 3
services/web/frontend/js/features/project-list/components/table/cells/inline-tags.tsx

@@ -1,18 +1,21 @@
+import { useState, useRef } from 'react'
 import { useTranslation } from 'react-i18next'
 import { Tag } from '../../../../../../../app/src/Features/Tags/types'
 import ColorManager from '../../../../../ide/colors/ColorManager'
 import Icon from '../../../../../shared/components/icon'
 import { useProjectListContext } from '../../../context/project-list-context'
+import classnames from 'classnames'
 
 type InlineTagsProps = {
   projectId: string
+  className?: string
 }
 
-function InlineTags({ projectId }: InlineTagsProps) {
+function InlineTags({ projectId, ...props }: InlineTagsProps) {
   const { tags } = useProjectListContext()
 
   return (
-    <span>
+    <span {...props}>
       {tags
         .filter(tag => tag.project_ids?.includes(projectId))
         .map((tag, index) => (
@@ -24,12 +27,32 @@ function InlineTags({ projectId }: InlineTagsProps) {
 
 function InlineTag({ tag }: { tag: Tag }) {
   const { t } = useTranslation()
+  const [classNames, setClassNames] = useState('')
+  const tagLabelRef = useRef(null)
+  const tagBtnRef = useRef<HTMLButtonElement>(null)
+
+  const handleLabelClick = (e: React.MouseEvent) => {
+    // trigger the click on the button only when the event
+    // is triggered from the wrapper element
+    if (e.target === tagLabelRef.current) {
+      tagBtnRef.current?.click()
+    }
+  }
+
+  const handleCloseMouseOver = () => setClassNames('tag-label-close-hover')
+  const handleCloseMouseOut = () => setClassNames('')
 
   return (
-    <div className="tag-label">
+    // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
+    <div
+      className={classnames('tag-label', classNames)}
+      onClick={handleLabelClick}
+      ref={tagLabelRef}
+    >
       <button
         className="label label-default tag-label-name"
         aria-label={t('select_tag', { tagName: tag.name })}
+        ref={tagBtnRef}
       >
         <span
           style={{
@@ -40,9 +63,12 @@ function InlineTag({ tag }: { tag: Tag }) {
         </span>{' '}
         {tag.name}
       </button>
+      {/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
       <button
         className="label label-default tag-label-remove"
         aria-label={t('remove_tag', { tagName: tag.name })}
+        onMouseOver={handleCloseMouseOver}
+        onMouseOut={handleCloseMouseOut}
       >
         <span aria-hidden="true">×</span>
       </button>

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

@@ -7,9 +7,14 @@ import { Project } from '../../../../../../../types/project/dashboard/api'
 type LinkSharingIconProps = {
   prependSpace: boolean
   project: Project
+  className?: string
 }
 
-function LinkSharingIcon({ project, prependSpace }: LinkSharingIconProps) {
+function LinkSharingIcon({
+  project,
+  prependSpace,
+  className,
+}: LinkSharingIconProps) {
   const { t } = useTranslation()
   return (
     <Tooltip
@@ -19,7 +24,7 @@ function LinkSharingIcon({ project, prependSpace }: LinkSharingIconProps) {
       overlayProps={{ placement: 'right', trigger: ['hover', 'focus'] }}
     >
       {/* OverlayTrigger won't fire unless icon is wrapped in a span */}
-      <span>
+      <span className={className}>
         {prependSpace ? ' ' : ''}
         <Icon
           type="link"
@@ -42,7 +47,11 @@ export default function OwnerCell({ project }: OwnerCellProps) {
     <>
       {ownerName}
       {project.source === 'token' ? (
-        <LinkSharingIcon project={project} prependSpace={!!project.owner} />
+        <LinkSharingIcon
+          className="hidden-xs"
+          project={project}
+          prependSpace={!!project.owner}
+        />
       ) : (
         ''
       )}

+ 27 - 7
services/web/frontend/js/features/project-list/components/table/project-list-table-row.tsx

@@ -1,11 +1,13 @@
+import { useCallback } from 'react'
 import { useTranslation } from 'react-i18next'
-import { Project } from '../../../../../../types/project/dashboard/api'
 import InlineTags from './cells/inline-tags'
 import OwnerCell from './cells/owner-cell'
 import LastUpdatedCell from './cells/last-updated-cell'
 import ActionsCell from './cells/actions-cell'
+import ActionsDropdown from '../dropdown/actions-dropdown'
 import { useProjectListContext } from '../../context/project-list-context'
-import { useCallback } from 'react'
+import { getOwnerName } from '../../util/project'
+import { Project } from '../../../../../../types/project/dashboard/api'
 
 type ProjectListTableRowProps = {
   project: Project
@@ -14,6 +16,7 @@ export default function ProjectListTableRow({
   project,
 }: ProjectListTableRowProps) {
   const { t } = useTranslation()
+  const ownerName = getOwnerName(project)
   const { selectedProjects, setSelectedProjects } = useProjectListContext()
 
   const handleCheckboxChange = useCallback(
@@ -35,7 +38,7 @@ export default function ProjectListTableRow({
 
   return (
     <tr>
-      <td className="dash-cell-checkbox">
+      <td className="dash-cell-checkbox hidden-xs">
         <input
           type="checkbox"
           id={`select-project-${project.id}`}
@@ -51,16 +54,33 @@ export default function ProjectListTableRow({
       </td>
       <td className="dash-cell-name">
         <a href={`/project/${project.id}`}>{project.name}</a>{' '}
-        <InlineTags projectId={project.id} />
+        <InlineTags className="hidden-xs" projectId={project.id} />
+      </td>
+      <td className="dash-cell-date-owner visible-xs pb-0">
+        <LastUpdatedCell project={project} />{' '}
+        {ownerName ? (
+          <>
+            — <span className="text-lowercase">{t('owned_by')}</span>{' '}
+            {ownerName}
+          </>
+        ) : null}
       </td>
-      <td className="dash-cell-owner">
+      <td className="dash-cell-owner hidden-xs">
         <OwnerCell project={project} />
       </td>
-      <td className="dash-cell-date">
+      <td className="dash-cell-date hidden-xs">
         <LastUpdatedCell project={project} />
       </td>
+      <td className="dash-cell-tag visible-xs pt-0">
+        <InlineTags projectId={project.id} />
+      </td>
       <td className="dash-cell-actions">
-        <ActionsCell project={project} />
+        <div className="hidden-xs">
+          <ActionsCell project={project} />
+        </div>
+        <div className="visible-xs">
+          <ActionsDropdown project={project} />
+        </div>
       </td>
     </tr>
   )

+ 99 - 134
services/web/frontend/js/features/project-list/components/table/project-list-table.tsx

@@ -3,57 +3,27 @@ import { useTranslation } from 'react-i18next'
 import Icon from '../../../../shared/components/icon'
 import ProjectListTableRow from './project-list-table-row'
 import { useProjectListContext } from '../../context/project-list-context'
-import { Project, Sort } from '../../../../../../types/project/dashboard/api'
-import { SortingOrder } from '../../../../../../types/sorting-order'
-
-type SortByIconTableProps = {
-  column: string
-  sort: Sort
-  text: string
-  onClick: () => void
-}
-
-function SortByButton({ column, sort, text, onClick }: SortByIconTableProps) {
-  const { t } = useTranslation()
-  let icon
-
-  let screenReaderText = t('sort_by_x', { x: text })
-
-  if (column === sort.by) {
-    const iconType = sort.order === 'asc' ? 'caret-up' : 'caret-down'
-    icon = <Icon className="tablesort" type={iconType} />
-    screenReaderText = t('reverse_x_sort_order', { x: text })
-  }
+import useSort from '../../hooks/use-sort'
+import withContent, { SortBtnProps } from '../sort/with-content'
+import { Project } from '../../../../../../types/project/dashboard/api'
 
+function SortBtn({ onClick, text, iconType, screenReaderText }: SortBtnProps) {
   return (
     <button className="btn-link table-header-sort-btn" onClick={onClick}>
       {text}
-      {icon}
+      {iconType ? <Icon className="tablesort" type={iconType} /> : null}
       <span className="sr-only">{screenReaderText}</span>
     </button>
   )
 }
 
-const toggleSort = (order: SortingOrder): SortingOrder => {
-  return order === 'asc' ? 'desc' : 'asc'
-}
+const SortByButton = withContent(SortBtn)
 
 function ProjectListTable() {
   const { t } = useTranslation()
-  const {
-    visibleProjects,
-    sort,
-    setSort,
-    selectedProjects,
-    setSelectedProjects,
-  } = useProjectListContext()
-
-  const handleSortClick = (by: Sort['by']) => {
-    setSort(prev => ({
-      by,
-      order: prev.by === by ? toggleSort(sort.order) : sort.order,
-    }))
-  }
+  const { visibleProjects, sort, selectedProjects, setSelectedProjects } =
+    useProjectListContext()
+  const { handleSort } = useSort()
 
   const handleAllProjectsCheckboxChange = useCallback(
     (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -67,103 +37,98 @@ function ProjectListTable() {
   )
 
   return (
-    <div className="card project-list-card">
-      <table className="project-dash-table">
-        <thead>
-          <tr>
-            <th
-              className="dash-cell-checkbox"
-              aria-label={t('select_projects')}
-            >
-              <input
-                type="checkbox"
-                id="project-list-table-select-all"
-                onChange={handleAllProjectsCheckboxChange}
-                checked={
-                  visibleProjects.length === selectedProjects.length &&
-                  visibleProjects.length !== 0
-                }
-                disabled={visibleProjects.length === 0}
-              />
-              <label
-                htmlFor="project-list-table-select-all"
-                aria-label={t('select_all_projects')}
-                className="sr-only"
-              />
-            </th>
-            <th
-              className="dash-cell-name"
-              aria-label={t('title')}
-              aria-sort={
-                sort.by === 'title'
-                  ? sort.order === 'asc'
-                    ? t('ascending')
-                    : t('descending')
-                  : undefined
-              }
-            >
-              <SortByButton
-                column="title"
-                text={t('title')}
-                sort={sort}
-                onClick={() => handleSortClick('title')}
-              />
-            </th>
-            <th
-              className="dash-cell-owner"
-              aria-label={t('owner')}
-              aria-sort={
-                sort.by === 'owner'
-                  ? sort.order === 'asc'
-                    ? t('ascending')
-                    : t('descending')
-                  : undefined
+    <table className="project-dash-table">
+      <thead className="hidden-xs">
+        <tr>
+          <th className="dash-cell-checkbox" aria-label={t('select_projects')}>
+            <input
+              type="checkbox"
+              id="project-list-table-select-all"
+              onChange={handleAllProjectsCheckboxChange}
+              checked={
+                visibleProjects.length === selectedProjects.length &&
+                visibleProjects.length !== 0
               }
-            >
-              <SortByButton
-                column="owner"
-                text={t('owner')}
-                sort={sort}
-                onClick={() => handleSortClick('owner')}
-              />
-            </th>
-            <th
-              className="dash-cell-date"
-              aria-label={t('last_modified')}
-              aria-sort={
-                sort.by === 'lastUpdated'
-                  ? sort.order === 'asc'
-                    ? t('ascending')
-                    : t('descending')
-                  : undefined
-              }
-            >
-              <SortByButton
-                column="lastUpdated"
-                text={t('last_modified')}
-                sort={sort}
-                onClick={() => handleSortClick('lastUpdated')}
-              />
-            </th>
-            <th className="dash-cell-actions">{t('actions')}</th>
-          </tr>
-        </thead>
+              disabled={visibleProjects.length === 0}
+            />
+            <label
+              htmlFor="project-list-table-select-all"
+              aria-label={t('select_all_projects')}
+              className="sr-only"
+            />
+          </th>
+          <th
+            className="dash-cell-name"
+            aria-label={t('title')}
+            aria-sort={
+              sort.by === 'title'
+                ? sort.order === 'asc'
+                  ? t('ascending')
+                  : t('descending')
+                : undefined
+            }
+          >
+            <SortByButton
+              column="title"
+              text={t('title')}
+              sort={sort}
+              onClick={() => handleSort('title')}
+            />
+          </th>
+          <th
+            className="dash-cell-owner"
+            aria-label={t('owner')}
+            aria-sort={
+              sort.by === 'owner'
+                ? sort.order === 'asc'
+                  ? t('ascending')
+                  : t('descending')
+                : undefined
+            }
+          >
+            <SortByButton
+              column="owner"
+              text={t('owner')}
+              sort={sort}
+              onClick={() => handleSort('owner')}
+            />
+          </th>
+          <th
+            className="dash-cell-date"
+            aria-label={t('last_modified')}
+            aria-sort={
+              sort.by === 'lastUpdated'
+                ? sort.order === 'asc'
+                  ? t('ascending')
+                  : t('descending')
+                : undefined
+            }
+          >
+            <SortByButton
+              column="lastUpdated"
+              text={t('last_modified')}
+              sort={sort}
+              onClick={() => handleSort('lastUpdated')}
+            />
+          </th>
+          <th className="dash-cell-actions">{t('actions')}</th>
+        </tr>
+      </thead>
 
-        <tbody>
-          {visibleProjects.length ? (
-            visibleProjects.map((p: Project) => (
-              <ProjectListTableRow project={p} key={p.id} />
-            ))
-          ) : (
-            <tr className="no-projects">
-              <td className="project-list-table-no-projects-cell" colSpan={5}>
-                {t('no_projects')}
-              </td>
-            </tr>
-          )}
-        </tbody>
-      </table>
-    </div>
+      <tbody>
+        {visibleProjects.length ? (
+          visibleProjects.map((p: Project) => (
+            <ProjectListTableRow project={p} key={p.id} />
+          ))
+        ) : (
+          <tr className="no-projects">
+            <td className="project-list-table-no-projects-cell" colSpan={5}>
+              {t('no_projects')}
+            </td>
+          </tr>
+        )}
+      </tbody>
+    </table>
   )
 }
 

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

@@ -2,10 +2,10 @@ import { memo, useCallback, useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import Icon from '../../../../../../shared/components/icon'
 import Tooltip from '../../../../../../shared/components/tooltip'
+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 ProjectsActionModal from '../../projects-action-modal'
 
 function ArchiveProjectsButton() {
   const { selectedProjects, updateProjectViewData, setSelectedProjects } =
@@ -51,12 +51,11 @@ function ArchiveProjectsButton() {
           <Icon type="inbox" />
         </button>
       </Tooltip>
-      <ProjectsActionModal
-        action="archive"
+      <ArchiveProjectModal
+        projects={selectedProjects}
         actionHandler={handleArchiveProjects}
         showModal={showModal}
         handleCloseModal={handleCloseModal}
-        projects={selectedProjects}
       />
     </>
   )

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

@@ -2,10 +2,10 @@ import { memo, useCallback, useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import Icon from '../../../../../../shared/components/icon'
 import Tooltip from '../../../../../../shared/components/tooltip'
+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 ProjectsActionModal from '../../projects-action-modal'
 
 function TrashProjectsButton() {
   const { selectedProjects, setSelectedProjects, updateProjectViewData } =
@@ -52,12 +52,11 @@ function TrashProjectsButton() {
           <Icon type="trash" />
         </button>
       </Tooltip>
-      <ProjectsActionModal
-        action="trash"
+      <TrashProjectModal
+        projects={selectedProjects}
         actionHandler={handleTrashProjects}
         showModal={showModal}
         handleCloseModal={handleCloseModal}
-        projects={selectedProjects}
       />
     </>
   )

+ 111 - 0
services/web/frontend/js/features/project-list/components/tags-list.tsx

@@ -0,0 +1,111 @@
+import { Button } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import MenuItemButton from './dropdown/menu-item-button'
+import Icon from '../../../shared/components/icon'
+import {
+  UNCATEGORIZED_KEY,
+  useProjectListContext,
+} from '../context/project-list-context'
+import ColorManager from '../../../ide/colors/ColorManager'
+import useTag from '../hooks/use-tag'
+import { sortBy } from 'lodash'
+import { Tag } from '../../../../../app/src/Features/Tags/types'
+
+type TagsListProps = {
+  onTagClick: () => void
+  onEditClick: () => void
+}
+
+function TagsList({ onTagClick, onEditClick }: TagsListProps) {
+  const { t } = useTranslation()
+  const { tags, untaggedProjectsCount, selectedTagId, selectTag } =
+    useProjectListContext()
+
+  const {
+    handleSelectTag,
+    openCreateTagModal,
+    handleEditTag,
+    CreateTagModal,
+    EditTagModal,
+  } = useTag()
+
+  const handleClick = (e: React.MouseEvent, tag: Tag) => {
+    handleSelectTag(e, tag._id)
+    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()
+                handleEditTag(e, tag._id)
+                onEditClick()
+              }}
+              className="btn-transparent edit-btn me-2"
+            >
+              <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: `hsl(${ColorManager.getHueForTagId(tag._id)}, 70%, 45%)`,
+              }}
+            >
+              <Icon
+                type={selectedTagId === tag._id ? 'folder-open' : 'folder'}
+              />
+            </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_folder')}</span>
+        </span>
+      </MenuItemButton>
+      <CreateTagModal id="create-tag-modal-dropdown" />
+      <EditTagModal id="edit-tag-modal-dropdown" />
+    </>
+  )
+}
+
+export default TagsList

+ 4 - 1
services/web/frontend/js/features/project-list/components/welcome-message.tsx

@@ -18,7 +18,10 @@ export default function WelcomeMessage() {
         <Row>
           <Col md={4} mdOffset={4}>
             <div className="dropdown minimal-create-proj-dropdown">
-              <NewProjectButton buttonText={t('create_first_project')} />
+              <NewProjectButton
+                id="new-project-button-welcome"
+                buttonText={t('create_first_project')}
+              />
             </div>
           </Col>
         </Row>

+ 3 - 0
services/web/frontend/js/features/project-list/context/project-list-context.tsx

@@ -71,6 +71,7 @@ type ProjectListContextValue = {
   deleteTag: (tagId: string) => void
   updateProjectViewData: (project: Project) => void
   removeProjectFromView: (project: Project) => void
+  searchText: string
   setSearchText: React.Dispatch<React.SetStateAction<string>>
   selectedProjects: Project[]
   setSelectedProjects: React.Dispatch<React.SetStateAction<Project[]>>
@@ -292,6 +293,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
       selectFilter,
       selectedProjects,
       selectTag,
+      searchText,
       setSearchText,
       setSelectedProjects,
       setSort,
@@ -316,6 +318,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
       selectFilter,
       selectedProjects,
       selectTag,
+      searchText,
       setSearchText,
       setSelectedProjects,
       setSort,

+ 22 - 0
services/web/frontend/js/features/project-list/hooks/use-sort.ts

@@ -0,0 +1,22 @@
+import { useProjectListContext } from '../context/project-list-context'
+import { Sort } from '../../../../../types/project/dashboard/api'
+import { SortingOrder } from '../../../../../types/sorting-order'
+
+const toggleSort = (order: SortingOrder): SortingOrder => {
+  return order === 'asc' ? 'desc' : 'asc'
+}
+
+function useSort() {
+  const { sort, setSort } = useProjectListContext()
+
+  const handleSort = (by: Sort['by']) => {
+    setSort(prev => ({
+      by,
+      order: prev.by === by ? toggleSort(sort.order) : sort.order,
+    }))
+  }
+
+  return { handleSort }
+}
+
+export default useSort

+ 161 - 0
services/web/frontend/js/features/project-list/hooks/use-tag.tsx

@@ -0,0 +1,161 @@
+import { useState, useCallback } from 'react'
+import { useProjectListContext } from '../context/project-list-context'
+import { Tag } from '../../../../../app/src/Features/Tags/types'
+import CreateTagModal from '../components/modals/create-tag-modal'
+import RenameTagModal from '../components/modals/rename-tag-modal'
+import DeleteTagModal from '../components/modals/delete-tag-modal'
+import EditTagModal from '../components/modals/edit-tag-modal'
+import { find } from 'lodash'
+
+function useTag() {
+  const { tags, selectTag, addTag, renameTag, deleteTag } =
+    useProjectListContext()
+  const [creatingTag, setCreatingTag] = useState<boolean>(false)
+  const [renamingTag, setRenamingTag] = useState<Tag>()
+  const [deletingTag, setDeletingTag] = useState<Tag>()
+  const [editingTag, setEditingTag] = useState<Tag>()
+
+  const handleSelectTag = useCallback(
+    (e: React.MouseEvent, tagId: string) => {
+      e.preventDefault()
+      selectTag(tagId)
+    },
+    [selectTag]
+  )
+
+  const openCreateTagModal = useCallback(() => {
+    setCreatingTag(true)
+  }, [setCreatingTag])
+
+  const onCreate = useCallback(
+    (tag: Tag) => {
+      setCreatingTag(false)
+      addTag(tag)
+    },
+    [addTag]
+  )
+
+  const handleRenameTag = useCallback(
+    (e, tagId) => {
+      e.preventDefault()
+      const tag = find(tags, ['_id', tagId])
+      if (tag) {
+        setRenamingTag(tag)
+      }
+    },
+    [tags, setRenamingTag]
+  )
+
+  const onRename = useCallback(
+    (tagId: string, newTagName: string) => {
+      renameTag(tagId, newTagName)
+      setRenamingTag(undefined)
+    },
+    [renameTag, setRenamingTag]
+  )
+
+  const handleDeleteTag = useCallback(
+    (e, tagId) => {
+      e.preventDefault()
+      const tag = find(tags, ['_id', tagId])
+      if (tag) {
+        setDeletingTag(tag)
+      }
+    },
+    [tags, setDeletingTag]
+  )
+
+  const onDelete = useCallback(
+    tagId => {
+      deleteTag(tagId)
+      setDeletingTag(undefined)
+    },
+    [deleteTag, setDeletingTag]
+  )
+
+  const handleEditTag = useCallback(
+    (e, tagId) => {
+      e.preventDefault()
+      const tag = find(tags, ['_id', tagId])
+      if (tag) {
+        setEditingTag(tag)
+      }
+    },
+    [tags, setEditingTag]
+  )
+
+  const onEditRename = useCallback(
+    (tagId: string, newTagName: string) => {
+      renameTag(tagId, newTagName)
+      setEditingTag(undefined)
+    },
+    [renameTag, setEditingTag]
+  )
+
+  const onEditDelete = useCallback(
+    (tagId: string) => {
+      deleteTag(tagId)
+      setEditingTag(undefined)
+    },
+    [deleteTag, setEditingTag]
+  )
+
+  function CreateModal({ id }: { id: string }) {
+    return (
+      <CreateTagModal
+        id={id}
+        show={creatingTag}
+        onCreate={onCreate}
+        onClose={() => setCreatingTag(false)}
+      />
+    )
+  }
+
+  function RenameModal({ id }: { id: string }) {
+    return (
+      <RenameTagModal
+        id={id}
+        tag={renamingTag}
+        onRename={onRename}
+        onClose={() => setRenamingTag(undefined)}
+      />
+    )
+  }
+
+  function DeleteModal({ id }: { id: string }) {
+    return (
+      <DeleteTagModal
+        id={id}
+        tag={deletingTag}
+        onDelete={onDelete}
+        onClose={() => setDeletingTag(undefined)}
+      />
+    )
+  }
+
+  function EditModal({ id }: { id: string }) {
+    return (
+      <EditTagModal
+        id={id}
+        tag={editingTag}
+        onRename={onEditRename}
+        onDelete={onEditDelete}
+        onClose={() => setEditingTag(undefined)}
+      />
+    )
+  }
+
+  return {
+    handleSelectTag,
+    openCreateTagModal,
+    handleRenameTag,
+    handleDeleteTag,
+    handleEditTag,
+    CreateTagModal: CreateModal,
+    RenameTagModal: RenameModal,
+    DeleteTagModal: DeleteModal,
+    EditTagModal: EditModal,
+  }
+}
+
+export default useTag

+ 1 - 0
services/web/frontend/js/shared/components/controlled-dropdown.js

@@ -33,4 +33,5 @@ ControlledDropdown.propTypes = {
   children: PropTypes.any,
   defaultOpen: PropTypes.bool,
   id: PropTypes.string,
+  className: PropTypes.string,
 }

+ 2 - 2
services/web/frontend/stories/project-list/new-project-button.stories.tsx

@@ -62,7 +62,7 @@ export const Success = () => {
     )
   })
 
-  return <NewProjectButton />
+  return <NewProjectButton id="new-project-button-story" />
 }
 
 export const Error = () => {
@@ -83,7 +83,7 @@ export const Error = () => {
     )
   })
 
-  return <NewProjectButton />
+  return <NewProjectButton id="new-project-button-story" />
 }
 
 export default {

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

@@ -38,10 +38,13 @@
   .project-list-sidebar-wrapper {
     float: left;
     position: static;
+    width: 15%;
+    min-width: 160px;
 
     .project-list-sidebar {
       > .dropdown {
         width: 100%;
+
         .new-project-button {
           width: 100%;
         }
@@ -52,16 +55,22 @@
   .project-list-main {
     position: static;
     overflow: auto;
+    padding-left: @grid-gutter-width / 2;
+    padding-right: @grid-gutter-width / 2;
+    margin-left: initial;
   }
 
   ul.folders-menu {
     margin: @folders-menu-margin;
+
     .subdued {
       color: @gray-light;
     }
+
     > li {
       cursor: pointer;
       position: relative;
+
       > button {
         display: block;
         width: 100%;
@@ -73,10 +82,12 @@
         border: none;
         border-bottom: solid 1px transparent;
         padding: @folders-menu-item-v-padding @folders-menu-item-h-padding;
+
         &:hover {
           background-color: @sidebar-hover-bg;
           text-decoration: @sidebar-hover-text-decoration;
         }
+
         &:focus {
           text-decoration: none;
           outline: none;
@@ -88,17 +99,21 @@
         cursor: auto;
       }
     }
+
     > li.active {
       border-radius: @sidebar-active-border-radius;
+
       > button {
         background-color: @sidebar-active-bg;
         font-weight: @sidebar-active-font-weight;
         color: @sidebar-active-color;
+
         .subdued {
           color: @sidebar-active-color;
         }
       }
     }
+
     h2 {
       margin-top: @folders-title-margin-top;
       margin-bottom: @folders-title-margin-bottom;
@@ -109,16 +124,19 @@
       font-weight: @folders-title-font-weight;
       font-family: @font-family-sans-serif;
     }
+
     > li.tag {
       &.active {
         .tag-menu > button {
           color: white;
           border-color: white;
+
           &:hover {
             background-color: @folders-tag-menu-active-hover;
           }
         }
       }
+
       &.untagged {
         button.tag-name {
           span.name {
@@ -127,29 +145,35 @@
           }
         }
       }
+
       &:hover {
         &:not(.active) {
           background-color: @folders-tag-hover;
         }
+
         .tag-menu {
           display: block;
         }
       }
+
       &:not(.active) {
         .tag-menu > a:hover {
           background-color: @folders-tag-menu-hover;
         }
       }
+
       button.tag-name {
         position: relative;
         padding: @folders-tag-padding;
         display: @folders-tag-display;
+
         span.name {
           padding-left: 0.5em;
           line-height: @folders-tag-line-height;
         }
       }
     }
+
     .tag-menu {
       button.dropdown-toggle {
         border: 1px solid @folders-tag-border-color;
@@ -160,21 +184,25 @@
         width: 16px;
         height: 16px;
         position: relative;
+
         .caret {
           position: absolute;
           top: 6px;
           left: 1px;
         }
       }
+
       display: none;
       width: auto;
       position: absolute;
       top: 50%;
       margin-top: -8px; // Half the element height.
       right: 4px;
+
       &.open {
         display: block;
       }
+
       button.tag-action {
         border-radius: unset;
         width: 100%;
@@ -188,6 +216,7 @@
           color: @white;
           background-color: @ol-green;
         }
+
         &:active {
           outline: none;
         }
@@ -242,6 +271,7 @@
       color: @ol-type-color;
       padding: 0;
       font-weight: bold;
+
       &:hover,
       &:focus {
         color: @ol-type-color;
@@ -251,44 +281,80 @@
 
     .dash-cell-checkbox {
       width: 5%;
+
       input[type='checkbox'] {
         margin-top: 5px;
       }
     }
+
     .dash-cell-name {
       width: 50%;
       overflow: hidden;
       text-overflow: ellipsis;
     }
+
     .dash-cell-owner {
       width: 20%;
       overflow: hidden;
       text-overflow: ellipsis;
     }
+
     .dash-cell-date {
       width: 25%;
       overflow: hidden;
       text-overflow: ellipsis;
     }
+
     .dash-cell-actions {
       display: none;
       text-align: right;
       white-space: nowrap;
     }
 
+    .dash-cell-date-owner {
+      font-size: 14px;
+      .text-overflow();
+    }
+
+    .dash-cell-tag {
+      .tag-label {
+        padding: 14px 0;
+        cursor: pointer;
+
+        &:not(.tag-label-close-hover) {
+          &:hover,
+          &:focus {
+            .label.tag-label-name {
+              color: @tag-color;
+              background-color: @tag-bg-hover-color;
+              outline-width: 0;
+            }
+          }
+        }
+
+        &:first-child {
+          margin-left: initial;
+        }
+      }
+    }
+
     @media (min-width: @screen-xs) {
       .dash-cell-checkbox {
         width: 4%;
       }
+
       .dash-cell-name {
         width: 50%;
       }
+
       .dash-cell-owner {
         width: 21%;
       }
+
       .dash-cell-date {
         width: 25%;
       }
+
       .dash-cell-actions {
         width: 0%;
       }
@@ -298,34 +364,46 @@
       .dash-cell-checkbox {
         width: 3%;
       }
+
       .dash-cell-name {
         width: 48%;
       }
+
       .dash-cell-owner {
         width: 13%;
       }
+
       .dash-cell-date {
         width: 15%;
       }
+
       .dash-cell-actions {
         display: table-cell;
         width: 21%;
       }
+
+      .project-tools {
+        float: none;
+      }
     }
 
     @media (min-width: @screen-md) {
       .dash-cell-checkbox {
         width: 3%;
       }
+
       .dash-cell-name {
         width: 50%;
       }
+
       .dash-cell-owner {
         width: 13%;
       }
+
       .dash-cell-date {
         width: 16%;
       }
+
       .dash-cell-actions {
         width: 18%;
       }
@@ -335,15 +413,19 @@
       .dash-cell-checkbox {
         width: 3%;
       }
+
       .dash-cell-name {
         width: 50%;
       }
+
       .dash-cell-owner {
         width: 15%;
       }
+
       .dash-cell-date {
         width: 19%;
       }
+
       .dash-cell-actions {
         width: 13%;
       }
@@ -353,19 +435,64 @@
       .dash-cell-checkbox {
         width: 2%;
       }
+
       .dash-cell-name {
         width: 50%;
       }
+
       .dash-cell-owner {
         width: 16%;
       }
+
       .dash-cell-date {
         width: 19%;
       }
+
       .dash-cell-actions {
         width: 13%;
       }
     }
+
+    @media (max-width: @screen-xs-max) {
+      @actions-btn-size: 48px;
+
+      tr {
+        position: relative;
+        display: flex;
+        flex-direction: column;
+
+        td {
+          padding-top: @line-height-computed / 6;
+          padding-bottom: @line-height-computed / 6;
+        }
+
+        td:not(.dash-cell-actions) {
+          padding-right: @actions-btn-size + 12.5px;
+        }
+      }
+
+      .dash-cell-name,
+      .dash-cell-owner,
+      .dash-cell-date,
+      .dash-cell-tag,
+      .dash-cell-actions {
+        display: block;
+        width: auto;
+      }
+
+      .dash-cell-actions {
+        position: absolute;
+        top: 0;
+        right: 0;
+        padding: 0 !important;
+
+        .dropdown-toggle {
+          padding: 13px 15px;
+          border-radius: 0;
+          font-size: 14px;
+        }
+      }
+    }
   }
 
   .loading-container {
@@ -377,6 +504,74 @@
       margin: 0 auto;
     }
   }
+
+  @media (max-width: @screen-xs-max) {
+    .project-tools {
+      float: left;
+    }
+
+    .row-spaced {
+      margin-top: 5px;
+    }
+  }
+
+  .projects-toolbar,
+  .tag-item {
+    display: flex;
+    align-items: center;
+  }
+
+  .projects-toolbar {
+    flex-wrap: wrap;
+  }
+
+  #projects-types-dropdown {
+    font-family: @font-family-serif;
+
+    & + .projects-dropdown-menu {
+      min-width: 226px;
+    }
+  }
+
+  #projects-sort-dropdown {
+    & + .projects-dropdown-menu {
+      min-width: 156px;
+    }
+  }
+
+  .projects-dropdown-menu {
+    .dropdown-header {
+      padding: 14px 20px;
+      font-size: 13px;
+      text-transform: uppercase;
+    }
+  }
+
+  .projects-types-menu-item {
+    .menu-item-button-icon {
+      left: 10px;
+    }
+
+    &.projects-types-menu-tag-item {
+      display: flex;
+
+      .edit-btn {
+        padding: 12px 14px;
+      }
+    }
+  }
+
+  .projects-sort-menu-item {
+    .menu-item-button-icon {
+      left: 14px;
+    }
+  }
+
+  .projects-action-menu-item {
+    .menu-item-button-icon {
+      left: 11px;
+    }
+  }
 }
 
 .project-list-react.container,
@@ -388,6 +583,7 @@
 .current-plan {
   vertical-align: middle;
   line-height: @line-height-base;
+
   a.current-plan-label {
     text-decoration: none;
     color: @text-color;
@@ -417,3 +613,42 @@
     }
   }
 }
+
+.btn-transparent {
+  box-shadow: none !important;
+  background: none !important;
+  border-radius: 0 !important;
+  color: inherit !important;
+  font-weight: 400;
+
+  &:hover {
+    box-shadow: none !important;
+    background: none !important;
+    color: inherit !important;
+  }
+}
+
+.menu-item-button,
+#new-project-button-projects-table + .dropdown-menu [role='menuitem'] {
+  padding: 12px 20px;
+}
+
+.menu-item-button {
+  position: relative;
+  width: 100%;
+  border: none;
+  box-shadow: none;
+  background: inherit;
+  color: @ol-blue-gray-5;
+  text-align: left;
+
+  .menu-item-button-text {
+    padding-left: 14px;
+  }
+
+  .menu-item-button-icon {
+    position: absolute;
+    top: 50%;
+    transform: translateY(-50%);
+  }
+}

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

@@ -484,6 +484,7 @@ i.tablesort {
   margin-left: @line-height-computed / 4;
   position: relative;
   display: inline-block;
+  line-height: 1;
   white-space: nowrap;
   top: @tag-top-adjustment;
 }

+ 6 - 1
services/web/frontend/stylesheets/components/navbar.less

@@ -213,11 +213,16 @@
 .navbar-toggle {
   position: relative;
   float: right;
+  padding: 3px 10px 0px;
   background-color: transparent;
   background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214
-  border: 2px solid @navbar-default-link-color;
+  border: 0;
   border-radius: @border-radius-base;
 
+  .fa {
+    font-size: @navbar-height / 2;
+  }
+
   // We remove the `outline` here, but later compensate by attaching `:hover`
   // styles to `:focus`.
   &:focus {

+ 16 - 0
services/web/frontend/stylesheets/core/spacing.less

@@ -100,3 +100,19 @@ each(@spacers, {
   margin-top: auto !important;
   margin-bottom: auto !important;
 }
+
+.ms-auto {
+  margin-left: auto !important;
+}
+
+.me-auto {
+  margin-right: auto !important;
+}
+
+.mt-auto {
+  margin-top: auto !important;
+}
+
+.mb-auto {
+  margin-bottom: auto !important;
+}

+ 4 - 1
services/web/frontend/stylesheets/core/type.less

@@ -188,6 +188,9 @@ cite {
 .text-lowercase {
   text-transform: lowercase;
 }
+.text-uppercase {
+  text-transform: uppercase;
+}
 
 // Contextual backgrounds
 // For now we'll leave these alongside the text classes until v4 when we can
@@ -301,7 +304,7 @@ dd {
 
 // Abbreviations and acronyms
 abbr[title],
-// Add data-* attribute to help out our tooltip plugin, per https://github.com/twbs/bootstrap/issues/5257
+  // Add data-* attribute to help out our tooltip plugin, per https://github.com/twbs/bootstrap/issues/5257
 abbr[data-original-title] {
   cursor: help;
   border-bottom: 1px dotted @abbr-border-color;

+ 14 - 0
services/web/frontend/stylesheets/core/utilities.less

@@ -54,3 +54,17 @@
 .w-100 {
   width: 100%;
 }
+
+// Overflow utils
+.overflow-auto {
+  overflow: auto !important;
+}
+.overflow-hidden {
+  overflow: hidden !important;
+}
+.overflow-visible {
+  overflow: visible !important;
+}
+.overflow-scroll {
+  overflow: scroll !important;
+}

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

@@ -856,6 +856,7 @@
   "fast": "Fast",
   "rename_folder": "Rename Folder",
   "delete_folder": "Delete Folder",
+  "edit_folder": "Edit Folder",
   "select_tag": "Select tag __tagName__",
   "remove_tag": "Remove tag __tagName__",
   "about_to_delete_folder": "You are about to delete the following folders (any projects in them will not be deleted):",
@@ -1872,6 +1873,8 @@
   "history_entry_origin_github": "via GitHub",
   "history_entry_origin_dropbox": "via Dropbox",
   "sort_by_x": "Sort by __x__",
+  "sort_by": "Sort by",
+  "owned_by": "Owned by",
   "last_updated_date_by_x":  "__lastUpdatedDate__ by __person__",
   "select_projects": "Select Projects",
   "ascending": "Ascending",
@@ -1880,6 +1883,8 @@
   "create_first_project": "Create First Project",
   "you_dont_have_any_repositories": "You don’t have any repositories",
   "tag_name_cannot_exceed_characters": "Tag name cannot exceed __maxLength__ characters",
+  "save_changes": "Save changes",
+  "tag_name_cannot_exceed_characters": "Tag name cannot exceed __maxLength__ characters",
   "overleaf_labs": "Overleaf Labs",
   "labs_program_already_participating": "You are enrolled in Labs",
   "labs_program_not_participating": "You are not enrolled in Labs",

+ 1 - 1
services/web/test/frontend/features/project-list/components/new-project-button.test.tsx

@@ -17,7 +17,7 @@ describe('<NewProjectButton />', function () {
       ],
     })
 
-    render(<NewProjectButton />)
+    render(<NewProjectButton id="test" />)
 
     const newProjectButton = screen.getByRole('button', {
       name: 'New Project',

+ 8 - 8
services/web/test/frontend/features/project-list/components/project-list-root.test.tsx

@@ -32,7 +32,7 @@ describe('<ProjectListRoot />', function () {
 
   describe('checkboxes', function () {
     let allCheckboxes: Array<HTMLInputElement> = []
-    let toolbar: HTMLElement
+    let actionsToolbar: HTMLElement
     let project1Id: string | null, project2Id: string | null
 
     beforeEach(async function () {
@@ -52,11 +52,11 @@ describe('<ProjectListRoot />', function () {
 
         project1Id = allCheckboxes[1].getAttribute('data-project-id')
         project2Id = allCheckboxes[2].getAttribute('data-project-id')
-        toolbar = screen.getByRole('toolbar')
+        actionsToolbar = screen.getAllByRole('toolbar')[0]
       })
 
       it('downloads all selected projects and then unselects them', async function () {
-        const downloadButton = within(toolbar).getByLabelText('Download')
+        const downloadButton = within(actionsToolbar).getByLabelText('Download')
         fireEvent.click(downloadButton)
 
         await waitFor(() => {
@@ -89,7 +89,7 @@ describe('<ProjectListRoot />', function () {
           { delay: 0 }
         )
 
-        const archiveButton = within(toolbar).getByLabelText('Archive')
+        const archiveButton = within(actionsToolbar).getByLabelText('Archive')
         fireEvent.click(archiveButton)
 
         const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
@@ -124,7 +124,7 @@ describe('<ProjectListRoot />', function () {
           { delay: 0 }
         )
 
-        const archiveButton = within(toolbar).getByLabelText('Trash')
+        const archiveButton = within(actionsToolbar).getByLabelText('Trash')
         fireEvent.click(archiveButton)
 
         const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
@@ -146,7 +146,7 @@ describe('<ProjectListRoot />', function () {
 
     describe('archived projects', function () {
       beforeEach(function () {
-        const filterButton = screen.getByText('Archived Projects')
+        const filterButton = screen.getAllByText('Archived Projects')[0]
         fireEvent.click(filterButton)
 
         allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
@@ -162,7 +162,7 @@ describe('<ProjectListRoot />', function () {
 
     describe('trashed projects', function () {
       beforeEach(function () {
-        const filterButton = screen.getByText('Trashed Projects')
+        const filterButton = screen.getAllByText('Trashed Projects')[0]
         fireEvent.click(filterButton)
 
         allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
@@ -176,7 +176,7 @@ describe('<ProjectListRoot />', function () {
       })
 
       it('clears selected projects when filter changed', function () {
-        const filterButton = screen.getByText('All Projects')
+        const filterButton = screen.getAllByText('All Projects')[0]
         fireEvent.click(filterButton)
 
         const allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')

+ 12 - 15
services/web/test/frontend/features/project-list/components/project-search.test.tsx

@@ -11,7 +11,7 @@ import * as eventTracking from '../../../../../frontend/js/infrastructure/event-
 import fetchMock from 'fetch-mock'
 import { projectsData } from '../fixtures/projects-data'
 
-describe('<ProjectListTable />', function () {
+describe('Project list search form', function () {
   beforeEach(function () {
     fetchMock.reset()
   })
@@ -21,35 +21,32 @@ describe('<ProjectListTable />', function () {
   })
 
   it('renders the search form', function () {
-    render(<SearchForm onChange={() => {}} />)
+    render(<SearchForm inputValue="" setInputValue={() => {}} />)
     screen.getByRole('search')
     screen.getByRole('textbox', { name: /search projects/i })
   })
 
-  it('clears text when clear button is clicked', function () {
-    render(<SearchForm onChange={() => {}} />)
+  it('calls clear text when clear button is clicked', function () {
+    const setInputValueMock = sinon.stub()
+    render(<SearchForm inputValue="abc" setInputValue={setInputValueMock} />)
+
     const input = screen.getByRole<HTMLInputElement>('textbox', {
       name: /search projects/i,
     })
 
-    expect(input.value).to.equal('')
-    expect(screen.queryByRole('button', { name: 'clear search' })).to.be.null // clear button
-
-    fireEvent.change(input, {
-      target: { value: 'abc' },
-    })
+    expect(input.value).to.equal('abc')
 
     const clearBtn = screen.getByRole('button', { name: 'clear search' })
     fireEvent.click(clearBtn)
 
-    expect(input.value).to.equal('')
+    expect(setInputValueMock).to.be.calledWith('')
   })
 
   it('changes text', function () {
-    const onChangeMock = sinon.stub()
+    const setInputValueMock = sinon.stub()
     const sendSpy = sinon.spy(eventTracking, 'send')
 
-    render(<SearchForm onChange={onChangeMock} />)
+    render(<SearchForm inputValue="" setInputValue={setInputValueMock} />)
     const input = screen.getByRole('textbox', { name: /search projects/i })
     const value = 'abc'
 
@@ -59,7 +56,7 @@ describe('<ProjectListTable />', function () {
       'project-search',
       'keydown'
     )
-    expect(onChangeMock).to.be.calledWith(value)
+    expect(setInputValueMock).to.be.calledWith(value)
     sendSpy.restore()
   })
 
@@ -93,7 +90,7 @@ describe('<ProjectListTable />', function () {
       )
 
       const handleChange = result.current.setSearchText
-      render(<SearchForm onChange={handleChange} />)
+      render(<SearchForm inputValue="" setInputValue={handleChange} />)
 
       const input = screen.getByRole('textbox', { name: /search projects/i })
       const value = projectsData[0].name

+ 2 - 2
services/web/test/frontend/features/project-list/components/sidebar/tags-list.test.tsx

@@ -181,13 +181,13 @@ describe('<TagsList />', function () {
     beforeEach(async function () {
       const tag1Button = screen.getByText('Another tag')
 
-      const renameButton = within(
+      const deleteButton = within(
         tag1Button.closest('li') as HTMLElement
       ).getByRole('button', {
         name: 'Delete',
       })
 
-      await fireEvent.click(renameButton)
+      await fireEvent.click(deleteButton)
     })
 
     it('modal is open', async function () {

+ 7 - 5
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/archive-project-button.test.tsx

@@ -1,6 +1,6 @@
 import { expect } from 'chai'
 import { fireEvent, screen } from '@testing-library/react'
-import ArchiveProjectButton from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/archive-project-button'
+import { ArchiveProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/archive-project-button'
 import {
   archiveableProject,
   archivedProject,
@@ -18,7 +18,7 @@ describe('<ArchiveProjectButton />', function () {
 
   it('renders tooltip for button', function () {
     renderWithProjectListContext(
-      <ArchiveProjectButton project={archiveableProject} />
+      <ArchiveProjectButtonTooltip project={archiveableProject} />
     )
     const btn = screen.getByLabelText('Archive')
     fireEvent.mouseOver(btn)
@@ -27,7 +27,7 @@ describe('<ArchiveProjectButton />', function () {
 
   it('opens the modal when clicked', function () {
     renderWithProjectListContext(
-      <ArchiveProjectButton project={archiveableProject} />
+      <ArchiveProjectButtonTooltip project={archiveableProject} />
     )
     const btn = screen.getByLabelText('Archive')
     fireEvent.click(btn)
@@ -37,7 +37,7 @@ describe('<ArchiveProjectButton />', function () {
 
   it('does not render the button when already archived', function () {
     renderWithProjectListContext(
-      <ArchiveProjectButton project={archivedProject} />
+      <ArchiveProjectButtonTooltip project={archivedProject} />
     )
     expect(screen.queryByLabelText('Archive')).to.be.null
   })
@@ -51,7 +51,9 @@ describe('<ArchiveProjectButton />', function () {
       },
       { delay: 0 }
     )
-    renderWithProjectListContext(<ArchiveProjectButton project={project} />)
+    renderWithProjectListContext(
+      <ArchiveProjectButtonTooltip project={project} />
+    )
     const btn = screen.getByLabelText('Archive')
     fireEvent.click(btn)
     screen.getByText('Archive Projects')

+ 7 - 5
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx

@@ -1,6 +1,6 @@
 import { expect } from 'chai'
 import { fireEvent, screen } from '@testing-library/react'
-import CopyProjectButton from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button'
+import { CopyProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button'
 import {
   archivedProject,
   copyableProject,
@@ -18,7 +18,7 @@ describe('<CopyProjectButton />', function () {
   })
   it('renders tooltip for button', function () {
     renderWithProjectListContext(
-      <CopyProjectButton project={copyableProject} />
+      <CopyProjectButtonTooltip project={copyableProject} />
     )
     const btn = screen.getByLabelText('Copy')
     fireEvent.mouseOver(btn)
@@ -27,13 +27,15 @@ describe('<CopyProjectButton />', function () {
 
   it('does not render the button when project is archived', function () {
     renderWithProjectListContext(
-      <CopyProjectButton project={archivedProject} />
+      <CopyProjectButtonTooltip project={archivedProject} />
     )
     expect(screen.queryByLabelText('Copy')).to.be.null
   })
 
   it('does not render the button when project is trashed', function () {
-    renderWithProjectListContext(<CopyProjectButton project={trashedProject} />)
+    renderWithProjectListContext(
+      <CopyProjectButtonTooltip project={trashedProject} />
+    )
     expect(screen.queryByLabelText('Copy')).to.be.null
   })
 
@@ -46,7 +48,7 @@ describe('<CopyProjectButton />', function () {
       { delay: 0 }
     )
     renderWithProjectListContext(
-      <CopyProjectButton project={copyableProject} />
+      <CopyProjectButtonTooltip project={copyableProject} />
     )
     const btn = screen.getByLabelText('Copy')
     fireEvent.click(btn)

+ 7 - 5
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/delete-project-button.test.tsx

@@ -1,6 +1,6 @@
 import { expect } from 'chai'
 import { fireEvent, screen } from '@testing-library/react'
-import DeleteProjectButton from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button'
+import { DeleteProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button'
 import {
   archiveableProject,
   trashedAndNotOwnedProject,
@@ -20,7 +20,7 @@ describe('<DeleteProjectButton />', function () {
   it('renders tooltip for button', function () {
     window.user_id = trashedProject?.owner?.id
     renderWithProjectListContext(
-      <DeleteProjectButton project={trashedProject} />
+      <DeleteProjectButtonTooltip project={trashedProject} />
     )
     const btn = screen.getByLabelText('Delete')
     fireEvent.mouseOver(btn)
@@ -30,7 +30,7 @@ describe('<DeleteProjectButton />', function () {
   it('does not render button when trashed and not owner', function () {
     window.user_id = '123abc'
     renderWithProjectListContext(
-      <DeleteProjectButton project={trashedAndNotOwnedProject} />
+      <DeleteProjectButtonTooltip project={trashedAndNotOwnedProject} />
     )
     const btn = screen.queryByLabelText('Delete')
     expect(btn).to.be.null
@@ -38,7 +38,7 @@ describe('<DeleteProjectButton />', function () {
 
   it('does not render the button when project is current', function () {
     renderWithProjectListContext(
-      <DeleteProjectButton project={archiveableProject} />
+      <DeleteProjectButtonTooltip project={archiveableProject} />
     )
     expect(screen.queryByLabelText('Delete')).to.be.null
   })
@@ -53,7 +53,9 @@ describe('<DeleteProjectButton />', function () {
       },
       { delay: 0 }
     )
-    renderWithProjectListContext(<DeleteProjectButton project={project} />)
+    renderWithProjectListContext(
+      <DeleteProjectButtonTooltip project={project} />
+    )
     const btn = screen.getByLabelText('Delete')
     fireEvent.click(btn)
     screen.getByText('Delete Projects')

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

@@ -1,7 +1,7 @@
 import { expect } from 'chai'
 import { fireEvent, render, screen, waitFor } from '@testing-library/react'
 import sinon from 'sinon'
-import DownloadProjectButton from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button'
+import { DownloadProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button'
 import { projectsData } from '../../../../fixtures/projects-data'
 
 describe('<DownloadProjectButton />', function () {
@@ -13,7 +13,7 @@ describe('<DownloadProjectButton />', function () {
       value: { assign: locationStub },
     })
 
-    render(<DownloadProjectButton project={projectsData[0]} />)
+    render(<DownloadProjectButtonTooltip project={projectsData[0]} />)
   })
 
   afterEach(function () {

+ 8 - 6
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/leave-project-button.test.tsx

@@ -1,6 +1,6 @@
 import { expect } from 'chai'
 import { fireEvent, screen } from '@testing-library/react'
-import LeaveProjectButton from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/leave-project-buttton'
+import { LeaveProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/leave-project-button'
 import {
   trashedProject,
   trashedAndNotOwnedProject,
@@ -19,7 +19,7 @@ describe('<LeaveProjectButtton />', function () {
   })
   it('renders tooltip for button', function () {
     renderWithProjectListContext(
-      <LeaveProjectButton project={trashedAndNotOwnedProject} />
+      <LeaveProjectButtonTooltip project={trashedAndNotOwnedProject} />
     )
     const btn = screen.getByLabelText('Leave')
     fireEvent.mouseOver(btn)
@@ -29,7 +29,7 @@ describe('<LeaveProjectButtton />', function () {
   it('does not render button when owner', function () {
     window.user_id = trashedProject?.owner?.id
     renderWithProjectListContext(
-      <LeaveProjectButton project={trashedProject} />
+      <LeaveProjectButtonTooltip project={trashedProject} />
     )
     const btn = screen.queryByLabelText('Leave')
     expect(btn).to.be.null
@@ -37,14 +37,14 @@ describe('<LeaveProjectButtton />', function () {
 
   it('does not render the button when project is archived', function () {
     renderWithProjectListContext(
-      <LeaveProjectButton project={archivedProject} />
+      <LeaveProjectButtonTooltip project={archivedProject} />
     )
     expect(screen.queryByLabelText('Leave')).to.be.null
   })
 
   it('does not render the button when project is current', function () {
     renderWithProjectListContext(
-      <LeaveProjectButton project={archiveableProject} />
+      <LeaveProjectButtonTooltip project={archiveableProject} />
     )
     expect(screen.queryByLabelText('Leave')).to.be.null
   })
@@ -58,7 +58,9 @@ describe('<LeaveProjectButtton />', function () {
       },
       { delay: 0 }
     )
-    renderWithProjectListContext(<LeaveProjectButton project={project} />)
+    renderWithProjectListContext(
+      <LeaveProjectButtonTooltip project={project} />
+    )
     const btn = screen.getByLabelText('Leave')
     fireEvent.click(btn)
     screen.getByText('Leave Projects')

+ 6 - 4
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/trash-project-button.test.tsx

@@ -1,6 +1,6 @@
 import { expect } from 'chai'
 import { fireEvent, screen } from '@testing-library/react'
-import TrashProjectButton from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/trash-project-button'
+import { TrashProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/trash-project-button'
 import {
   archivedProject,
   trashedProject,
@@ -18,7 +18,7 @@ describe('<TrashProjectButton />', function () {
 
   it('renders tooltip for button', function () {
     renderWithProjectListContext(
-      <TrashProjectButton project={archivedProject} />
+      <TrashProjectButtonTooltip project={archivedProject} />
     )
     const btn = screen.getByLabelText('Trash')
     fireEvent.mouseOver(btn)
@@ -27,7 +27,7 @@ describe('<TrashProjectButton />', function () {
 
   it('does not render the button when project is trashed', function () {
     renderWithProjectListContext(
-      <TrashProjectButton project={trashedProject} />
+      <TrashProjectButtonTooltip project={trashedProject} />
     )
     expect(screen.queryByLabelText('Trash')).to.be.null
   })
@@ -41,7 +41,9 @@ describe('<TrashProjectButton />', function () {
       },
       { delay: 0 }
     )
-    renderWithProjectListContext(<TrashProjectButton project={project} />)
+    renderWithProjectListContext(
+      <TrashProjectButtonTooltip project={project} />
+    )
     const btn = screen.getByLabelText('Trash')
     fireEvent.click(btn)
     screen.getByText('Trash Projects')

+ 7 - 5
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/unarchive-project-button.test.tsx

@@ -1,6 +1,6 @@
 import { expect } from 'chai'
 import { fireEvent, screen } from '@testing-library/react'
-import UnarchiveProjectButton from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/unarchive-project-button'
+import { UnarchiveProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/unarchive-project-button'
 import {
   archiveableProject,
   archivedProject,
@@ -19,7 +19,7 @@ describe('<UnarchiveProjectButton />', function () {
 
   it('renders tooltip for button', function () {
     renderWithProjectListContext(
-      <UnarchiveProjectButton project={archivedProject} />
+      <UnarchiveProjectButtonTooltip project={archivedProject} />
     )
     const btn = screen.getByLabelText('Restore')
     fireEvent.mouseOver(btn)
@@ -28,14 +28,14 @@ describe('<UnarchiveProjectButton />', function () {
 
   it('does not render the button when project is trashed', function () {
     renderWithProjectListContext(
-      <UnarchiveProjectButton project={trashedProject} />
+      <UnarchiveProjectButtonTooltip project={trashedProject} />
     )
     expect(screen.queryByLabelText('Restore')).to.be.null
   })
 
   it('does not render the button when project is current', function () {
     renderWithProjectListContext(
-      <UnarchiveProjectButton project={archiveableProject} />
+      <UnarchiveProjectButtonTooltip project={archiveableProject} />
     )
     expect(screen.queryByLabelText('Restore')).to.be.null
   })
@@ -49,7 +49,9 @@ describe('<UnarchiveProjectButton />', function () {
       },
       { delay: 0 }
     )
-    renderWithProjectListContext(<UnarchiveProjectButton project={project} />)
+    renderWithProjectListContext(
+      <UnarchiveProjectButtonTooltip project={project} />
+    )
     const btn = screen.getByLabelText('Restore')
     fireEvent.click(btn)
 

+ 6 - 4
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/untrash-project-button.test.tsx

@@ -1,7 +1,7 @@
 import { expect } from 'chai'
 import { fireEvent, screen } from '@testing-library/react'
 import fetchMock from 'fetch-mock'
-import UntrashProjectButton from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/untrash-project-button'
+import { UntrashProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/untrash-project-button'
 import {
   archiveableProject,
   trashedProject,
@@ -22,7 +22,7 @@ describe('<UntrashProjectButton />', function () {
 
   it('renders tooltip for button', function () {
     renderWithProjectListContext(
-      <UntrashProjectButton project={trashedProject} />
+      <UntrashProjectButtonTooltip project={trashedProject} />
     )
     const btn = screen.getByLabelText('Restore')
     fireEvent.mouseOver(btn)
@@ -31,7 +31,7 @@ describe('<UntrashProjectButton />', function () {
 
   it('does not render the button when project is current', function () {
     renderWithProjectListContext(
-      <UntrashProjectButton project={archiveableProject} />
+      <UntrashProjectButtonTooltip project={archiveableProject} />
     )
     expect(screen.queryByLabelText('Restore')).to.be.null
   })
@@ -45,7 +45,9 @@ describe('<UntrashProjectButton />', function () {
       },
       { delay: 0 }
     )
-    renderWithProjectListContext(<UntrashProjectButton project={project} />)
+    renderWithProjectListContext(
+      <UntrashProjectButtonTooltip project={project} />
+    )
     const btn = screen.getByLabelText('Restore')
     fireEvent.click(btn)
 

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

@@ -81,30 +81,30 @@ describe('<ProjectListTable />', function () {
       .getByRole('cell', { name: currentProjects[0].name })
       .closest('tr')!
     within(row1).getByText('You')
-    within(row1).getByText('a day ago by Jean-Luc Picard')
+    within(row1).getAllByText('a day ago by Jean-Luc Picard', { exact: false })
     const row2 = screen
       .getByRole('cell', { name: currentProjects[1].name })
       .closest('tr')!
     within(row2).getByText('Jean-Luc Picard')
-    within(row2).getByText('7 days ago by Jean-Luc Picard')
+    within(row2).getAllByText('7 days ago by Jean-Luc Picard')
     const row3 = screen
       .getByRole('cell', { name: currentProjects[2].name })
       .closest('tr')!
     within(row3).getByText('worf@overleaf.com')
-    within(row3).getByText('a month ago by worf@overleaf.com')
+    within(row3).getAllByText('a month ago by worf@overleaf.com')
     // link sharing project
     const row4 = screen
       .getByRole('cell', { name: currentProjects[3].name })
       .closest('tr')!
     within(row4).getByText('La Forge')
     within(row4).getByText('Link sharing')
-    within(row4).getByText('2 months ago by La Forge')
+    within(row4).getAllByText('2 months ago by La Forge')
     // link sharing read only, so it will not show an owner
     const row5 = screen
       .getByRole('cell', { name: currentProjects[4].name })
       .closest('tr')!
     within(row5).getByText('Link sharing')
-    within(row5).getByText('2 years ago')
+    within(row5).getAllByText('2 years ago')
 
     // Action Column
     // temporary count tests until we add filtering for archived/trashed

+ 1 - 1
services/web/test/frontend/features/project-list/components/table/projects-action-modal.test.tsx

@@ -1,7 +1,7 @@
 import { expect } from 'chai'
 import sinon from 'sinon'
 import { fireEvent, screen, waitFor } from '@testing-library/react'
-import ProjectsActionModal from '../../../../../../frontend/js/features/project-list/components/table/projects-action-modal'
+import ProjectsActionModal from '../../../../../../frontend/js/features/project-list/components/modals/projects-action-modal'
 import { projectsData } from '../../fixtures/projects-data'
 import {
   resetProjectListContextFetch,

+ 9 - 7
services/web/test/frontend/features/project-list/helpers/render-with-context.js → services/web/test/frontend/features/project-list/helpers/render-with-context.tsx

@@ -1,19 +1,21 @@
-// Disable prop type checks for test harnesses
-/* eslint-disable react/prop-types */
-
 import { render } from '@testing-library/react'
 import fetchMock from 'fetch-mock'
 import { ProjectListProvider } from '../../../../../frontend/js/features/project-list/context/project-list-context'
 import { projectsData } from '../fixtures/projects-data'
 
-export function renderWithProjectListContext(component) {
+export function renderWithProjectListContext(
+  component: React.ReactElement,
+  contextProps = {}
+) {
   fetchMock.post('express:/api/project', {
     status: 200,
     body: { projects: projectsData, totalSize: projectsData.length },
   })
-  const ProjectListProviderWrapper = ({ children }) => (
-    <ProjectListProvider>{children}</ProjectListProvider>
-  )
+  const ProjectListProviderWrapper = ({
+    children,
+  }: {
+    children: React.ReactNode
+  }) => <ProjectListProvider {...contextProps}>{children}</ProjectListProvider>
 
   return render(component, {
     wrapper: ProjectListProviderWrapper,