mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #9603 from overleaf/jel-project-tools
[web] Begin project tools for React dash GitOrigin-RevId: a735864153f836ca01135001c661aa31ec52cfa8
This commit is contained in:
parent
6ff77971ad
commit
475201d42f
22 changed files with 680 additions and 117 deletions
|
@ -14,6 +14,7 @@ import WelcomeMessage from './welcome-message'
|
|||
import LoadingBranded from '../../../shared/components/loading-branded'
|
||||
import UserNotifications from './notifications/user-notifications'
|
||||
import SearchForm from './search-form'
|
||||
import ProjectTools from './table/project-tools/project-tools'
|
||||
|
||||
function ProjectListRoot() {
|
||||
const { isReady } = useWaitForI18n()
|
||||
|
@ -26,8 +27,14 @@ function ProjectListRoot() {
|
|||
}
|
||||
|
||||
function ProjectListPageContent() {
|
||||
const { totalProjectsCount, error, isLoading, loadProgress, setSearchText } =
|
||||
useProjectListContext()
|
||||
const {
|
||||
totalProjectsCount,
|
||||
error,
|
||||
isLoading,
|
||||
loadProgress,
|
||||
setSearchText,
|
||||
selectedProjects,
|
||||
} = useProjectListContext()
|
||||
|
||||
return isLoading ? (
|
||||
<div className="loading-container">
|
||||
|
@ -58,9 +65,11 @@ function ProjectListPageContent() {
|
|||
</Col>
|
||||
<Col md={5} xs={12}>
|
||||
<div className="project-tools">
|
||||
<div className="text-right pull-right">
|
||||
{selectedProjects.length === 0 ? (
|
||||
<CurrentPlanWidget />
|
||||
</div>
|
||||
) : (
|
||||
<ProjectTools />
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
|
|
@ -57,22 +57,8 @@ function ArchiveProjectButton({ project }: ArchiveProjectButtonProps) {
|
|||
</Tooltip>
|
||||
|
||||
<ProjectsActionModal
|
||||
title={t('archive_projects')}
|
||||
action="archive"
|
||||
actionHandler={handleArchiveProject}
|
||||
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={[project]}
|
||||
|
|
|
@ -60,16 +60,8 @@ function DeleteProjectButton({ project }: DeleteProjectButtonProps) {
|
|||
</Tooltip>
|
||||
|
||||
<ProjectsActionModal
|
||||
title={t('delete_projects')}
|
||||
action="delete"
|
||||
actionHandler={handleDeleteProject}
|
||||
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={[project]}
|
||||
|
|
|
@ -59,16 +59,8 @@ function LeaveProjectButton({ project }: LeaveProjectButtonProps) {
|
|||
</Tooltip>
|
||||
|
||||
<ProjectsActionModal
|
||||
title={t('leave_projects')}
|
||||
action="trash"
|
||||
action="leave"
|
||||
actionHandler={handleLeaveProject}
|
||||
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={[project]}
|
||||
|
|
|
@ -58,22 +58,8 @@ function TrashProjectButton({ project }: TrashProjectButtonProps) {
|
|||
</Tooltip>
|
||||
|
||||
<ProjectsActionModal
|
||||
title={t('trash_projects')}
|
||||
action="trash"
|
||||
actionHandler={handleTrashProject}
|
||||
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={[project]}
|
||||
|
|
|
@ -4,6 +4,8 @@ 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 { useProjectListContext } from '../../context/project-list-context'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
type ProjectListTableRowProps = {
|
||||
project: Project
|
||||
|
@ -12,11 +14,35 @@ export default function ProjectListTableRow({
|
|||
project,
|
||||
}: ProjectListTableRowProps) {
|
||||
const { t } = useTranslation()
|
||||
const { selectedProjects, setSelectedProjects } = useProjectListContext()
|
||||
|
||||
const handleCheckboxChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const checked = event.target.checked
|
||||
setSelectedProjects(selectedProjects => {
|
||||
let projects = [...selectedProjects]
|
||||
if (checked) {
|
||||
projects.push(project)
|
||||
} else {
|
||||
const projectId = event.target.getAttribute('data-project-id')
|
||||
projects = projects.filter(p => p.id !== projectId)
|
||||
}
|
||||
return projects
|
||||
})
|
||||
},
|
||||
[project, setSelectedProjects]
|
||||
)
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td className="dash-cell-checkbox">
|
||||
<input type="checkbox" id={`select-project-${project.id}`} />
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`select-project-${project.id}`}
|
||||
checked={selectedProjects.includes(project)}
|
||||
onChange={handleCheckboxChange}
|
||||
data-project-id={project.id}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`select-project-${project.id}`}
|
||||
aria-label={t('select_project', { project: project.name })}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Icon from '../../../../shared/components/icon'
|
||||
import ProjectListTableRow from './project-list-table-row'
|
||||
|
@ -39,7 +40,13 @@ const toggleSort = (order: SortingOrder): SortingOrder => {
|
|||
|
||||
function ProjectListTable() {
|
||||
const { t } = useTranslation()
|
||||
const { visibleProjects, sort, setSort } = useProjectListContext()
|
||||
const {
|
||||
visibleProjects,
|
||||
sort,
|
||||
setSort,
|
||||
selectedProjects,
|
||||
setSelectedProjects,
|
||||
} = useProjectListContext()
|
||||
|
||||
const handleSortClick = (by: Sort['by']) => {
|
||||
setSort(prev => ({
|
||||
|
@ -48,6 +55,17 @@ function ProjectListTable() {
|
|||
}))
|
||||
}
|
||||
|
||||
const handleAllProjectsCheckboxChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.checked) {
|
||||
setSelectedProjects(visibleProjects)
|
||||
} else {
|
||||
setSelectedProjects([])
|
||||
}
|
||||
},
|
||||
[setSelectedProjects, visibleProjects]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="card project-list-card">
|
||||
<table className="project-dash-table">
|
||||
|
@ -57,7 +75,16 @@ function ProjectListTable() {
|
|||
className="dash-cell-checkbox"
|
||||
aria-label={t('select_projects')}
|
||||
>
|
||||
<input type="checkbox" id="project-list-table-select-all" />
|
||||
<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')}
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
import { memo, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Icon from '../../../../../../shared/components/icon'
|
||||
import Tooltip from '../../../../../../shared/components/tooltip'
|
||||
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 } =
|
||||
useProjectListContext()
|
||||
const { t } = useTranslation()
|
||||
const text = t('archive')
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const handleOpenModal = useCallback(() => {
|
||||
setShowModal(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
if (isMounted.current) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}, [isMounted])
|
||||
|
||||
const handleArchiveProjects = useCallback(async () => {
|
||||
for (const project of selectedProjects) {
|
||||
await archiveProject(project.id)
|
||||
// update view
|
||||
project.archived = true
|
||||
updateProjectViewData(project)
|
||||
}
|
||||
setSelectedProjects([])
|
||||
}, [selectedProjects, setSelectedProjects, updateProjectViewData])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
id="tooltip-download-projects"
|
||||
description={text}
|
||||
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<button
|
||||
className="btn btn-default"
|
||||
aria-label={text}
|
||||
onClick={handleOpenModal}
|
||||
>
|
||||
<Icon type="inbox" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<ProjectsActionModal
|
||||
action="archive"
|
||||
actionHandler={handleArchiveProjects}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
projects={selectedProjects}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ArchiveProjectsButton)
|
|
@ -0,0 +1,46 @@
|
|||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Icon from '../../../../../../shared/components/icon'
|
||||
import Tooltip from '../../../../../../shared/components/tooltip'
|
||||
import * as eventTracking from '../../../../../../infrastructure/event-tracking'
|
||||
import { useProjectListContext } from '../../../../context/project-list-context'
|
||||
|
||||
function DownloadProjectsButton() {
|
||||
const { selectedProjects, setSelectedProjects } = useProjectListContext()
|
||||
const { t } = useTranslation()
|
||||
const text = t('download')
|
||||
|
||||
const projectIds = selectedProjects.map(p => p.id)
|
||||
|
||||
const handleDownloadProjects = useCallback(() => {
|
||||
eventTracking.send(
|
||||
'project-list-page-interaction',
|
||||
'project action',
|
||||
'Download Zip'
|
||||
)
|
||||
|
||||
window.location.assign(
|
||||
`/project/download/zip?project_ids=${projectIds.join(',')}`
|
||||
)
|
||||
|
||||
setSelectedProjects([])
|
||||
}, [projectIds, setSelectedProjects])
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
id="tooltip-download-projects"
|
||||
description={text}
|
||||
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<button
|
||||
className="btn btn-default"
|
||||
aria-label={text}
|
||||
onClick={handleDownloadProjects}
|
||||
>
|
||||
<Icon type="cloud-download" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(DownloadProjectsButton)
|
|
@ -0,0 +1,66 @@
|
|||
import { memo, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Icon from '../../../../../../shared/components/icon'
|
||||
import Tooltip from '../../../../../../shared/components/tooltip'
|
||||
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 } =
|
||||
useProjectListContext()
|
||||
const { t } = useTranslation()
|
||||
const text = t('trash')
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const handleOpenModal = useCallback(() => {
|
||||
setShowModal(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
if (isMounted.current) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}, [isMounted])
|
||||
|
||||
const handleTrashProjects = useCallback(async () => {
|
||||
for (const project of selectedProjects) {
|
||||
await trashProject(project.id)
|
||||
// update view
|
||||
project.trashed = true
|
||||
project.archived = false
|
||||
updateProjectViewData(project)
|
||||
}
|
||||
setSelectedProjects([])
|
||||
}, [selectedProjects, setSelectedProjects, updateProjectViewData])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
id="tooltip-download-projects"
|
||||
description={text}
|
||||
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<button
|
||||
className="btn btn-default"
|
||||
aria-label={text}
|
||||
onClick={handleOpenModal}
|
||||
>
|
||||
<Icon type="trash" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<ProjectsActionModal
|
||||
action="trash"
|
||||
actionHandler={handleTrashProjects}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
projects={selectedProjects}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(TrashProjectsButton)
|
|
@ -0,0 +1,20 @@
|
|||
import { memo } from 'react'
|
||||
import { useProjectListContext } from '../../../context/project-list-context'
|
||||
import ArchiveProjectsButton from './buttons/archive-projects-button'
|
||||
import DownloadProjectsButton from './buttons/download-projects-button'
|
||||
import TrashProjectsButton from './buttons/trash-projects-button'
|
||||
|
||||
function ProjectTools() {
|
||||
const { filter } = useProjectListContext()
|
||||
return (
|
||||
<div className="btn-toolbar" role="toolbar">
|
||||
<div className="btn-group">
|
||||
<DownloadProjectsButton />
|
||||
{filter !== 'archived' && <ArchiveProjectsButton />}
|
||||
{filter !== 'trashed' && <TrashProjectsButton />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ProjectTools)
|
|
@ -6,28 +6,24 @@ 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'
|
||||
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)
|
||||
|
@ -67,6 +63,56 @@ 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
|
||||
|
|
|
@ -72,6 +72,8 @@ type ProjectListContextValue = {
|
|||
updateProjectViewData: (project: Project) => void
|
||||
removeProjectFromView: (project: Project) => void
|
||||
setSearchText: React.Dispatch<React.SetStateAction<string>>
|
||||
selectedProjects: Project[]
|
||||
setSelectedProjects: React.Dispatch<React.SetStateAction<Project[]>>
|
||||
}
|
||||
|
||||
export const ProjectListContext = createContext<
|
||||
|
@ -101,6 +103,8 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
|
|||
>('project-list-selected-tag-id', undefined)
|
||||
const [tags, setTags] = useState<Tag[]>(getMeta('ol-tags', []) as Tag[])
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [selectedProjects, setSelectedProjects] = useState<Project[]>([])
|
||||
|
||||
const {
|
||||
isLoading: loading,
|
||||
isIdle,
|
||||
|
@ -193,6 +197,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
|
|||
(filter: Filter) => {
|
||||
setFilter(filter)
|
||||
setSelectedTagId(undefined)
|
||||
setSelectedProjects([])
|
||||
},
|
||||
[setFilter, setSelectedTagId]
|
||||
)
|
||||
|
@ -285,8 +290,10 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
|
|||
renameTag,
|
||||
selectedTagId,
|
||||
selectFilter,
|
||||
selectedProjects,
|
||||
selectTag,
|
||||
setSearchText,
|
||||
setSelectedProjects,
|
||||
setSort,
|
||||
sort,
|
||||
tags,
|
||||
|
@ -307,8 +314,10 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
|
|||
renameTag,
|
||||
selectedTagId,
|
||||
selectFilter,
|
||||
selectedProjects,
|
||||
selectTag,
|
||||
setSearchText,
|
||||
setSelectedProjects,
|
||||
setSort,
|
||||
sort,
|
||||
tags,
|
||||
|
|
|
@ -0,0 +1,188 @@
|
|||
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import sinon from 'sinon'
|
||||
import ProjectListRoot from '../../../../../frontend/js/features/project-list/components/project-list-root'
|
||||
import { renderWithProjectListContext } from '../helpers/render-with-context'
|
||||
|
||||
const userId = '624333f147cfd8002622a1d3'
|
||||
|
||||
describe('<ProjectListRoot />', function () {
|
||||
const originalLocation = window.location
|
||||
const locationStub = sinon.stub()
|
||||
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache = new Map()
|
||||
window.metaAttributesCache.set('ol-tags', [])
|
||||
window.metaAttributesCache.set('ol-ExposedSettings', { templateLinks: [] })
|
||||
window.user_id = userId
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { assign: locationStub },
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
window.user_id = undefined
|
||||
fetchMock.reset()
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: originalLocation,
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkboxes', function () {
|
||||
let allCheckboxes: Array<HTMLInputElement> = []
|
||||
let toolbar: HTMLElement
|
||||
let project1Id: string | null, project2Id: string | null
|
||||
|
||||
beforeEach(async function () {
|
||||
renderWithProjectListContext(<ProjectListRoot />)
|
||||
await fetchMock.flush(true)
|
||||
await waitFor(() => {
|
||||
screen.findByRole('table')
|
||||
})
|
||||
})
|
||||
|
||||
describe('all projects', function () {
|
||||
beforeEach(function () {
|
||||
allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
|
||||
// first one is the select all checkbox
|
||||
fireEvent.click(allCheckboxes[1])
|
||||
fireEvent.click(allCheckboxes[2])
|
||||
|
||||
project1Id = allCheckboxes[1].getAttribute('data-project-id')
|
||||
project2Id = allCheckboxes[2].getAttribute('data-project-id')
|
||||
toolbar = screen.getByRole('toolbar')
|
||||
})
|
||||
|
||||
it('downloads all selected projects and then unselects them', async function () {
|
||||
const downloadButton = within(toolbar).getByLabelText('Download')
|
||||
fireEvent.click(downloadButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(locationStub).to.have.been.called
|
||||
})
|
||||
|
||||
sinon.assert.calledWithMatch(
|
||||
locationStub,
|
||||
`/project/download/zip?project_ids=${project1Id},${project2Id}`
|
||||
)
|
||||
|
||||
const allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
|
||||
const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
|
||||
expect(allCheckboxesChecked.length).to.equal(0)
|
||||
})
|
||||
|
||||
it('opens archive modal for all selected projects and archives all', async function () {
|
||||
fetchMock.post(
|
||||
`express:/project/${project1Id}/archive`,
|
||||
{
|
||||
status: 200,
|
||||
},
|
||||
{ delay: 0 }
|
||||
)
|
||||
fetchMock.post(
|
||||
`express:/project/${project2Id}/archive`,
|
||||
{
|
||||
status: 200,
|
||||
},
|
||||
{ delay: 0 }
|
||||
)
|
||||
|
||||
const archiveButton = within(toolbar).getByLabelText('Archive')
|
||||
fireEvent.click(archiveButton)
|
||||
|
||||
const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
|
||||
fireEvent.click(confirmBtn)
|
||||
expect(confirmBtn.disabled).to.be.true
|
||||
|
||||
await fetchMock.flush(true)
|
||||
expect(fetchMock.done()).to.be.true
|
||||
|
||||
const requests = fetchMock.calls()
|
||||
const [projectRequest1Url, projectRequest1Headers] = requests[2]
|
||||
expect(projectRequest1Url).to.equal(`/project/${project1Id}/archive`)
|
||||
expect(projectRequest1Headers?.method).to.equal('POST')
|
||||
const [projectRequest2Url, projectRequest2Headers] = requests[3]
|
||||
expect(projectRequest2Url).to.equal(`/project/${project2Id}/archive`)
|
||||
expect(projectRequest2Headers?.method).to.equal('POST')
|
||||
})
|
||||
|
||||
it('opens trash modal for all selected projects and trashes all', async function () {
|
||||
fetchMock.post(
|
||||
`express:/project/${project1Id}/trash`,
|
||||
{
|
||||
status: 200,
|
||||
},
|
||||
{ delay: 0 }
|
||||
)
|
||||
fetchMock.post(
|
||||
`express:/project/${project2Id}/trash`,
|
||||
{
|
||||
status: 200,
|
||||
},
|
||||
{ delay: 0 }
|
||||
)
|
||||
|
||||
const archiveButton = within(toolbar).getByLabelText('Trash')
|
||||
fireEvent.click(archiveButton)
|
||||
|
||||
const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
|
||||
fireEvent.click(confirmBtn)
|
||||
expect(confirmBtn.disabled).to.be.true
|
||||
|
||||
await fetchMock.flush(true)
|
||||
expect(fetchMock.done()).to.be.true
|
||||
|
||||
const requests = fetchMock.calls()
|
||||
const [projectRequest1Url, projectRequest1Headers] = requests[2]
|
||||
expect(projectRequest1Url).to.equal(`/project/${project1Id}/trash`)
|
||||
expect(projectRequest1Headers?.method).to.equal('POST')
|
||||
const [projectRequest2Url, projectRequest2Headers] = requests[3]
|
||||
expect(projectRequest2Url).to.equal(`/project/${project2Id}/trash`)
|
||||
expect(projectRequest2Headers?.method).to.equal('POST')
|
||||
})
|
||||
})
|
||||
|
||||
describe('archived projects', function () {
|
||||
beforeEach(function () {
|
||||
const filterButton = screen.getByText('Archived Projects')
|
||||
fireEvent.click(filterButton)
|
||||
|
||||
allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
|
||||
expect(allCheckboxes.length === 2).to.be.true
|
||||
// first one is the select all checkbox
|
||||
fireEvent.click(allCheckboxes[1])
|
||||
project1Id = allCheckboxes[1].getAttribute('data-project-id')
|
||||
})
|
||||
it('does not show the archive button in toolbar when archive view selected', function () {
|
||||
expect(screen.queryByLabelText('Archive')).to.be.null
|
||||
})
|
||||
})
|
||||
|
||||
describe('trashed projects', function () {
|
||||
beforeEach(function () {
|
||||
const filterButton = screen.getByText('Trashed Projects')
|
||||
fireEvent.click(filterButton)
|
||||
|
||||
allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
|
||||
// first one is the select all checkbox
|
||||
fireEvent.click(allCheckboxes[1])
|
||||
|
||||
project1Id = allCheckboxes[1].getAttribute('data-project-id')
|
||||
})
|
||||
it('does not show the trash button in toolbar when archive view selected', function () {
|
||||
expect(screen.queryByLabelText('Trash')).to.be.null
|
||||
})
|
||||
|
||||
it('clears selected projects when filter changed', function () {
|
||||
const filterButton = screen.getByText('All Projects')
|
||||
fireEvent.click(filterButton)
|
||||
|
||||
const allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
|
||||
const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
|
||||
expect(allCheckboxesChecked.length).to.equal(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -1,20 +1,12 @@
|
|||
import { render, screen, within, fireEvent } from '@testing-library/react'
|
||||
import { screen, within, fireEvent } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import ProjectListTable from '../../../../../../frontend/js/features/project-list/components/table/project-list-table'
|
||||
import { ProjectListProvider } from '../../../../../../frontend/js/features/project-list/context/project-list-context'
|
||||
import { projectsData } from '../../fixtures/projects-data'
|
||||
import { currentProjects } from '../../fixtures/projects-data'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import { renderWithProjectListContext } from '../../helpers/render-with-context'
|
||||
|
||||
const userId = '624333f147cfd8002622a1d3'
|
||||
|
||||
const renderProjectListTableWithinProjectListProvider = () => {
|
||||
render(<ProjectListTable />, {
|
||||
wrapper: ({ children }) => (
|
||||
<ProjectListProvider>{children}</ProjectListProvider>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
describe('<ProjectListTable />', function () {
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache = new Map()
|
||||
|
@ -29,12 +21,12 @@ describe('<ProjectListTable />', function () {
|
|||
})
|
||||
|
||||
it('renders the table', function () {
|
||||
renderProjectListTableWithinProjectListProvider()
|
||||
renderWithProjectListContext(<ProjectListTable />)
|
||||
screen.getByRole('table')
|
||||
})
|
||||
|
||||
it('sets aria-sort on column header currently sorted', function () {
|
||||
renderProjectListTableWithinProjectListProvider()
|
||||
renderWithProjectListContext(<ProjectListTable />)
|
||||
let foundSortedColumn = false
|
||||
const columns = screen.getAllByRole('columnheader')
|
||||
columns.forEach(col => {
|
||||
|
@ -49,7 +41,7 @@ describe('<ProjectListTable />', function () {
|
|||
})
|
||||
|
||||
it('keeps the order type when selecting different column for sorting', function () {
|
||||
renderProjectListTableWithinProjectListProvider()
|
||||
renderWithProjectListContext(<ProjectListTable />)
|
||||
const lastModifiedBtn = screen.getByRole('button', {
|
||||
name: /last modified/i,
|
||||
})
|
||||
|
@ -65,64 +57,51 @@ describe('<ProjectListTable />', function () {
|
|||
})
|
||||
|
||||
it('renders buttons for sorting all sortable columns', function () {
|
||||
renderProjectListTableWithinProjectListProvider()
|
||||
renderWithProjectListContext(<ProjectListTable />)
|
||||
screen.getByText('Sort by Title')
|
||||
screen.getByText('Sort by Owner')
|
||||
screen.getByText('Reverse Last Modified sort order') // currently sorted
|
||||
})
|
||||
|
||||
it('renders project title, owner, last modified, and action buttons', async function () {
|
||||
// archived and trashed projects are currently not shown
|
||||
const filteredProjects = projectsData.filter(
|
||||
({ archived, trashed }) => !archived && !trashed
|
||||
)
|
||||
|
||||
fetchMock.post('/api/project', {
|
||||
status: 200,
|
||||
body: {
|
||||
projects: filteredProjects,
|
||||
totalSize: filteredProjects.length,
|
||||
},
|
||||
})
|
||||
|
||||
renderProjectListTableWithinProjectListProvider()
|
||||
renderWithProjectListContext(<ProjectListTable />)
|
||||
await fetchMock.flush(true)
|
||||
|
||||
const rows = screen.getAllByRole('row')
|
||||
rows.shift() // remove first row since it's the header
|
||||
expect(rows.length).to.equal(filteredProjects.length)
|
||||
expect(rows.length).to.equal(currentProjects.length)
|
||||
|
||||
// Project name cell
|
||||
filteredProjects.forEach(project => {
|
||||
currentProjects.forEach(project => {
|
||||
screen.getByText(project.name)
|
||||
})
|
||||
|
||||
// Owner Column and Last Modified Column
|
||||
const row1 = screen
|
||||
.getByRole('cell', { name: filteredProjects[0].name })
|
||||
.getByRole('cell', { name: currentProjects[0].name })
|
||||
.closest('tr')!
|
||||
within(row1).getByText('You')
|
||||
within(row1).getByText('a day ago by Jean-Luc Picard')
|
||||
const row2 = screen
|
||||
.getByRole('cell', { name: filteredProjects[1].name })
|
||||
.getByRole('cell', { name: currentProjects[1].name })
|
||||
.closest('tr')!
|
||||
within(row2).getByText('Jean-Luc Picard')
|
||||
within(row2).getByText('7 days ago by Jean-Luc Picard')
|
||||
const row3 = screen
|
||||
.getByRole('cell', { name: filteredProjects[2].name })
|
||||
.getByRole('cell', { name: currentProjects[2].name })
|
||||
.closest('tr')!
|
||||
within(row3).getByText('worf@overleaf.com')
|
||||
within(row3).getByText('a month ago by worf@overleaf.com')
|
||||
// link sharing project
|
||||
const row4 = screen
|
||||
.getByRole('cell', { name: filteredProjects[3].name })
|
||||
.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')
|
||||
// link sharing read only, so it will not show an owner
|
||||
const row5 = screen
|
||||
.getByRole('cell', { name: filteredProjects[4].name })
|
||||
.getByRole('cell', { name: currentProjects[4].name })
|
||||
.closest('tr')!
|
||||
within(row5).getByText('Link sharing')
|
||||
within(row5).getByText('2 years ago')
|
||||
|
@ -131,13 +110,13 @@ describe('<ProjectListTable />', function () {
|
|||
// temporary count tests until we add filtering for archived/trashed
|
||||
const copyButtons = screen.getAllByLabelText('Copy')
|
||||
screen.debug()
|
||||
expect(copyButtons.length).to.equal(filteredProjects.length)
|
||||
expect(copyButtons.length).to.equal(currentProjects.length)
|
||||
const downloadButtons = screen.getAllByLabelText('Download')
|
||||
expect(downloadButtons.length).to.equal(filteredProjects.length)
|
||||
expect(downloadButtons.length).to.equal(currentProjects.length)
|
||||
const archiveButtons = screen.getAllByLabelText('Archive')
|
||||
expect(archiveButtons.length).to.equal(filteredProjects.length)
|
||||
expect(archiveButtons.length).to.equal(currentProjects.length)
|
||||
const trashButtons = screen.getAllByLabelText('Trash')
|
||||
expect(trashButtons.length).to.equal(filteredProjects.length)
|
||||
expect(trashButtons.length).to.equal(currentProjects.length)
|
||||
|
||||
// TODO to be implemented when the component renders trashed & archived projects
|
||||
// const restoreButtons = screen.getAllByLabelText('Restore')
|
||||
|
@ -145,4 +124,49 @@ describe('<ProjectListTable />', function () {
|
|||
// const deleteButtons = screen.getAllByLabelText('Delete')
|
||||
// expect(deleteButtons.length).to.equal(1)
|
||||
})
|
||||
|
||||
it('selects all projects when header checkbox checked', async function () {
|
||||
renderWithProjectListContext(<ProjectListTable />)
|
||||
await fetchMock.flush(true)
|
||||
const checkbox = screen.getByLabelText('Select all projects')
|
||||
fireEvent.click(checkbox)
|
||||
const allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
|
||||
const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
|
||||
// + 1 because of select all checkbox
|
||||
expect(allCheckboxesChecked.length).to.equal(currentProjects.length + 1)
|
||||
})
|
||||
|
||||
it('unselects all projects when select all checkbox uchecked', async function () {
|
||||
renderWithProjectListContext(<ProjectListTable />)
|
||||
await fetchMock.flush(true)
|
||||
const checkbox = screen.getByLabelText('Select all projects')
|
||||
fireEvent.click(checkbox)
|
||||
fireEvent.click(checkbox)
|
||||
const allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
|
||||
const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
|
||||
expect(allCheckboxesChecked.length).to.equal(0)
|
||||
})
|
||||
|
||||
it('unselects select all projects checkbox when one project is unchecked', async function () {
|
||||
renderWithProjectListContext(<ProjectListTable />)
|
||||
await fetchMock.flush(true)
|
||||
const checkbox = screen.getByLabelText('Select all projects')
|
||||
fireEvent.click(checkbox)
|
||||
let allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
|
||||
expect(allCheckboxes[1].getAttribute('data-project-id')).to.exist // make sure we are unchecking a project checkbox
|
||||
fireEvent.click(allCheckboxes[1])
|
||||
allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
|
||||
const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
|
||||
expect(allCheckboxesChecked.length).to.equal(currentProjects.length - 1)
|
||||
})
|
||||
|
||||
it('only checks the checked project', async function () {
|
||||
renderWithProjectListContext(<ProjectListTable />)
|
||||
await fetchMock.flush(true)
|
||||
const checkbox = screen.getByLabelText(`Select ${currentProjects[0].name}`)
|
||||
fireEvent.click(checkbox)
|
||||
const allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
|
||||
const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
|
||||
expect(allCheckboxesChecked.length).to.equal(1)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import ArchiveProjectsButton from '../../../../../../../../frontend/js/features/project-list/components/table/project-tools/buttons/archive-projects-button'
|
||||
import {
|
||||
resetProjectListContextFetch,
|
||||
renderWithProjectListContext,
|
||||
} from '../../../../helpers/render-with-context'
|
||||
|
||||
describe('<ArchiveProjectsButton />', function () {
|
||||
afterEach(function () {
|
||||
resetProjectListContextFetch()
|
||||
})
|
||||
|
||||
it('renders tooltip for button', function () {
|
||||
renderWithProjectListContext(<ArchiveProjectsButton />)
|
||||
const btn = screen.getByLabelText('Archive')
|
||||
fireEvent.mouseOver(btn)
|
||||
screen.getByRole('tooltip', { name: 'Archive' })
|
||||
})
|
||||
|
||||
it('opens the modal when clicked', function () {
|
||||
renderWithProjectListContext(<ArchiveProjectsButton />)
|
||||
const btn = screen.getByLabelText('Archive')
|
||||
fireEvent.click(btn)
|
||||
screen.getByText('Archive Projects')
|
||||
})
|
||||
})
|
|
@ -0,0 +1,19 @@
|
|||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import DownloadProjectsButton from '../../../../../../../../frontend/js/features/project-list/components/table/project-tools/buttons/download-projects-button'
|
||||
import {
|
||||
resetProjectListContextFetch,
|
||||
renderWithProjectListContext,
|
||||
} from '../../../../helpers/render-with-context'
|
||||
|
||||
describe('<DownloadProjectsButton />', function () {
|
||||
afterEach(function () {
|
||||
resetProjectListContextFetch()
|
||||
})
|
||||
|
||||
it('renders tooltip for button', function () {
|
||||
renderWithProjectListContext(<DownloadProjectsButton />)
|
||||
const btn = screen.getByLabelText('Download')
|
||||
fireEvent.mouseOver(btn)
|
||||
screen.getByRole('tooltip', { name: 'Download' })
|
||||
})
|
||||
})
|
|
@ -0,0 +1,26 @@
|
|||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import TrashProjectsButton from '../../../../../../../../frontend/js/features/project-list/components/table/project-tools/buttons/trash-projects-button'
|
||||
import {
|
||||
resetProjectListContextFetch,
|
||||
renderWithProjectListContext,
|
||||
} from '../../../../helpers/render-with-context'
|
||||
|
||||
describe('<TrashProjectsButton />', function () {
|
||||
afterEach(function () {
|
||||
resetProjectListContextFetch()
|
||||
})
|
||||
|
||||
it('renders tooltip for button', function () {
|
||||
renderWithProjectListContext(<TrashProjectsButton />)
|
||||
const btn = screen.getByLabelText('Trash')
|
||||
fireEvent.mouseOver(btn)
|
||||
screen.getByRole('tooltip', { name: 'Trash' })
|
||||
})
|
||||
|
||||
it('opens the modal when clicked', function () {
|
||||
renderWithProjectListContext(<TrashProjectsButton />)
|
||||
const btn = screen.getByLabelText('Trash')
|
||||
fireEvent.click(btn)
|
||||
screen.getByText('Trash Projects')
|
||||
})
|
||||
})
|
|
@ -0,0 +1,21 @@
|
|||
import { screen } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import ProjectTools from '../../../../../../../frontend/js/features/project-list/components/table/project-tools/project-tools'
|
||||
import {
|
||||
renderWithProjectListContext,
|
||||
resetProjectListContextFetch,
|
||||
} from '../../../helpers/render-with-context'
|
||||
|
||||
describe('<ProjectListTable />', function () {
|
||||
afterEach(function () {
|
||||
resetProjectListContextFetch()
|
||||
})
|
||||
|
||||
it('renders the project tools', function () {
|
||||
renderWithProjectListContext(<ProjectTools />)
|
||||
expect(screen.getAllByRole('button').length).to.equal(3)
|
||||
screen.getByLabelText('Download')
|
||||
screen.getByLabelText('Archive')
|
||||
screen.getByLabelText('Trash')
|
||||
})
|
||||
})
|
|
@ -13,12 +13,6 @@ describe('<ProjectsActionModal />', function () {
|
|||
const actionHandler = sinon.stub().resolves({})
|
||||
let sendSpy: sinon.SinonSpy
|
||||
|
||||
const modalText = {
|
||||
title: 'Action Title',
|
||||
top: <p>top text</p>,
|
||||
bottom: <b>bottom text</b>,
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
sendSpy = sinon.spy(eventTracking, 'send')
|
||||
})
|
||||
|
@ -31,11 +25,8 @@ describe('<ProjectsActionModal />', function () {
|
|||
it('should handle the action passed', async function () {
|
||||
renderWithProjectListContext(
|
||||
<ProjectsActionModal
|
||||
title={modalText.title}
|
||||
action="archive"
|
||||
actionHandler={actionHandler}
|
||||
bodyTop={modalText.top}
|
||||
bodyBottom={modalText.bottom}
|
||||
projects={[projectsData[0], projectsData[1]]}
|
||||
handleCloseModal={() => {}}
|
||||
showModal
|
||||
|
@ -58,11 +49,8 @@ describe('<ProjectsActionModal />', function () {
|
|||
|
||||
renderWithProjectListContext(
|
||||
<ProjectsActionModal
|
||||
title={modalText.title}
|
||||
action="archive"
|
||||
actionHandler={actionHandler}
|
||||
bodyTop={modalText.top}
|
||||
bodyBottom={modalText.bottom}
|
||||
projects={[
|
||||
projectsData[0],
|
||||
projectsData[1],
|
||||
|
@ -91,11 +79,8 @@ describe('<ProjectsActionModal />', function () {
|
|||
it('should send an analytics even when opened', function () {
|
||||
renderWithProjectListContext(
|
||||
<ProjectsActionModal
|
||||
title={modalText.title}
|
||||
action="archive"
|
||||
actionHandler={actionHandler}
|
||||
bodyTop={modalText.top}
|
||||
bodyBottom={modalText.bottom}
|
||||
projects={[projectsData[0], projectsData[1]]}
|
||||
handleCloseModal={() => {}}
|
||||
showModal
|
||||
|
|
|
@ -128,3 +128,7 @@ export const projectsData: Array<Project> = [
|
|||
archivedProject,
|
||||
trashedProject,
|
||||
]
|
||||
|
||||
export const currentProjects: Array<Project> = projectsData.filter(
|
||||
({ archived, trashed }) => !archived && !trashed
|
||||
)
|
||||
|
|
|
@ -6,13 +6,13 @@ 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, contextProps) {
|
||||
export function renderWithProjectListContext(component) {
|
||||
fetchMock.post('express:/api/project', {
|
||||
status: 200,
|
||||
body: { projects: projectsData },
|
||||
body: { projects: projectsData, totalSize: projectsData.length },
|
||||
})
|
||||
const ProjectListProviderWrapper = ({ children }) => (
|
||||
<ProjectListProvider {...contextProps}>{children}</ProjectListProvider>
|
||||
<ProjectListProvider>{children}</ProjectListProvider>
|
||||
)
|
||||
|
||||
return render(component, {
|
||||
|
|
Loading…
Reference in a new issue