import {
fireEvent,
screen,
waitFor,
waitForElementToBeRemoved,
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'
import * as eventTracking from '@/infrastructure/event-tracking'
import {
projectsData,
owner,
archivedProjects,
makeLongProjectList,
archiveableProject,
copyableProject,
} from '../fixtures/projects-data'
import { location } from '@/shared/components/location'
import getMeta from '@/utils/meta'
const {
fullList,
currentList,
archivedList,
trashedList,
leavableList,
deletableList,
} = makeLongProjectList(40)
const userId = owner.id
describe('', function () {
this.timeout('10s')
let sendMBSpy: sinon.SinonSpy
beforeEach(async function () {
global.localStorage.clear()
sendMBSpy = sinon.spy(eventTracking, 'sendMB')
this.tagId = '999fff999fff'
this.tagName = 'First tag name'
window.metaAttributesCache.set('ol-tags', [
{
_id: this.tagId,
name: this.tagName,
project_ids: [projectsData[0].id, projectsData[1].id],
},
])
Object.assign(getMeta('ol-ExposedSettings'), {
templateLinks: [],
})
window.metaAttributesCache.set('ol-userEmails', [
{ email: 'test@overleaf.com', default: true },
])
// we need a blank user here since its used in checking if we should display certain ads
window.metaAttributesCache.set('ol-user', {})
window.metaAttributesCache.set('ol-user_id', userId)
window.metaAttributesCache.set('ol-footer', {
showThinFooter: false,
translatedLanguages: { en: 'English' },
subdomainLang: { en: { lngCode: 'en', url: 'overleaf.com' } },
})
window.metaAttributesCache.set('ol-navbar', {
items: [],
})
this.locationWrapperSandbox = sinon.createSandbox()
this.locationWrapperStub = this.locationWrapperSandbox.stub(location)
})
afterEach(function () {
sendMBSpy.restore()
fetchMock.removeRoutes().clearHistory()
this.locationWrapperSandbox.restore()
})
describe('welcome page', function () {
beforeEach(async function () {
renderWithProjectListContext(, {
projects: [],
})
await fetchMock.callHistory.flush(true)
})
it('the welcome page is displayed', async function () {
await screen.findByRole('heading', { name: 'Welcome to Overleaf' })
})
it('the email confirmation alert is not displayed', async function () {
expect(
screen.queryByText(
'Please confirm your primary email address test@overleaf.com by clicking on the link in the confirmation email.'
)
).to.be.null
})
})
describe('project table', function () {
beforeEach(async function () {
const { unmount } = renderWithProjectListContext(, {
projects: fullList,
})
this.unmount = unmount
await fetchMock.callHistory.flush(true)
await screen.findByRole('table')
})
describe('checkboxes', function () {
let allCheckboxes: Array = []
let actionsToolbar: HTMLElement
let project1Id: string | null, project2Id: string | null
describe('all projects', function () {
beforeEach(function () {
allCheckboxes = screen.getAllByRole('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')
actionsToolbar = screen.getAllByRole('toolbar')[0]
})
it('downloads all selected projects and then unselects them', async function () {
const downloadButton =
within(actionsToolbar).getByLabelText('Download')
fireEvent.click(downloadButton)
const assignStub = this.locationWrapperStub.assign
await waitFor(() => {
expect(assignStub).to.have.been.called
})
sinon.assert.calledWithMatch(
assignStub,
`/project/download/zip?project_ids=${project1Id},${project2Id}`
)
const allCheckboxes =
screen.getAllByRole('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 () {
const archiveProjectMock = fetchMock.post(
`express:/project/:projectId/archive`,
{
status: 200,
},
{ delay: 0 }
)
const archiveButton = within(actionsToolbar).getByLabelText('Archive')
fireEvent.click(archiveButton)
const confirmBtn = screen.getByRole('button', {
name: 'Confirm',
}) as HTMLButtonElement
fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true
await waitFor(
() =>
expect(
archiveProjectMock.callHistory.called(
`/project/${project1Id}/archive`
)
).to.be.true
)
await waitFor(
() =>
expect(
archiveProjectMock.callHistory.called(
`/project/${project2Id}/archive`
)
).to.be.true
)
})
it('opens trash modal for all selected projects and trashes all', async function () {
const trashProjectMock = fetchMock.post(
`express:/project/:projectId/trash`,
{
status: 200,
},
{ delay: 0 }
)
const archiveButton = within(actionsToolbar).getByLabelText('Trash')
fireEvent.click(archiveButton)
const confirmBtn = screen.getByRole('button', {
name: 'Confirm',
}) as HTMLButtonElement
fireEvent.click(confirmBtn)
expect(confirmBtn.disabled).to.be.true
await waitFor(
() =>
expect(
trashProjectMock.callHistory.called(
`/project/${project1Id}/trash`
)
).to.be.true
)
await waitFor(
() =>
expect(
trashProjectMock.callHistory.called(
`/project/${project2Id}/trash`
)
).to.be.true
)
})
it('only checks the projects that are viewable when there is a load more button', async function () {
// first one is the select all checkbox
fireEvent.click(allCheckboxes[0])
allCheckboxes = screen.getAllByRole('checkbox')
let checked = allCheckboxes.filter(c => c.checked)
expect(checked.length).to.equal(21) // max projects viewable by default is 20, and plus one for check all
const loadMoreButton = screen.getByRole('button', {
name: 'Show 17 more projects',
})
fireEvent.click(loadMoreButton)
allCheckboxes = screen.getAllByRole('checkbox')
expect(allCheckboxes.length).to.equal(currentList.length + 1)
checked = allCheckboxes.filter(c => c.checked)
expect(checked.length).to.equal(20) // remains same even after showing more
})
it('maintains viewable and selected projects after loading more and then selecting all', async function () {
const loadMoreButton = screen.getByRole('button', {
name: 'Show 17 more projects',
})
fireEvent.click(loadMoreButton)
// verify button gone
screen.getByText(
`Showing ${currentList.length} out of ${currentList.length} projects.`
)
// first one is the select all checkbox
fireEvent.click(allCheckboxes[0])
// verify button still gone
screen.getByText(
`Showing ${currentList.length} out of ${currentList.length} projects.`
)
// allCheckboxes = screen.getAllByRole('checkbox')
// expect(allCheckboxes.length).to.equal(currentList.length + 1)
})
})
describe('archived projects', function () {
beforeEach(function () {
const filterButton = screen.getAllByText('Archived projects')[0]
fireEvent.click(filterButton)
allCheckboxes = screen.getAllByRole('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')
actionsToolbar = screen.getAllByRole('toolbar')[0]
})
it('does not show the archive button in toolbar when archive view selected', function () {
expect(screen.queryByLabelText('Archive')).to.be.null
})
it('restores all projects when selected', async function () {
fetchMock.delete(`express:/project/:id/archive`, {
status: 200,
})
const unarchiveButton = within(actionsToolbar).getByRole('button', {
name: 'Restore',
})
fireEvent.click(unarchiveButton)
await fetchMock.callHistory.flush(true)
await screen.findByText('No projects')
})
it('only unarchive the selected projects', async function () {
// beforeEach selected all, so uncheck the 1st project
fireEvent.click(allCheckboxes[1])
const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
expect(allCheckboxesChecked.length).to.equal(
archivedProjects.length - 1
)
await fetchMock.callHistory.flush(true)
expect(screen.queryByText('No projects')).to.be.null
})
})
describe('trashed projects', function () {
beforeEach(function () {
const filterButton = screen.getAllByText('Trashed projects')[0]
fireEvent.click(filterButton)
allCheckboxes = screen.getAllByRole('checkbox')
// + 1 because of select all
expect(allCheckboxes.length).to.equal(trashedList.length + 1)
// first one is the select all checkbox
fireEvent.click(allCheckboxes[0])
const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
// + 1 because of select all
expect(allCheckboxesChecked.length).to.equal(trashedList.length + 1)
actionsToolbar = screen.getAllByRole('toolbar')[0]
})
it('shows the download, archive, and restore buttons in top toolbar', function () {
expect(screen.queryByLabelText('Trash')).to.be.null
within(actionsToolbar).queryByLabelText('Download')
within(actionsToolbar).queryByLabelText('Archive')
within(actionsToolbar).getByText('Restore') // no icon for this button
})
it('clears selected projects when filter changed', function () {
const filterButton = screen.getAllByText('All projects')[0]
fireEvent.click(filterButton)
const allCheckboxes =
screen.getAllByRole('checkbox')
const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
expect(allCheckboxesChecked.length).to.equal(0)
})
it('untrashes all the projects', async function () {
fetchMock.delete(`express:/project/:id/trash`, {
status: 200,
})
const untrashButton =
within(actionsToolbar).getByText('Restore')
fireEvent.click(untrashButton)
await fetchMock.callHistory.flush(true)
await screen.findByText('No projects')
})
it('only untrashes the selected projects', async function () {
// beforeEach selected all, so uncheck the 1st project
fireEvent.click(allCheckboxes[1])
const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
expect(allCheckboxesChecked.length).to.equal(trashedList.length - 1)
await fetchMock.callHistory.flush(true)
expect(screen.queryByText('No projects')).to.be.null
})
it('removes project from view when archiving', async function () {
fetchMock.post(
`express:/project/:id/archive`,
{
status: 200,
},
{ repeat: trashedList.length }
)
const archiveButton =
within(actionsToolbar).getByLabelText('Archive')
fireEvent.click(archiveButton)
const confirmButton = screen.getByRole('button', {
name: 'Confirm',
}) as HTMLButtonElement
fireEvent.click(confirmButton)
expect(confirmButton.disabled).to.be.true
await fetchMock.callHistory.flush(true)
const calls = fetchMock.callHistory.calls().map(({ url }) => url)
trashedList.forEach(project => {
expect(calls).to.contain(
`https://www.test-overleaf.com/project/${project.id}/archive`
)
})
})
it('removes only selected projects from view when leaving', async function () {
// rerender content with different projects
this.unmount()
fetchMock.removeRoutes().clearHistory()
renderWithProjectListContext(, {
projects: leavableList,
})
await fetchMock.callHistory.flush(true)
await screen.findByRole('table')
expect(leavableList.length).to.be.greaterThan(0)
fetchMock.post(
`express:/project/:id/leave`,
{
status: 200,
},
{ repeat: leavableList.length }
)
// + 1 because of select all
await waitFor(() =>
expect(
screen.getAllByRole('checkbox').length
).to.equal(leavableList.length + 1)
)
// first one is the select all checkbox
fireEvent.click(screen.getAllByRole('checkbox')[0])
actionsToolbar = screen.getAllByRole('toolbar')[0]
const toolbar = within(actionsToolbar)
expect(toolbar.queryByRole('button', { name: /delete/i })).to.be.null
expect(
toolbar.queryByRole('button', {
name: /delete \/ leave/i,
})
).to.be.null
const leaveButton = toolbar.getByRole('button', {
name: /leave/i,
})
fireEvent.click(leaveButton)
const confirmButton = screen.getByRole('button', {
name: 'Confirm',
}) as HTMLButtonElement
fireEvent.click(confirmButton)
expect(confirmButton.disabled).to.be.true
await fetchMock.callHistory.flush(true)
const calls = fetchMock.callHistory.calls().map(({ url }) => url)
leavableList.forEach(project => {
expect(calls).to.contain(
`https://www.test-overleaf.com/project/${project.id}/leave`
)
})
})
it('removes only selected projects from view when deleting', async function () {
// rerender content with different projects
this.unmount()
fetchMock.removeRoutes().clearHistory()
renderWithProjectListContext(, {
projects: deletableList,
})
await fetchMock.callHistory.flush(true)
await screen.findByRole('table')
expect(deletableList.length).to.be.greaterThan(0)
fetchMock.delete(
`express:/project/:id`,
{
status: 200,
},
{ repeat: deletableList.length }
)
await waitFor(() => {
allCheckboxes = screen.getAllByRole('checkbox')
// + 1 because of select all
expect(allCheckboxes.length).to.equal(deletableList.length + 1)
})
// first one is the select all checkbox
fireEvent.click(allCheckboxes[0])
actionsToolbar = screen.getAllByRole('toolbar')[0]
const toolbar = within(actionsToolbar)
expect(toolbar.queryByRole('button', { name: /leave/i })).to.be.null
expect(
toolbar.queryByRole('button', {
name: /delete \/ leave/i,
})
).to.be.null
const deleteButton = toolbar.getByRole('button', {
name: /delete/i,
})
fireEvent.click(deleteButton)
const confirmButton = screen.getByRole('button', {
name: 'Confirm',
}) as HTMLButtonElement
fireEvent.click(confirmButton)
expect(confirmButton.disabled).to.be.true
await fetchMock.callHistory.flush(true)
const calls = fetchMock.callHistory.calls().map(({ url }) => url)
deletableList.forEach(project => {
expect(calls).to.contain(
`https://www.test-overleaf.com/project/${project.id}`
)
})
})
it('removes only selected projects from view when deleting and leaving', async function () {
// rerender content with different projects
this.unmount()
fetchMock.removeRoutes().clearHistory()
const deletableAndLeavableList = [...deletableList, ...leavableList]
renderWithProjectListContext(, {
projects: deletableAndLeavableList,
})
await fetchMock.callHistory.flush(true)
await screen.findByRole('table')
expect(deletableList.length).to.be.greaterThan(0)
expect(leavableList.length).to.be.greaterThan(0)
fetchMock
.delete(
`express:/project/:id`,
{
status: 200,
},
{ repeat: deletableList.length }
)
.post(
`express:/project/:id/leave`,
{
status: 200,
},
{ repeat: leavableList.length }
)
// + 1 because of select all
await waitFor(() =>
expect(
screen.getAllByRole('checkbox').length
).to.equal(deletableAndLeavableList.length + 1)
)
// first one is the select all checkbox
fireEvent.click(screen.getAllByRole('checkbox')[0])
actionsToolbar = screen.getAllByRole('toolbar')[0]
const toolbar = within(actionsToolbar)
expect(toolbar.queryByRole('button', { name: 'Leave' })).to.be.null
expect(toolbar.queryByRole('button', { name: 'Delete' })).to.be.null
const deleteLeaveButton = toolbar.getByRole('button', {
name: /delete \/ leave/i,
})
fireEvent.click(deleteLeaveButton)
const confirmButton = screen.getByRole('button', {
name: 'Confirm',
}) as HTMLButtonElement
fireEvent.click(confirmButton)
expect(confirmButton.disabled).to.be.true
await fetchMock.callHistory.flush(true)
const calls = fetchMock.callHistory.calls().map(({ url }) => url)
deletableAndLeavableList.forEach(project => {
expect(calls).to.contain.oneOf([
`https://www.test-overleaf.com/project/${project.id}`,
`https://www.test-overleaf.com/project/${project.id}/leave`,
])
})
})
})
describe('tags', function () {
it('does not show archived or trashed project', async function () {
this.unmount()
fetchMock.removeRoutes().clearHistory()
window.metaAttributesCache.set('ol-tags', [
{
_id: this.tagId,
name: this.tagName,
project_ids: [
projectsData[0].id,
projectsData[1].id,
...archivedList.map(p => p.id),
...trashedList.map(p => p.id),
],
},
])
const trashProjectMock = fetchMock.post(
`express:/project/:projectId/trash`,
{ status: 200 }
)
renderWithProjectListContext(, {
projects: fullList,
})
await screen.findByRole('table')
let visibleProjectsCount = 2
const [tagBtn] = screen.getAllByRole('button', {
name: `${this.tagName} (${visibleProjectsCount})`,
})
fireEvent.click(tagBtn)
const nonArchivedAndTrashedProjects = [
projectsData[0],
projectsData[1],
]
nonArchivedAndTrashedProjects.forEach(p => {
screen.getByText(p.name)
})
const archivedAndTrashedProjects = [...archivedList, ...trashedList]
archivedAndTrashedProjects.forEach(p => {
expect(screen.queryByText(p.name)).to.be.null
})
const trashBtns = screen.getAllByRole('button', { name: 'Trash' })
for (const [index, trashBtn] of trashBtns.entries()) {
fireEvent.click(trashBtn)
fireEvent.click(
screen.getByRole('button', {
name: 'Confirm',
})
)
await waitFor(() => {
expect(
trashProjectMock.callHistory.called(
`/project/${projectsData[index].id}/trash`
)
).to.be.true
})
await waitFor(() => {
expect(
screen.queryAllByText(projectsData[index].name)
).to.have.length(0)
})
screen.getAllByRole('button', {
name: `${this.tagName} (${--visibleProjectsCount})`,
})
}
})
})
describe('tags dropdown', function () {
beforeEach(async function () {
allCheckboxes = screen.getAllByRole('checkbox')
// first one is the select all checkbox
fireEvent.click(allCheckboxes[1])
fireEvent.click(allCheckboxes[2])
actionsToolbar = screen.getAllByRole('toolbar')[0]
this.newTagName = 'Some tag name'
this.newTagId = 'abc123def456'
})
it('opens the tags dropdown and creates a new tag', async function () {
fetchMock.post(`express:/tag`, {
status: 200,
body: {
_id: this.newTagId,
name: this.newTagName,
project_ids: [],
},
})
fetchMock.post(`express:/tag/:id/projects`, {
status: 204,
})
const tagsDropdown =
await within(actionsToolbar).findByLabelText('Tags')
fireEvent.click(tagsDropdown)
screen.getByText('Add to tag')
const newTagButton = screen.getByRole('menuitem', {
name: 'Create new tag',
})
fireEvent.click(newTagButton)
const modal = screen.getAllByRole('dialog')[0]
const input = within(modal).getByRole('textbox')
fireEvent.change(input, {
target: { value: this.newTagName },
})
const createButton = within(modal).getByRole('button', {
name: 'Create',
})
fireEvent.click(createButton)
await fetchMock.callHistory.flush(true)
expect(
fetchMock.callHistory.called('/tag', { name: this.newTagName })
).to.be.true
expect(
fetchMock.callHistory.called(`/tag/${this.newTagId}/projects`, {
body: {
projectIds: [projectsData[0].id, projectsData[1].id],
},
})
).to.be.true
await screen.findByRole(
'button',
{ name: `${this.newTagName} (2)` },
{ timeout: 5000 }
)
})
it('opens the tags dropdown and remove a tag from selected projects', async function () {
const deleteProjectsFromTagMock = fetchMock.post(
`express:/tag/:id/projects/remove`,
{
status: 204,
}
)
screen.getByRole('button', { name: `${this.tagName} (2)` })
const tagsDropdown = within(actionsToolbar).getByLabelText('Tags')
fireEvent.click(tagsDropdown)
within(actionsToolbar).getByText('Add to tag')
const tagButton = within(actionsToolbar).getByLabelText(
`Add or remove project from tag ${this.tagName}`
)
fireEvent.click(tagButton)
await fetchMock.callHistory.flush(true)
expect(
deleteProjectsFromTagMock.callHistory.called(
`/tag/${this.tagId}/projects/remove`,
{
body: {
projectIds: [projectsData[0].id, projectsData[1].id],
},
}
)
).to.be.true
screen.getByRole('button', { name: `${this.tagName} (0)` })
})
it('select another project, opens the tags dropdown and add a tag only to the untagged project', async function () {
const addProjectsToTagMock = fetchMock.post(
`express:/tag/:id/projects`,
{
status: 204,
}
)
fireEvent.click(allCheckboxes[3])
screen.getByRole('button', { name: `${this.tagName} (2)` })
const tagsDropdown = within(actionsToolbar).getByLabelText('Tags')
fireEvent.click(tagsDropdown)
within(actionsToolbar).getByText('Add to tag')
const tagButton = within(actionsToolbar).getByLabelText(
`Add or remove project from tag ${this.tagName}`
)
fireEvent.click(tagButton)
await fetchMock.callHistory.flush(true)
expect(
addProjectsToTagMock.callHistory.called(
`/tag/${this.tagId}/projects`,
{
body: {
projectIds: [projectsData[2].id],
},
}
)
).to.be.true
screen.getByRole('button', { name: `${this.tagName} (3)` })
})
})
describe('project tools', function () {
it('renders download, archive, trash buttons followed by tags and "more" dropdowns when selecting an archived or trashed filter before selecting tag filter', function () {
const assertToolbarButtonsExists = () => {
within(actionsToolbar).getByLabelText(/download/i)
within(actionsToolbar).getByLabelText(/archive/i)
within(actionsToolbar).getByLabelText(/trash/i)
within(actionsToolbar).getByLabelText(/tags/i)
within(actionsToolbar).getByText(/more/i)
}
// Select archived projects
const [archivedProjectsButton] =
screen.getAllByText(/archived projects/i)
fireEvent.click(archivedProjectsButton)
const [tag] = screen.getAllByText(this.tagName)
fireEvent.click(tag)
allCheckboxes = screen.getAllByRole('checkbox')
fireEvent.click(allCheckboxes[1])
actionsToolbar = screen.getAllByRole('toolbar')[0]
assertToolbarButtonsExists()
// select trashed projects
const [trashedProjectsButton] =
screen.getAllByText(/trashed projects/i)
fireEvent.click(trashedProjectsButton)
fireEvent.click(tag)
allCheckboxes = screen.getAllByRole('checkbox')
fireEvent.click(allCheckboxes[1])
actionsToolbar = screen.getAllByRole('toolbar')[0]
assertToolbarButtonsExists()
})
describe('"More" dropdown', function () {
beforeEach(async function () {
const filterButton = screen.getAllByText('All projects')[0]
fireEvent.click(filterButton)
allCheckboxes = screen.getAllByRole('checkbox')
})
it('does not show the dropdown when more than 1 project is selected', async function () {
fireEvent.click(allCheckboxes[2]) // select a project
const actionsToolbar = screen.getAllByRole('toolbar')[0]
await waitFor(() => {
within(actionsToolbar).getByText('More')
})
fireEvent.click(allCheckboxes[0]) // select all
expect(within(actionsToolbar).queryByText('More')).to
.be.null
})
it('validates the project name', async function () {
fireEvent.click(allCheckboxes[1]) // select a project owned by the current user
const actionsToolbar = screen.getAllByRole('toolbar')[0]
const moreDropdown =
await within(actionsToolbar).findByText('More')
fireEvent.click(moreDropdown)
const editButton =
screen.getAllByText('Rename')[0]
fireEvent.click(editButton)
const modals = await screen.findAllByRole('dialog')
const modal = modals[0]
expect(sendMBSpy).to.have.been.calledTwice
expect(sendMBSpy).to.have.been.calledWith('loads_v2_dash')
expect(sendMBSpy).to.have.been.calledWith(
'project-list-page-interaction',
{
action: 'rename',
page: '/',
projectId: copyableProject.id,
isSmallDevice: true,
}
)
// same name
let confirmButton = within(modal).getByRole('button', {
name: 'Rename',
}) as HTMLButtonElement
expect(confirmButton.disabled).to.be.true
// no name
const input = screen.getByLabelText(
/New name/i
) as HTMLButtonElement
fireEvent.change(input, {
target: { value: '' },
})
confirmButton = within(modal).getByRole('button', {
name: 'Rename',
}) as HTMLButtonElement
expect(confirmButton.disabled).to.be.true
})
it('opens the rename modal, and can rename the project, and view updated', async function () {
fireEvent.click(allCheckboxes[1]) // select a project owned by the current user
const actionsToolbar = screen.getAllByRole('toolbar')[0]
const renameProjectMock = fetchMock.post(
`express:/project/:id/rename`,
{
status: 200,
}
)
const moreDropdown =
await within(actionsToolbar).findByText('More')
fireEvent.click(moreDropdown)
const renameButton =
within(actionsToolbar).getByText('Rename')
fireEvent.click(renameButton)
const modals = await screen.findAllByRole('dialog')
const modal = modals[0]
// a valid name
const newProjectName = 'A new project name'
const input = (await within(modal).findByLabelText(
/New name/i
)) as HTMLButtonElement
const oldName = input.value
fireEvent.change(input, {
target: { value: newProjectName },
})
const confirmButton = within(modal).getByRole('button', {
name: 'Rename',
}) as HTMLButtonElement
expect(confirmButton.disabled).to.be.false
fireEvent.click(confirmButton)
await fetchMock.callHistory.flush(true)
expect(
renameProjectMock.callHistory.called(
`/project/${projectsData[0].id}/rename`
)
).to.be.true
const table = await screen.findByRole('table')
await within(table).findByText(newProjectName)
expect(within(table).queryByText(oldName)).to.be.null
const allCheckboxesInTable =
await within(table).findAllByRole('checkbox')
const allCheckboxesChecked = allCheckboxesInTable.filter(
c => c.checked
)
expect(allCheckboxesChecked.length).to.equal(0)
})
it('opens the copy modal, can copy the project, and view updated', async function () {
fireEvent.click(allCheckboxes[2])
const actionsToolbar = screen.getAllByRole('toolbar')[0]
const tableRows = screen.getAllByRole('row')
const linkForProjectToCopy = within(tableRows[1]).getByRole('link')
const projectNameToCopy = linkForProjectToCopy.textContent || '' // needed for type checking
screen.getByText(projectNameToCopy) // make sure not just empty string
const copiedProjectName = `${projectNameToCopy} (Copy)`
const cloneProjectMock = fetchMock.post(
`express:/project/:id/clone`,
{
status: 200,
body: {
name: copiedProjectName,
lastUpdated: new Date(),
project_id: userId,
owner_ref: userId,
owner,
id: '6328e14abec0df019fce0be5',
lastUpdatedBy: owner,
accessLevel: 'owner',
source: 'owner',
trashed: false,
archived: false,
},
}
)
await waitFor(() => {
const moreDropdown =
within(actionsToolbar).getByText('More')
fireEvent.click(moreDropdown)
})
const copyButton =
within(actionsToolbar).getByText('Make a copy')
fireEvent.click(copyButton)
// confirm in modal
const copyConfirmButton = document.querySelector(
'button[type="submit"]'
) as HTMLElement
fireEvent.click(copyConfirmButton)
await fetchMock.callHistory.flush(true)
expect(
cloneProjectMock.callHistory.called(
`/project/${projectsData[1].id}/clone`
)
).to.be.true
expect(sendMBSpy).to.have.been.calledTwice
expect(sendMBSpy).to.have.been.calledWith('loads_v2_dash')
expect(sendMBSpy).to.have.been.calledWith(
'project-list-page-interaction',
{
action: 'clone',
page: '/',
projectId: archiveableProject.id,
isSmallDevice: true,
}
)
await screen.findByText(copiedProjectName)
})
})
})
describe('projects list in actions modal', function () {
let modal: HTMLElement
let projectsToProcess: any[]
function selectedProjectNames() {
projectsToProcess = []
// needs to be done ahead of opening modal
allCheckboxes = screen.getAllByRole('checkbox')
// update list so we know which are checked
const tableRows = screen.getAllByRole('row')
for (const [index, checkbox] of allCheckboxes.entries()) {
if (checkbox.checked) {
const linkForProjectToCopy = within(tableRows[index]).getByRole(
'link'
)
const projectNameToCopy = linkForProjectToCopy.textContent
projectsToProcess.push(projectNameToCopy)
}
}
expect(projectsToProcess.length > 0).to.be.true
}
function selectedMatchesDisplayed(expectedLength: number) {
selectedProjectNames()
// any action will work for check since they all use the same modal
const archiveButton = within(actionsToolbar).getByLabelText('Archive')
fireEvent.click(archiveButton)
modal = screen.getAllByRole('dialog')[0]
const listitems = within(modal).getAllByRole('listitem')
expect(listitems.length).to.equal(projectsToProcess.length)
expect(listitems.length).to.equal(expectedLength)
for (const projectName of projectsToProcess) {
within(modal).getByText(projectName)
}
}
beforeEach(function () {
fetchMock.removeRoutes().clearHistory()
allCheckboxes = screen.getAllByRole('checkbox')
// first one is the select all checkbox, just check 2 at first
fireEvent.click(allCheckboxes[1])
fireEvent.click(allCheckboxes[2])
actionsToolbar = screen.getAllByRole('toolbar')[0]
})
it('opens the modal with the 2 originally selected projects', function () {
selectedMatchesDisplayed(2)
})
it('shows correct list after closing modal, changing selection, and reopening modal', async function () {
selectedMatchesDisplayed(2)
const modal = screen.getAllByRole('dialog', { hidden: false })[0]
const cancelButton = within(modal).getByRole('button', {
name: 'Cancel',
})
fireEvent.click(cancelButton)
await waitForElementToBeRemoved(
screen.getByRole('dialog', { hidden: false })
)
await screen.findAllByRole('checkbox')
fireEvent.click(allCheckboxes[3])
selectedMatchesDisplayed(3)
})
it('maintains original list even after some have been processed', async function () {
const totalProjectsToProcess = 2
selectedMatchesDisplayed(totalProjectsToProcess)
const button = screen.getByRole('button', { name: 'Confirm' })
fireEvent.click(button)
project1Id = allCheckboxes[1].getAttribute('data-project-id')
fetchMock.post('express:/project/:id/archive', {
status: 200,
})
fetchMock.post(`/project/${project2Id}/archive`, {
status: 500,
})
await screen.findByRole('alert') // ensure that error was thrown for the 2nd project
const listitems = within(modal).getAllByRole('listitem')
expect(listitems.length).to.equal(totalProjectsToProcess)
})
})
})
describe('search', function () {
it('shows only projects based on the input', async function () {
const input = screen.getAllByRole('textbox', {
name: /search in all projects/i,
})[0]
const value = currentList[0].name
fireEvent.change(input, { target: { value } })
const results = screen.getAllByRole('row')
expect(results.length).to.equal(2) // first is header
})
})
describe('copying project', function () {
it('correctly updates the view after copying a shared project', async function () {
const filterButton = screen.getAllByText('Shared with you')[0]
fireEvent.click(filterButton)
const tableRows = screen.getAllByRole('row')
const linkForProjectToCopy = within(tableRows[1]).getByRole('link')
const projectNameToCopy = linkForProjectToCopy.textContent
const copiedProjectName = `${projectNameToCopy} Copy`
fetchMock.post(`express:/project/:id/clone`, {
status: 200,
body: {
name: copiedProjectName,
lastUpdated: new Date(),
project_id: userId,
owner_ref: userId,
owner,
id: '6328e14abec0df019fce0be5',
lastUpdatedBy: owner,
accessLevel: 'owner',
source: 'owner',
trashed: false,
archived: false,
},
})
const copyButton = within(tableRows[1]).getAllByRole('button', {
name: 'Copy',
})[0]
fireEvent.click(copyButton)
// confirm in modal
const copyConfirmButton = document.querySelector(
'button[type="submit"]'
) as HTMLElement
fireEvent.click(copyConfirmButton)
await fetchMock.callHistory.flush(true)
expect(sendMBSpy).to.have.been.calledTwice
expect(sendMBSpy).to.have.been.calledWith('loads_v2_dash')
expect(sendMBSpy).to.have.been.calledWith(
'project-list-page-interaction',
{
action: 'clone',
page: '/',
projectId: archiveableProject.id,
isSmallDevice: true,
}
)
expect(screen.queryByText(copiedProjectName)).to.be.null
const yourProjectFilter = screen.getAllByText('Your projects')[0]
fireEvent.click(yourProjectFilter)
await screen.findByText(copiedProjectName)
})
})
})
})