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-19 08:04:24 +00:00
committed by Copybot
parent 6ff77971ad
commit 475201d42f
22 changed files with 680 additions and 117 deletions
@@ -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, {