project-list-root.test.tsx 41 KB

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