Merge pull request #9603 from overleaf/jel-project-tools

[web] Begin project tools for React dash

GitOrigin-RevId: a735864153f836ca01135001c661aa31ec52cfa8
This commit is contained in:
Jessica Lawshe 2022-09-16 09:02:08 -05:00 committed by Copybot
parent 6ff77971ad
commit 475201d42f
22 changed files with 680 additions and 117 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -128,3 +128,7 @@ export const projectsData: Array<Project> = [
archivedProject,
trashedProject,
]
export const currentProjects: Array<Project> = projectsData.filter(
({ archived, trashed }) => !archived && !trashed
)

View file

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