project-list-root.test.tsx 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117
  1. import { fireEvent, screen, waitFor, within } from '@testing-library/react'
  2. import { expect } from 'chai'
  3. import fetchMock from 'fetch-mock'
  4. import sinon from 'sinon'
  5. import ProjectListRoot from '../../../../../frontend/js/features/project-list/components/project-list-root'
  6. import { renderWithProjectListContext } from '../helpers/render-with-context'
  7. import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking'
  8. import {
  9. projectsData,
  10. owner,
  11. archivedProjects,
  12. makeLongProjectList,
  13. } from '../fixtures/projects-data'
  14. import * as useLocationModule from '../../../../../frontend/js/shared/hooks/use-location'
  15. const {
  16. fullList,
  17. currentList,
  18. archivedList,
  19. trashedList,
  20. leavableList,
  21. deletableList,
  22. } = makeLongProjectList(40)
  23. const userId = owner.id
  24. // eslint-disable-next-line mocha/no-skipped-tests
  25. describe.skip('<ProjectListRoot />', function () {
  26. let sendMBSpy: sinon.SinonSpy
  27. let assignStub: sinon.SinonStub
  28. beforeEach(async function () {
  29. global.localStorage.clear()
  30. sendMBSpy = sinon.spy(eventTracking, 'sendMB')
  31. window.metaAttributesCache = new Map()
  32. this.tagId = '999fff999fff'
  33. this.tagName = 'First tag name'
  34. window.metaAttributesCache.set('ol-tags', [
  35. {
  36. _id: this.tagId,
  37. name: this.tagName,
  38. project_ids: [projectsData[0].id, projectsData[1].id],
  39. },
  40. ])
  41. window.metaAttributesCache.set('ol-ExposedSettings', {
  42. templateLinks: [],
  43. })
  44. window.metaAttributesCache.set('ol-userEmails', [
  45. { email: 'test@overleaf.com', default: true },
  46. ])
  47. window.user_id = userId
  48. assignStub = sinon.stub()
  49. this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
  50. assign: assignStub,
  51. reload: sinon.stub(),
  52. })
  53. })
  54. afterEach(function () {
  55. sendMBSpy.restore()
  56. window.user_id = undefined
  57. fetchMock.reset()
  58. this.locationStub.restore()
  59. })
  60. describe('welcome page', function () {
  61. beforeEach(async function () {
  62. renderWithProjectListContext(<ProjectListRoot />, {
  63. projects: [],
  64. })
  65. await fetchMock.flush(true)
  66. })
  67. it('the welcome page is displayed', async function () {
  68. screen.getByRole('heading', { name: 'Welcome to Overleaf!' })
  69. })
  70. it('the email confirmation alert is not displayed', async function () {
  71. expect(
  72. screen.queryByText(
  73. 'Please confirm your email test@overleaf.com by clicking on the link in the confirmation email'
  74. )
  75. ).to.be.null
  76. })
  77. })
  78. describe('project table', function () {
  79. beforeEach(async function () {
  80. const { unmount } = renderWithProjectListContext(<ProjectListRoot />, {
  81. projects: fullList,
  82. })
  83. this.unmount = unmount
  84. await fetchMock.flush(true)
  85. await screen.findByRole('table')
  86. })
  87. describe('checkboxes', function () {
  88. let allCheckboxes: Array<HTMLInputElement> = []
  89. let actionsToolbar: HTMLElement
  90. let project1Id: string | null, project2Id: string | null
  91. describe('all projects', function () {
  92. beforeEach(function () {
  93. allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
  94. // first one is the select all checkbox
  95. fireEvent.click(allCheckboxes[1])
  96. fireEvent.click(allCheckboxes[2])
  97. project1Id = allCheckboxes[1].getAttribute('data-project-id')
  98. project2Id = allCheckboxes[2].getAttribute('data-project-id')
  99. actionsToolbar = screen.getAllByRole('toolbar')[0]
  100. })
  101. it('downloads all selected projects and then unselects them', async function () {
  102. const downloadButton =
  103. within(actionsToolbar).getByLabelText('Download')
  104. fireEvent.click(downloadButton)
  105. await waitFor(() => {
  106. expect(assignStub).to.have.been.called
  107. })
  108. sinon.assert.calledWithMatch(
  109. assignStub,
  110. `/project/download/zip?project_ids=${project1Id},${project2Id}`
  111. )
  112. const allCheckboxes =
  113. screen.getAllByRole<HTMLInputElement>('checkbox')
  114. const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
  115. expect(allCheckboxesChecked.length).to.equal(0)
  116. })
  117. it('opens archive modal for all selected projects and archives all', async function () {
  118. const archiveProjectMock = fetchMock.post(
  119. `express:/project/:projectId/archive`,
  120. {
  121. status: 200,
  122. },
  123. { delay: 0 }
  124. )
  125. const archiveButton = within(actionsToolbar).getByLabelText('Archive')
  126. fireEvent.click(archiveButton)
  127. const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
  128. fireEvent.click(confirmBtn)
  129. expect(confirmBtn.disabled).to.be.true
  130. await waitFor(
  131. () =>
  132. expect(
  133. archiveProjectMock.called(`/project/${project1Id}/archive`)
  134. ).to.be.true
  135. )
  136. await waitFor(
  137. () =>
  138. expect(
  139. archiveProjectMock.called(`/project/${project2Id}/archive`)
  140. ).to.be.true
  141. )
  142. })
  143. it('opens trash modal for all selected projects and trashes all', async function () {
  144. const trashProjectMock = fetchMock.post(
  145. `express:/project/:projectId/trash`,
  146. {
  147. status: 200,
  148. },
  149. { delay: 0 }
  150. )
  151. const archiveButton = within(actionsToolbar).getByLabelText('Trash')
  152. fireEvent.click(archiveButton)
  153. const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
  154. fireEvent.click(confirmBtn)
  155. expect(confirmBtn.disabled).to.be.true
  156. await waitFor(
  157. () =>
  158. expect(trashProjectMock.called(`/project/${project1Id}/trash`)).to
  159. .be.true
  160. )
  161. await waitFor(
  162. () =>
  163. expect(trashProjectMock.called(`/project/${project2Id}/trash`)).to
  164. .be.true
  165. )
  166. })
  167. // eslint-disable-next-line mocha/no-skipped-tests
  168. it.skip('only checks the projects that are viewable when there is a load more button', async function () {
  169. // first one is the select all checkbox
  170. fireEvent.click(allCheckboxes[0])
  171. allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
  172. let checked = allCheckboxes.filter(c => c.checked)
  173. expect(checked.length).to.equal(21) // max projects viewable by default is 20, and plus one for check all
  174. const loadMoreButton = screen.getByLabelText('Show 17 more projects')
  175. fireEvent.click(loadMoreButton)
  176. allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
  177. expect(allCheckboxes.length).to.equal(currentList.length + 1)
  178. checked = allCheckboxes.filter(c => c.checked)
  179. expect(checked.length).to.equal(20) // remains same even after showing more
  180. })
  181. it('maintains viewable and selected projects after loading more and then selecting all', async function () {
  182. const loadMoreButton = screen.getByLabelText('Show 17 more projects')
  183. fireEvent.click(loadMoreButton)
  184. // verify button gone
  185. screen.getByText(
  186. `Showing ${currentList.length} out of ${currentList.length} projects.`
  187. )
  188. // first one is the select all checkbox
  189. fireEvent.click(allCheckboxes[0])
  190. // verify button still gone
  191. screen.getByText(
  192. `Showing ${currentList.length} out of ${currentList.length} projects.`
  193. )
  194. allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
  195. expect(allCheckboxes.length).to.equal(currentList.length + 1)
  196. })
  197. })
  198. describe('archived projects', function () {
  199. beforeEach(function () {
  200. const filterButton = screen.getAllByText('Archived Projects')[0]
  201. fireEvent.click(filterButton)
  202. allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
  203. expect(allCheckboxes.length === 2).to.be.true
  204. // first one is the select all checkbox
  205. fireEvent.click(allCheckboxes[1])
  206. project1Id = allCheckboxes[1].getAttribute('data-project-id')
  207. actionsToolbar = screen.getAllByRole('toolbar')[0]
  208. })
  209. it('does not show the archive button in toolbar when archive view selected', function () {
  210. expect(screen.queryByLabelText('Archive')).to.be.null
  211. })
  212. it('restores all projects when selected', async function () {
  213. fetchMock.delete(`express:/project/:id/archive`, {
  214. status: 200,
  215. })
  216. const unarchiveButton =
  217. within(actionsToolbar).getByText<HTMLButtonElement>('Restore')
  218. fireEvent.click(unarchiveButton)
  219. await fetchMock.flush(true)
  220. expect(fetchMock.done()).to.be.true
  221. screen.getByText('No projects')
  222. })
  223. it('only unarchive the selected projects', async function () {
  224. // beforeEach selected all, so uncheck the 1st project
  225. fireEvent.click(allCheckboxes[1])
  226. const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
  227. expect(allCheckboxesChecked.length).to.equal(
  228. archivedProjects.length - 1
  229. )
  230. await fetchMock.flush(true)
  231. expect(fetchMock.done()).to.be.true
  232. expect(screen.queryByText('No projects')).to.be.null
  233. })
  234. })
  235. describe('trashed projects', function () {
  236. beforeEach(function () {
  237. const filterButton = screen.getAllByText('Trashed Projects')[0]
  238. fireEvent.click(filterButton)
  239. allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
  240. // + 1 because of select all
  241. expect(allCheckboxes.length).to.equal(trashedList.length + 1)
  242. // first one is the select all checkbox
  243. fireEvent.click(allCheckboxes[0])
  244. const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
  245. // + 1 because of select all
  246. expect(allCheckboxesChecked.length).to.equal(trashedList.length + 1)
  247. actionsToolbar = screen.getAllByRole('toolbar')[0]
  248. })
  249. it('shows the download, archive, and restore buttons in top toolbar', function () {
  250. expect(screen.queryByLabelText('Trash')).to.be.null
  251. within(actionsToolbar).queryByLabelText('Download')
  252. within(actionsToolbar).queryByLabelText('Archive')
  253. within(actionsToolbar).getByText('Restore') // no icon for this button
  254. })
  255. it('clears selected projects when filter changed', function () {
  256. const filterButton = screen.getAllByText('All Projects')[0]
  257. fireEvent.click(filterButton)
  258. const allCheckboxes =
  259. screen.getAllByRole<HTMLInputElement>('checkbox')
  260. const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
  261. expect(allCheckboxesChecked.length).to.equal(0)
  262. })
  263. it('untrashes all the projects', async function () {
  264. fetchMock.delete(`express:/project/:id/trash`, {
  265. status: 200,
  266. })
  267. const untrashButton =
  268. within(actionsToolbar).getByText<HTMLButtonElement>('Restore')
  269. fireEvent.click(untrashButton)
  270. await fetchMock.flush(true)
  271. expect(fetchMock.done()).to.be.true
  272. screen.getByText('No projects')
  273. })
  274. it('only untrashes the selected projects', async function () {
  275. // beforeEach selected all, so uncheck the 1st project
  276. fireEvent.click(allCheckboxes[1])
  277. const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
  278. expect(allCheckboxesChecked.length).to.equal(trashedList.length - 1)
  279. await fetchMock.flush(true)
  280. expect(fetchMock.done()).to.be.true
  281. expect(screen.queryByText('No projects')).to.be.null
  282. })
  283. it('removes project from view when archiving', async function () {
  284. fetchMock.post(
  285. `express:/project/:id/archive`,
  286. {
  287. status: 200,
  288. },
  289. { repeat: trashedList.length }
  290. )
  291. const archiveButton =
  292. within(actionsToolbar).getByLabelText<HTMLButtonElement>('Archive')
  293. fireEvent.click(archiveButton)
  294. const confirmButton = screen.getByText<HTMLButtonElement>('Confirm')
  295. fireEvent.click(confirmButton)
  296. expect(confirmButton.disabled).to.be.true
  297. await fetchMock.flush(true)
  298. expect(fetchMock.done()).to.be.true
  299. const calls = fetchMock.calls().map(([url]) => url)
  300. trashedList.forEach(project => {
  301. expect(calls).to.contain(`/project/${project.id}/archive`)
  302. })
  303. })
  304. it('removes only selected projects from view when leaving', async function () {
  305. // rerender content with different projects
  306. this.unmount()
  307. fetchMock.restore()
  308. renderWithProjectListContext(<ProjectListRoot />, {
  309. projects: leavableList,
  310. })
  311. await fetchMock.flush(true)
  312. await screen.findByRole('table')
  313. expect(leavableList.length).to.be.greaterThan(0)
  314. fetchMock.post(
  315. `express:/project/:id/leave`,
  316. {
  317. status: 200,
  318. },
  319. { repeat: leavableList.length }
  320. )
  321. allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
  322. // + 1 because of select all
  323. expect(allCheckboxes.length).to.equal(leavableList.length + 1)
  324. // first one is the select all checkbox
  325. fireEvent.click(allCheckboxes[0])
  326. actionsToolbar = screen.getAllByRole('toolbar')[0]
  327. const toolbar = within(actionsToolbar)
  328. expect(toolbar.queryByRole('button', { name: /delete/i })).to.be.null
  329. expect(
  330. toolbar.queryByRole('button', {
  331. name: /delete \/ leave/i,
  332. })
  333. ).to.be.null
  334. const leaveButton = toolbar.getByRole('button', {
  335. name: /leave/i,
  336. })
  337. fireEvent.click(leaveButton)
  338. const confirmButton = screen.getByText<HTMLButtonElement>('Confirm')
  339. fireEvent.click(confirmButton)
  340. expect(confirmButton.disabled).to.be.true
  341. await fetchMock.flush(true)
  342. expect(fetchMock.done()).to.be.true
  343. const calls = fetchMock.calls().map(([url]) => url)
  344. leavableList.forEach(project => {
  345. expect(calls).to.contain(`/project/${project.id}/leave`)
  346. })
  347. })
  348. it('removes only selected projects from view when deleting', async function () {
  349. // rerender content with different projects
  350. this.unmount()
  351. fetchMock.restore()
  352. renderWithProjectListContext(<ProjectListRoot />, {
  353. projects: deletableList,
  354. })
  355. await fetchMock.flush(true)
  356. await screen.findByRole('table')
  357. expect(deletableList.length).to.be.greaterThan(0)
  358. fetchMock.delete(
  359. `express:/project/:id`,
  360. {
  361. status: 200,
  362. },
  363. { repeat: deletableList.length }
  364. )
  365. allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
  366. // + 1 because of select all
  367. expect(allCheckboxes.length).to.equal(deletableList.length + 1)
  368. // first one is the select all checkbox
  369. fireEvent.click(allCheckboxes[0])
  370. actionsToolbar = screen.getAllByRole('toolbar')[0]
  371. const toolbar = within(actionsToolbar)
  372. expect(toolbar.queryByRole('button', { name: /leave/i })).to.be.null
  373. expect(
  374. toolbar.queryByRole('button', {
  375. name: /delete \/ leave/i,
  376. })
  377. ).to.be.null
  378. const deleteButton = toolbar.getByRole('button', {
  379. name: /delete/i,
  380. })
  381. fireEvent.click(deleteButton)
  382. const confirmButton = screen.getByText<HTMLButtonElement>('Confirm')
  383. fireEvent.click(confirmButton)
  384. expect(confirmButton.disabled).to.be.true
  385. await fetchMock.flush(true)
  386. expect(fetchMock.done()).to.be.true
  387. const calls = fetchMock.calls().map(([url]) => url)
  388. deletableList.forEach(project => {
  389. expect(calls).to.contain(`/project/${project.id}`)
  390. })
  391. })
  392. it('removes only selected projects from view when deleting and leaving', async function () {
  393. // rerender content with different projects
  394. this.unmount()
  395. fetchMock.restore()
  396. const deletableAndLeavableList = [...deletableList, ...leavableList]
  397. renderWithProjectListContext(<ProjectListRoot />, {
  398. projects: deletableAndLeavableList,
  399. })
  400. await fetchMock.flush(true)
  401. await screen.findByRole('table')
  402. expect(deletableList.length).to.be.greaterThan(0)
  403. expect(leavableList.length).to.be.greaterThan(0)
  404. fetchMock
  405. .delete(
  406. `express:/project/:id`,
  407. {
  408. status: 200,
  409. },
  410. { repeat: deletableList.length }
  411. )
  412. .post(
  413. `express:/project/:id/leave`,
  414. {
  415. status: 200,
  416. },
  417. { repeat: leavableList.length }
  418. )
  419. allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
  420. // + 1 because of select all
  421. expect(allCheckboxes.length).to.equal(
  422. deletableAndLeavableList.length + 1
  423. )
  424. // first one is the select all checkbox
  425. fireEvent.click(allCheckboxes[0])
  426. actionsToolbar = screen.getAllByRole('toolbar')[0]
  427. const toolbar = within(actionsToolbar)
  428. expect(toolbar.queryByRole('button', { name: 'Leave' })).to.be.null
  429. expect(toolbar.queryByRole('button', { name: 'Delete' })).to.be.null
  430. const deleteLeaveButton = toolbar.getByRole('button', {
  431. name: /delete \/ leave/i,
  432. })
  433. fireEvent.click(deleteLeaveButton)
  434. const confirmButton = screen.getByText<HTMLButtonElement>('Confirm')
  435. fireEvent.click(confirmButton)
  436. expect(confirmButton.disabled).to.be.true
  437. await fetchMock.flush(true)
  438. expect(fetchMock.done()).to.be.true
  439. const calls = fetchMock.calls().map(([url]) => url)
  440. deletableAndLeavableList.forEach(project => {
  441. expect(calls).to.contain.oneOf([
  442. `/project/${project.id}`,
  443. `/project/${project.id}/leave`,
  444. ])
  445. })
  446. })
  447. })
  448. describe('tags', function () {
  449. it('does not show archived or trashed project', async function () {
  450. this.unmount()
  451. fetchMock.restore()
  452. window.metaAttributesCache.set('ol-tags', [
  453. {
  454. _id: this.tagId,
  455. name: this.tagName,
  456. project_ids: [
  457. projectsData[0].id,
  458. projectsData[1].id,
  459. ...archivedList.map(p => p.id),
  460. ...trashedList.map(p => p.id),
  461. ],
  462. },
  463. ])
  464. const trashProjectMock = fetchMock.post(
  465. `express:/project/:projectId/trash`,
  466. { status: 200 }
  467. )
  468. renderWithProjectListContext(<ProjectListRoot />, {
  469. projects: fullList,
  470. })
  471. await screen.findByRole('table')
  472. let visibleProjectsCount = 2
  473. const [tagBtn] = screen.getAllByRole('button', {
  474. name: `${this.tagName} (${visibleProjectsCount})`,
  475. })
  476. fireEvent.click(tagBtn)
  477. const nonArchivedAndTrashedProjects = [
  478. projectsData[0],
  479. projectsData[1],
  480. ]
  481. nonArchivedAndTrashedProjects.forEach(p => {
  482. screen.getByText(p.name)
  483. })
  484. const archivedAndTrashedProjects = [...archivedList, ...trashedList]
  485. archivedAndTrashedProjects.forEach(p => {
  486. expect(screen.queryByText(p.name)).to.be.null
  487. })
  488. const trashBtns = screen.getAllByRole('button', { name: 'Trash' })
  489. for (const [index, trashBtn] of trashBtns.entries()) {
  490. fireEvent.click(trashBtn)
  491. fireEvent.click(screen.getByText<HTMLButtonElement>('Confirm'))
  492. await waitFor(() => {
  493. expect(
  494. trashProjectMock.called(
  495. `/project/${projectsData[index].id}/trash`
  496. )
  497. ).to.be.true
  498. })
  499. expect(
  500. screen.queryAllByText(projectsData[index].name)
  501. ).to.have.length(0)
  502. screen.getAllByRole('button', {
  503. name: `${this.tagName} (${--visibleProjectsCount})`,
  504. })
  505. }
  506. })
  507. })
  508. describe('tags dropdown', function () {
  509. beforeEach(async function () {
  510. allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
  511. // first one is the select all checkbox
  512. fireEvent.click(allCheckboxes[1])
  513. fireEvent.click(allCheckboxes[2])
  514. actionsToolbar = screen.getAllByRole('toolbar')[0]
  515. this.newTagName = 'Some tag name'
  516. this.newTagId = 'abc123def456'
  517. })
  518. it('opens the tags dropdown and creates a new tag', async function () {
  519. fetchMock.post(`express:/tag`, {
  520. status: 200,
  521. body: {
  522. _id: this.newTagId,
  523. name: this.newTagName,
  524. project_ids: [],
  525. },
  526. })
  527. fetchMock.post(`express:/tag/:id/projects`, {
  528. status: 204,
  529. })
  530. await waitFor(() => {
  531. const tagsDropdown = within(actionsToolbar).getByLabelText('Tags')
  532. fireEvent.click(tagsDropdown)
  533. })
  534. screen.getByText('Add to tag')
  535. const newTagButton = screen.getByText('Create new tag')
  536. fireEvent.click(newTagButton)
  537. const modal = screen.getAllByRole('dialog')[0]
  538. const input = within(modal).getByRole<HTMLInputElement>('textbox')
  539. fireEvent.change(input, {
  540. target: { value: this.newTagName },
  541. })
  542. const createButton = within(modal).getByRole('button', {
  543. name: 'Create',
  544. })
  545. fireEvent.click(createButton)
  546. await fetchMock.flush(true)
  547. expect(fetchMock.called('/tag', { name: this.newTagName })).to.be.true
  548. expect(
  549. fetchMock.called(`/tag/${this.newTagId}/projects`, {
  550. body: {
  551. projectIds: [projectsData[0].id, projectsData[1].id],
  552. },
  553. })
  554. ).to.be.true
  555. screen.getByRole('button', { name: `${this.newTagName} (2)` })
  556. })
  557. it('opens the tags dropdown and remove a tag from selected projects', async function () {
  558. const deleteProjectsFromTagMock = fetchMock.delete(
  559. `express:/tag/:id/projects`,
  560. {
  561. status: 204,
  562. }
  563. )
  564. screen.getByRole('button', { name: `${this.tagName} (2)` })
  565. const tagsDropdown = within(actionsToolbar).getByLabelText('Tags')
  566. fireEvent.click(tagsDropdown)
  567. within(actionsToolbar).getByText('Add to tag')
  568. const tagButton = within(actionsToolbar).getByLabelText(
  569. `Add or remove project from tag ${this.tagName}`
  570. )
  571. fireEvent.click(tagButton)
  572. await fetchMock.flush(true)
  573. expect(
  574. deleteProjectsFromTagMock.called(`/tag/${this.tagId}/projects`, {
  575. body: {
  576. projectIds: [projectsData[0].id, projectsData[1].id],
  577. },
  578. })
  579. ).to.be.true
  580. screen.getByRole('button', { name: `${this.tagName} (0)` })
  581. })
  582. it('select another project, opens the tags dropdown and add a tag only to the untagged project', async function () {
  583. const addProjectsToTagMock = fetchMock.post(
  584. `express:/tag/:id/projects`,
  585. {
  586. status: 204,
  587. }
  588. )
  589. fireEvent.click(allCheckboxes[3])
  590. screen.getByRole('button', { name: `${this.tagName} (2)` })
  591. const tagsDropdown = within(actionsToolbar).getByLabelText('Tags')
  592. fireEvent.click(tagsDropdown)
  593. within(actionsToolbar).getByText('Add to tag')
  594. const tagButton = within(actionsToolbar).getByLabelText(
  595. `Add or remove project from tag ${this.tagName}`
  596. )
  597. fireEvent.click(tagButton)
  598. await fetchMock.flush(true)
  599. expect(
  600. addProjectsToTagMock.called(`/tag/${this.tagId}/projects`, {
  601. body: {
  602. projectIds: [projectsData[2].id],
  603. },
  604. })
  605. ).to.be.true
  606. screen.getByRole('button', { name: `${this.tagName} (3)` })
  607. })
  608. })
  609. describe('project tools', function () {
  610. it('renders download, archive, trash buttons followed by tags and "more" dropdowns when selecting an archived or trashed filter before selecting tag filter', function () {
  611. const assertToolbarButtonsExists = () => {
  612. within(actionsToolbar).getByLabelText(/download/i)
  613. within(actionsToolbar).getByLabelText(/archive/i)
  614. within(actionsToolbar).getByLabelText(/trash/i)
  615. within(actionsToolbar).getByLabelText(/tags/i)
  616. within(actionsToolbar).getByText(/more/i)
  617. }
  618. // Select archived projects
  619. const [archivedProjectsButton] =
  620. screen.getAllByText(/archived projects/i)
  621. fireEvent.click(archivedProjectsButton)
  622. const [tag] = screen.getAllByText(this.tagName)
  623. fireEvent.click(tag)
  624. allCheckboxes = screen.getAllByRole('checkbox')
  625. fireEvent.click(allCheckboxes[1])
  626. actionsToolbar = screen.getAllByRole('toolbar')[0]
  627. assertToolbarButtonsExists()
  628. // select trashed projects
  629. const [trashedProjectsButton] =
  630. screen.getAllByText(/trashed projects/i)
  631. fireEvent.click(trashedProjectsButton)
  632. fireEvent.click(tag)
  633. allCheckboxes = screen.getAllByRole('checkbox')
  634. fireEvent.click(allCheckboxes[1])
  635. actionsToolbar = screen.getAllByRole('toolbar')[0]
  636. assertToolbarButtonsExists()
  637. })
  638. describe('"More" dropdown', function () {
  639. beforeEach(async function () {
  640. const filterButton = screen.getAllByText('All Projects')[0]
  641. fireEvent.click(filterButton)
  642. allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
  643. // first one is the select all checkbox
  644. fireEvent.click(allCheckboxes[2])
  645. actionsToolbar = screen.getAllByRole('toolbar')[0]
  646. })
  647. it('does not show the dropdown when more than 1 project is selected', async function () {
  648. await waitFor(() => {
  649. within(actionsToolbar).getByText<HTMLElement>('More')
  650. })
  651. fireEvent.click(allCheckboxes[0])
  652. expect(within(actionsToolbar).queryByText<HTMLElement>('More')).to
  653. .be.null
  654. })
  655. it('validates the project name', async function () {
  656. const moreDropdown = await within(
  657. actionsToolbar
  658. ).findByText<HTMLElement>('More')
  659. fireEvent.click(moreDropdown)
  660. const editButton =
  661. screen.getAllByText<HTMLButtonElement>('Rename')[0]
  662. fireEvent.click(editButton)
  663. const modals = await screen.findAllByRole('dialog')
  664. const modal = modals[0]
  665. expect(sendMBSpy).to.have.been.calledTwice
  666. expect(sendMBSpy).to.have.been.calledWith('loads_v2_dash')
  667. expect(sendMBSpy).to.have.been.calledWith(
  668. 'project-list-page-interaction',
  669. {
  670. action: 'rename',
  671. page: '/',
  672. }
  673. )
  674. // same name
  675. let confirmButton =
  676. within(modal).getByText<HTMLButtonElement>('Rename')
  677. expect(confirmButton.disabled).to.be.true
  678. // no name
  679. const input = screen.getByLabelText('New Name') as HTMLButtonElement
  680. fireEvent.change(input, {
  681. target: { value: '' },
  682. })
  683. confirmButton = within(modal).getByText<HTMLButtonElement>('Rename')
  684. expect(confirmButton.disabled).to.be.true
  685. })
  686. it('opens the rename modal, and can rename the project, and view updated', async function () {
  687. const renameProjectMock = fetchMock.post(
  688. `express:/project/:id/rename`,
  689. {
  690. status: 200,
  691. }
  692. )
  693. const moreDropdown = await within(
  694. actionsToolbar
  695. ).findByText<HTMLElement>('More')
  696. fireEvent.click(moreDropdown)
  697. const renameButton =
  698. within(actionsToolbar).getByText<HTMLButtonElement>('Rename')
  699. fireEvent.click(renameButton)
  700. const modals = await screen.findAllByRole('dialog')
  701. const modal = modals[0]
  702. // a valid name
  703. const newProjectName = 'A new project name'
  704. const input = (await within(modal).findByLabelText(
  705. 'New Name'
  706. )) as HTMLButtonElement
  707. const oldName = input.value
  708. fireEvent.change(input, {
  709. target: { value: newProjectName },
  710. })
  711. const confirmButton =
  712. within(modal).getByText<HTMLButtonElement>('Rename')
  713. expect(confirmButton.disabled).to.be.false
  714. fireEvent.click(confirmButton)
  715. await fetchMock.flush(true)
  716. expect(
  717. renameProjectMock.called(`/project/${projectsData[1].id}/rename`)
  718. ).to.be.true
  719. const table = await screen.findByRole('table')
  720. within(table).getByText(newProjectName)
  721. expect(within(table).queryByText(oldName)).to.be.null
  722. const allCheckboxes = await within(
  723. table
  724. ).findAllByRole<HTMLInputElement>('checkbox')
  725. const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
  726. expect(allCheckboxesChecked.length).to.equal(0)
  727. })
  728. it('opens the copy modal, can copy the project, and view updated', async function () {
  729. const tableRows = screen.getAllByRole('row')
  730. const linkForProjectToCopy = within(tableRows[1]).getByRole('link')
  731. const projectNameToCopy = linkForProjectToCopy.textContent || '' // needed for type checking
  732. screen.getByText(projectNameToCopy) // make sure not just empty string
  733. const copiedProjectName = `${projectNameToCopy} (Copy)`
  734. const cloneProjectMock = fetchMock.post(
  735. `express:/project/:id/clone`,
  736. {
  737. status: 200,
  738. body: {
  739. name: copiedProjectName,
  740. lastUpdated: new Date(),
  741. project_id: userId,
  742. owner_ref: userId,
  743. owner,
  744. id: '6328e14abec0df019fce0be5',
  745. lastUpdatedBy: owner,
  746. accessLevel: 'owner',
  747. source: 'owner',
  748. trashed: false,
  749. archived: false,
  750. },
  751. }
  752. )
  753. await waitFor(() => {
  754. const moreDropdown =
  755. within(actionsToolbar).getByText<HTMLElement>('More')
  756. fireEvent.click(moreDropdown)
  757. })
  758. const copyButton =
  759. within(actionsToolbar).getByText<HTMLButtonElement>('Make a copy')
  760. fireEvent.click(copyButton)
  761. // confirm in modal
  762. const copyConfirmButton = document.querySelector(
  763. 'button[type="submit"]'
  764. ) as HTMLElement
  765. fireEvent.click(copyConfirmButton)
  766. await fetchMock.flush(true)
  767. expect(
  768. cloneProjectMock.called(`/project/${projectsData[1].id}/clone`)
  769. ).to.be.true
  770. expect(sendMBSpy).to.have.been.calledTwice
  771. expect(sendMBSpy).to.have.been.calledWith('loads_v2_dash')
  772. expect(sendMBSpy).to.have.been.calledWith(
  773. 'project-list-page-interaction',
  774. {
  775. action: 'clone',
  776. page: '/',
  777. }
  778. )
  779. screen.getByText(copiedProjectName)
  780. })
  781. })
  782. })
  783. describe('projects list in actions modal', function () {
  784. let modal: HTMLElement
  785. let projectsToProcess: any[]
  786. function selectedProjectNames() {
  787. projectsToProcess = []
  788. // needs to be done ahead of opening modal
  789. allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
  790. // update list so we know which are checked
  791. const tableRows = screen.getAllByRole('row')
  792. for (const [index, checkbox] of allCheckboxes.entries()) {
  793. if (checkbox.checked) {
  794. const linkForProjectToCopy = within(tableRows[index]).getByRole(
  795. 'link'
  796. )
  797. const projectNameToCopy = linkForProjectToCopy.textContent
  798. projectsToProcess.push(projectNameToCopy)
  799. }
  800. }
  801. expect(projectsToProcess.length > 0).to.be.true
  802. }
  803. function selectedMatchesDisplayed(expectedLength: number) {
  804. selectedProjectNames()
  805. // any action will work for check since they all use the same modal
  806. const archiveButton = within(actionsToolbar).getByLabelText('Archive')
  807. fireEvent.click(archiveButton)
  808. modal = screen.getAllByRole('dialog')[0]
  809. const listitems = within(modal).getAllByRole('listitem')
  810. expect(listitems.length).to.equal(projectsToProcess.length)
  811. expect(listitems.length).to.equal(expectedLength)
  812. for (const projectName of projectsToProcess) {
  813. within(modal).getByText(projectName)
  814. }
  815. }
  816. beforeEach(function () {
  817. allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
  818. // first one is the select all checkbox, just check 2 at first
  819. fireEvent.click(allCheckboxes[1])
  820. fireEvent.click(allCheckboxes[2])
  821. actionsToolbar = screen.getAllByRole('toolbar')[0]
  822. })
  823. it('opens the modal with the 2 originally selected projects', function () {
  824. selectedMatchesDisplayed(2)
  825. })
  826. it('shows correct list after closing modal, changing selecting, and reopening modal', async function () {
  827. selectedMatchesDisplayed(2)
  828. const modal = screen.getAllByRole('dialog', { hidden: false })[0]
  829. const cancelButton = within(modal).getByRole('button', {
  830. name: 'Cancel',
  831. })
  832. fireEvent.click(cancelButton)
  833. expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
  834. await screen.findAllByRole('checkbox')
  835. fireEvent.click(allCheckboxes[3])
  836. selectedMatchesDisplayed(3)
  837. })
  838. it('maintains original list even after some have been processed', async function () {
  839. const totalProjectsToProcess = 2
  840. selectedMatchesDisplayed(totalProjectsToProcess)
  841. const button = screen.getByRole('button', { name: 'Confirm' })
  842. fireEvent.click(button)
  843. project1Id = allCheckboxes[1].getAttribute('data-project-id')
  844. fetchMock.post('express:/project/:id/archive', {
  845. status: 200,
  846. })
  847. fetchMock.post(`/project/${project2Id}/archive`, {
  848. status: 500,
  849. })
  850. await screen.findByRole('alert') // ensure that error was thrown for the 2nd project
  851. const listitems = within(modal).getAllByRole('listitem')
  852. expect(listitems.length).to.equal(totalProjectsToProcess)
  853. })
  854. })
  855. })
  856. describe('search', function () {
  857. it('shows only projects based on the input', async function () {
  858. const input = screen.getAllByRole('textbox', {
  859. name: /search in all projects/i,
  860. })[0]
  861. const value = currentList[0].name
  862. fireEvent.change(input, { target: { value } })
  863. const results = screen.getAllByRole('row')
  864. expect(results.length).to.equal(2) // first is header
  865. })
  866. })
  867. describe('copying project', function () {
  868. it('correctly updates the view after copying a shared project', async function () {
  869. const filterButton = screen.getAllByText('Shared with you')[0]
  870. fireEvent.click(filterButton)
  871. const tableRows = screen.getAllByRole('row')
  872. const linkForProjectToCopy = within(tableRows[1]).getByRole('link')
  873. const projectNameToCopy = linkForProjectToCopy.textContent
  874. const copiedProjectName = `${projectNameToCopy} Copy`
  875. fetchMock.post(`express:/project/:id/clone`, {
  876. status: 200,
  877. body: {
  878. name: copiedProjectName,
  879. lastUpdated: new Date(),
  880. project_id: userId,
  881. owner_ref: userId,
  882. owner,
  883. id: '6328e14abec0df019fce0be5',
  884. lastUpdatedBy: owner,
  885. accessLevel: 'owner',
  886. source: 'owner',
  887. trashed: false,
  888. archived: false,
  889. },
  890. })
  891. const copyButton = within(tableRows[1]).getAllByLabelText('Copy')[0]
  892. fireEvent.click(copyButton)
  893. // confirm in modal
  894. const copyConfirmButton = document.querySelector(
  895. 'button[type="submit"]'
  896. ) as HTMLElement
  897. fireEvent.click(copyConfirmButton)
  898. await fetchMock.flush(true)
  899. expect(fetchMock.done()).to.be.true
  900. expect(sendMBSpy).to.have.been.calledTwice
  901. expect(sendMBSpy).to.have.been.calledWith('loads_v2_dash')
  902. expect(sendMBSpy).to.have.been.calledWith(
  903. 'project-list-page-interaction',
  904. {
  905. action: 'clone',
  906. page: '/',
  907. }
  908. )
  909. expect(screen.queryByText(copiedProjectName)).to.be.null
  910. const yourProjectFilter = screen.getAllByText('Your Projects')[0]
  911. fireEvent.click(yourProjectFilter)
  912. screen.findByText(copiedProjectName)
  913. })
  914. })
  915. describe('notifications', function () {
  916. it('email confirmation alert is displayed', async function () {
  917. screen.getByText(
  918. 'Please confirm your email test@overleaf.com by clicking on the link in the confirmation email'
  919. )
  920. })
  921. })
  922. })
  923. })