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

[web] Projects dashboard mobile view

GitOrigin-RevId: 84894e19c814a2cc1ce751181952c0ade6b62044
This commit is contained in:
ilkin-overleaf 2022-09-23 11:37:02 +03:00 committed by Copybot
parent 67c5b2a2a1
commit e12c93c537
67 changed files with 2089 additions and 639 deletions

View file

@ -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"

View file

@ -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": "",

View file

@ -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}
>

View file

@ -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

View file

@ -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>
)
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -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>
)
}

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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

View file

@ -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"

View file

@ -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">

View file

@ -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

View file

@ -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) {
const { t } = useTranslation()
const [input, setInput] = useState('')
const placeholder = `${t('search_projects')}`
type SearchFormProps = SearchFormOwnProps &
Omit<React.ComponentProps<typeof Form>, keyof SearchFormOwnProps>
useEffect(() => {
onChange(input)
}, [input, onChange])
function SearchForm({
inputValue,
setInputValue,
formGroupProps,
...props
}: SearchFormProps) {
const { t } = useTranslation()
const placeholder = `${t('search_projects')}`
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"

View file

@ -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>
)
}

View file

@ -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" />
</>
)
}

View file

@ -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

View file

@ -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 }

View file

@ -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 }

View file

@ -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 }

View file

@ -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 }

View file

@ -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 }

View file

@ -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 }

View file

@ -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 }

View file

@ -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 (
<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>
)
return children(text, handleUntrashProject)
}
const UntrashProjectButtonTooltip = memo(function UntrashProjectButtonTooltip({
project,
}: Pick<UntrashProjectButtonProps, 'project'>) {
return (
<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 }

View file

@ -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} />
</>
)
}

View file

@ -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>

View file

@ -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}
/>
) : (
''
)}

View file

@ -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-owner">
<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 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>
)

View file

@ -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
<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="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
}
>
<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>
)
}

View file

@ -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}
/>
</>
)

View file

@ -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}
/>
</>
)

View file

@ -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

View file

@ -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>

View file

@ -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,

View file

@ -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

View file

@ -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

View file

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

View file

@ -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 {

View file

@ -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%);
}
}

View file

@ -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;
}

View file

@ -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 {

View file

@ -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;
}

View file

@ -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;

View file

@ -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;
}

View file

@ -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 dont 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",

View file

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

View file

@ -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')

View file

@ -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

View file

@ -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 () {

View file

@ -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')

View file

@ -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)

View file

@ -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')

View file

@ -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 () {

View file

@ -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')

View file

@ -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')

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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,

View file

@ -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,