mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #9700 from overleaf/ii-dashboard-mobile-view
[web] Projects dashboard mobile view GitOrigin-RevId: 84894e19c814a2cc1ce751181952c0ade6b62044
This commit is contained in:
parent
67c5b2a2a1
commit
e12c93c537
67 changed files with 2089 additions and 639 deletions
|
@ -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"
|
||||
|
|
|
@ -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": "",
|
||||
|
|
|
@ -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}
|
||||
>
|
||||
|
|
|
@ -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
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
|
@ -0,0 +1,37 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import ProjectsActionModal from './projects-action-modal'
|
||||
import Icon from '../../../../shared/components/icon'
|
||||
|
||||
type DeleteProjectModalProps = Pick<
|
||||
React.ComponentProps<typeof ProjectsActionModal>,
|
||||
'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal'
|
||||
>
|
||||
|
||||
function DeleteProjectModal({
|
||||
projects,
|
||||
actionHandler,
|
||||
showModal,
|
||||
handleCloseModal,
|
||||
}: DeleteProjectModalProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<ProjectsActionModal
|
||||
action="delete"
|
||||
actionHandler={actionHandler}
|
||||
title={t('delete_projects')}
|
||||
bodyTop={<p>{t('about_to_delete_projects')}</p>}
|
||||
bodyBottom={
|
||||
<div className="project-action-alert alert alert-warning">
|
||||
<Icon type="exclamation-triangle" fw />{' '}
|
||||
{t('this_action_cannot_be_undone')}
|
||||
</div>
|
||||
}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
projects={projects}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeleteProjectModal
|
|
@ -7,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')} …</> : t('delete')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</AccessibleModal>
|
|
@ -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')} …</> : 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')} …</> : 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>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import ProjectsActionModal from './projects-action-modal'
|
||||
import Icon from '../../../../shared/components/icon'
|
||||
|
||||
type LeaveProjectModalProps = Pick<
|
||||
React.ComponentProps<typeof ProjectsActionModal>,
|
||||
'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal'
|
||||
>
|
||||
|
||||
function LeaveProjectModal({
|
||||
projects,
|
||||
actionHandler,
|
||||
showModal,
|
||||
handleCloseModal,
|
||||
}: LeaveProjectModalProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<ProjectsActionModal
|
||||
action="leave"
|
||||
actionHandler={actionHandler}
|
||||
title={t('leave_projects')}
|
||||
bodyTop={<p>{t('about_to_leave_projects')}</p>}
|
||||
bodyBottom={
|
||||
<div className="project-action-alert alert alert-warning">
|
||||
<Icon type="exclamation-triangle" fw />{' '}
|
||||
{t('this_action_cannot_be_undone')}
|
||||
</div>
|
||||
}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
projects={projects}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default LeaveProjectModal
|
|
@ -6,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
|
|
@ -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')} …</> : t('rename')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</AccessibleModal>
|
|
@ -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
|
|
@ -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"
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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 }
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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 }
|
|
@ -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 }
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -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')}
|
||||
<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
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
import { useProjectListContext } from '../context/project-list-context'
|
||||
import { Sort } from '../../../../../types/project/dashboard/api'
|
||||
import { SortingOrder } from '../../../../../types/sorting-order'
|
||||
|
||||
const toggleSort = (order: SortingOrder): SortingOrder => {
|
||||
return order === 'asc' ? 'desc' : 'asc'
|
||||
}
|
||||
|
||||
function useSort() {
|
||||
const { sort, setSort } = useProjectListContext()
|
||||
|
||||
const handleSort = (by: Sort['by']) => {
|
||||
setSort(prev => ({
|
||||
by,
|
||||
order: prev.by === by ? toggleSort(sort.order) : sort.order,
|
||||
}))
|
||||
}
|
||||
|
||||
return { handleSort }
|
||||
}
|
||||
|
||||
export default useSort
|
161
services/web/frontend/js/features/project-list/hooks/use-tag.tsx
Normal file
161
services/web/frontend/js/features/project-list/hooks/use-tag.tsx
Normal 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
|
|
@ -33,4 +33,5 @@ ControlledDropdown.propTypes = {
|
|||
children: PropTypes.any,
|
||||
defaultOpen: PropTypes.bool,
|
||||
id: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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%);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -856,6 +856,7 @@
|
|||
"fast": "Fast",
|
||||
"rename_folder": "Rename Folder",
|
||||
"delete_folder": "Delete Folder",
|
||||
"edit_folder": "Edit Folder",
|
||||
"select_tag": "Select tag __tagName__",
|
||||
"remove_tag": "Remove tag __tagName__",
|
||||
"about_to_delete_folder": "You are about to delete the following folders (any projects in them will not be deleted):",
|
||||
|
@ -1872,6 +1873,8 @@
|
|||
"history_entry_origin_github": "via GitHub",
|
||||
"history_entry_origin_dropbox": "via Dropbox",
|
||||
"sort_by_x": "Sort by __x__",
|
||||
"sort_by": "Sort by",
|
||||
"owned_by": "Owned by",
|
||||
"last_updated_date_by_x": "__lastUpdatedDate__ by __person__",
|
||||
"select_projects": "Select Projects",
|
||||
"ascending": "Ascending",
|
||||
|
@ -1880,6 +1883,8 @@
|
|||
"create_first_project": "Create First Project",
|
||||
"you_dont_have_any_repositories": "You don’t have any repositories",
|
||||
"tag_name_cannot_exceed_characters": "Tag name cannot exceed __maxLength__ characters",
|
||||
"save_changes": "Save changes",
|
||||
"tag_name_cannot_exceed_characters": "Tag name cannot exceed __maxLength__ characters",
|
||||
"overleaf_labs": "Overleaf Labs",
|
||||
"labs_program_already_participating": "You are enrolled in Labs",
|
||||
"labs_program_not_participating": "You are not enrolled in Labs",
|
||||
|
|
|
@ -17,7 +17,7 @@ describe('<NewProjectButton />', function () {
|
|||
],
|
||||
})
|
||||
|
||||
render(<NewProjectButton />)
|
||||
render(<NewProjectButton id="test" />)
|
||||
|
||||
const newProjectButton = screen.getByRole('button', {
|
||||
name: 'New Project',
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -81,30 +81,30 @@ describe('<ProjectListTable />', function () {
|
|||
.getByRole('cell', { name: currentProjects[0].name })
|
||||
.closest('tr')!
|
||||
within(row1).getByText('You')
|
||||
within(row1).getByText('a day ago by Jean-Luc Picard')
|
||||
within(row1).getAllByText('a day ago by Jean-Luc Picard', { exact: false })
|
||||
const row2 = screen
|
||||
.getByRole('cell', { name: currentProjects[1].name })
|
||||
.closest('tr')!
|
||||
within(row2).getByText('Jean-Luc Picard')
|
||||
within(row2).getByText('7 days ago by Jean-Luc Picard')
|
||||
within(row2).getAllByText('7 days ago by Jean-Luc Picard')
|
||||
const row3 = screen
|
||||
.getByRole('cell', { name: currentProjects[2].name })
|
||||
.closest('tr')!
|
||||
within(row3).getByText('worf@overleaf.com')
|
||||
within(row3).getByText('a month ago by worf@overleaf.com')
|
||||
within(row3).getAllByText('a month ago by worf@overleaf.com')
|
||||
// link sharing project
|
||||
const row4 = screen
|
||||
.getByRole('cell', { name: currentProjects[3].name })
|
||||
.closest('tr')!
|
||||
within(row4).getByText('La Forge')
|
||||
within(row4).getByText('Link sharing')
|
||||
within(row4).getByText('2 months ago by La Forge')
|
||||
within(row4).getAllByText('2 months ago by La Forge')
|
||||
// link sharing read only, so it will not show an owner
|
||||
const row5 = screen
|
||||
.getByRole('cell', { name: currentProjects[4].name })
|
||||
.closest('tr')!
|
||||
within(row5).getByText('Link sharing')
|
||||
within(row5).getByText('2 years ago')
|
||||
within(row5).getAllByText('2 years ago')
|
||||
|
||||
// Action Column
|
||||
// temporary count tests until we add filtering for archived/trashed
|
||||
|
|
|
@ -1,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,
|
||||
|
|
|
@ -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,
|
Loading…
Reference in a new issue