project-list-root.test.tsx 41 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202
  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.calledThrice
  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('New Name') as HTMLButtonElement
  746. fireEvent.change(input, {
  747. target: { value: '' },
  748. })
  749. confirmButton = within(modal).getByRole('button', {
  750. name: 'Rename',
  751. }) as HTMLButtonElement
  752. expect(confirmButton.disabled).to.be.true
  753. })
  754. it('opens the rename modal, and can rename the project, and view updated', async function () {
  755. fireEvent.click(allCheckboxes[1]) // select a project owned by the current user
  756. const actionsToolbar = screen.getAllByRole('toolbar')[0]
  757. const renameProjectMock = fetchMock.post(
  758. `express:/project/:id/rename`,
  759. {
  760. status: 200,
  761. }
  762. )
  763. const moreDropdown =
  764. await within(actionsToolbar).findByText<HTMLElement>('More')
  765. fireEvent.click(moreDropdown)
  766. const renameButton =
  767. within(actionsToolbar).getByText<HTMLButtonElement>('Rename')
  768. fireEvent.click(renameButton)
  769. const modals = await screen.findAllByRole('dialog')
  770. const modal = modals[0]
  771. // a valid name
  772. const newProjectName = 'A new project name'
  773. const input = (await within(modal).findByLabelText(
  774. 'New Name'
  775. )) as HTMLButtonElement
  776. const oldName = input.value
  777. fireEvent.change(input, {
  778. target: { value: newProjectName },
  779. })
  780. const confirmButton = within(modal).getByRole('button', {
  781. name: 'Rename',
  782. }) as HTMLButtonElement
  783. expect(confirmButton.disabled).to.be.false
  784. fireEvent.click(confirmButton)
  785. await fetchMock.callHistory.flush(true)
  786. expect(
  787. renameProjectMock.callHistory.called(
  788. `/project/${projectsData[0].id}/rename`
  789. )
  790. ).to.be.true
  791. const table = await screen.findByRole('table')
  792. await within(table).findByText(newProjectName)
  793. expect(within(table).queryByText(oldName)).to.be.null
  794. const allCheckboxesInTable =
  795. await within(table).findAllByRole<HTMLInputElement>('checkbox')
  796. const allCheckboxesChecked = allCheckboxesInTable.filter(
  797. c => c.checked
  798. )
  799. expect(allCheckboxesChecked.length).to.equal(0)
  800. })
  801. it('opens the copy modal, can copy the project, and view updated', async function () {
  802. fireEvent.click(allCheckboxes[2])
  803. const actionsToolbar = screen.getAllByRole('toolbar')[0]
  804. const tableRows = screen.getAllByRole('row')
  805. const linkForProjectToCopy = within(tableRows[1]).getByRole('link')
  806. const projectNameToCopy = linkForProjectToCopy.textContent || '' // needed for type checking
  807. screen.getByText(projectNameToCopy) // make sure not just empty string
  808. const copiedProjectName = `${projectNameToCopy} (Copy)`
  809. const cloneProjectMock = fetchMock.post(
  810. `express:/project/:id/clone`,
  811. {
  812. status: 200,
  813. body: {
  814. name: copiedProjectName,
  815. lastUpdated: new Date(),
  816. project_id: userId,
  817. owner_ref: userId,
  818. owner,
  819. id: '6328e14abec0df019fce0be5',
  820. lastUpdatedBy: owner,
  821. accessLevel: 'owner',
  822. source: 'owner',
  823. trashed: false,
  824. archived: false,
  825. },
  826. }
  827. )
  828. await waitFor(() => {
  829. const moreDropdown =
  830. within(actionsToolbar).getByText<HTMLElement>('More')
  831. fireEvent.click(moreDropdown)
  832. })
  833. const copyButton =
  834. within(actionsToolbar).getByText<HTMLButtonElement>('Make a copy')
  835. fireEvent.click(copyButton)
  836. // confirm in modal
  837. const copyConfirmButton = document.querySelector(
  838. 'button[type="submit"]'
  839. ) as HTMLElement
  840. fireEvent.click(copyConfirmButton)
  841. await fetchMock.callHistory.flush(true)
  842. expect(
  843. cloneProjectMock.callHistory.called(
  844. `/project/${projectsData[1].id}/clone`
  845. )
  846. ).to.be.true
  847. expect(sendMBSpy).to.have.been.calledThrice
  848. expect(sendMBSpy).to.have.been.calledWith('loads_v2_dash')
  849. expect(sendMBSpy).to.have.been.calledWith(
  850. 'project-list-page-interaction',
  851. {
  852. action: 'clone',
  853. page: '/',
  854. projectId: archiveableProject.id,
  855. isSmallDevice: true,
  856. }
  857. )
  858. await screen.findByText(copiedProjectName)
  859. })
  860. })
  861. })
  862. describe('projects list in actions modal', function () {
  863. let modal: HTMLElement
  864. let projectsToProcess: any[]
  865. function selectedProjectNames() {
  866. projectsToProcess = []
  867. // needs to be done ahead of opening modal
  868. allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
  869. // update list so we know which are checked
  870. const tableRows = screen.getAllByRole('row')
  871. for (const [index, checkbox] of allCheckboxes.entries()) {
  872. if (checkbox.checked) {
  873. const linkForProjectToCopy = within(tableRows[index]).getByRole(
  874. 'link'
  875. )
  876. const projectNameToCopy = linkForProjectToCopy.textContent
  877. projectsToProcess.push(projectNameToCopy)
  878. }
  879. }
  880. expect(projectsToProcess.length > 0).to.be.true
  881. }
  882. function selectedMatchesDisplayed(expectedLength: number) {
  883. selectedProjectNames()
  884. // any action will work for check since they all use the same modal
  885. const archiveButton = within(actionsToolbar).getByLabelText('Archive')
  886. fireEvent.click(archiveButton)
  887. modal = screen.getAllByRole('dialog')[0]
  888. const listitems = within(modal).getAllByRole('listitem')
  889. expect(listitems.length).to.equal(projectsToProcess.length)
  890. expect(listitems.length).to.equal(expectedLength)
  891. for (const projectName of projectsToProcess) {
  892. within(modal).getByText(projectName)
  893. }
  894. }
  895. beforeEach(function () {
  896. fetchMock.removeRoutes().clearHistory()
  897. allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
  898. // first one is the select all checkbox, just check 2 at first
  899. fireEvent.click(allCheckboxes[1])
  900. fireEvent.click(allCheckboxes[2])
  901. actionsToolbar = screen.getAllByRole('toolbar')[0]
  902. })
  903. it('opens the modal with the 2 originally selected projects', function () {
  904. selectedMatchesDisplayed(2)
  905. })
  906. it('shows correct list after closing modal, changing selection, and reopening modal', async function () {
  907. selectedMatchesDisplayed(2)
  908. const modal = screen.getAllByRole('dialog', { hidden: false })[0]
  909. const cancelButton = within(modal).getByRole('button', {
  910. name: 'Cancel',
  911. })
  912. fireEvent.click(cancelButton)
  913. await waitForElementToBeRemoved(
  914. screen.getByRole('dialog', { hidden: false })
  915. )
  916. await screen.findAllByRole('checkbox')
  917. fireEvent.click(allCheckboxes[3])
  918. selectedMatchesDisplayed(3)
  919. })
  920. it('maintains original list even after some have been processed', async function () {
  921. const totalProjectsToProcess = 2
  922. selectedMatchesDisplayed(totalProjectsToProcess)
  923. const button = screen.getByRole('button', { name: 'Confirm' })
  924. fireEvent.click(button)
  925. project1Id = allCheckboxes[1].getAttribute('data-project-id')
  926. fetchMock.post('express:/project/:id/archive', {
  927. status: 200,
  928. })
  929. fetchMock.post(`/project/${project2Id}/archive`, {
  930. status: 500,
  931. })
  932. await screen.findByRole('alert') // ensure that error was thrown for the 2nd project
  933. const listitems = within(modal).getAllByRole('listitem')
  934. expect(listitems.length).to.equal(totalProjectsToProcess)
  935. })
  936. })
  937. })
  938. describe('search', function () {
  939. it('shows only projects based on the input', async function () {
  940. const input = screen.getAllByRole('textbox', {
  941. name: /search in all projects/i,
  942. })[0]
  943. const value = currentList[0].name
  944. fireEvent.change(input, { target: { value } })
  945. const results = screen.getAllByRole('row')
  946. expect(results.length).to.equal(2) // first is header
  947. })
  948. })
  949. describe('copying project', function () {
  950. it('correctly updates the view after copying a shared project', async function () {
  951. const filterButton = screen.getAllByText('Shared with you')[0]
  952. fireEvent.click(filterButton)
  953. const tableRows = screen.getAllByRole('row')
  954. const linkForProjectToCopy = within(tableRows[1]).getByRole('link')
  955. const projectNameToCopy = linkForProjectToCopy.textContent
  956. const copiedProjectName = `${projectNameToCopy} Copy`
  957. fetchMock.post(`express:/project/:id/clone`, {
  958. status: 200,
  959. body: {
  960. name: copiedProjectName,
  961. lastUpdated: new Date(),
  962. project_id: userId,
  963. owner_ref: userId,
  964. owner,
  965. id: '6328e14abec0df019fce0be5',
  966. lastUpdatedBy: owner,
  967. accessLevel: 'owner',
  968. source: 'owner',
  969. trashed: false,
  970. archived: false,
  971. },
  972. })
  973. const copyButton = within(tableRows[1]).getAllByRole('button', {
  974. name: 'Copy',
  975. })[0]
  976. fireEvent.click(copyButton)
  977. // confirm in modal
  978. const copyConfirmButton = document.querySelector(
  979. 'button[type="submit"]'
  980. ) as HTMLElement
  981. fireEvent.click(copyConfirmButton)
  982. await fetchMock.callHistory.flush(true)
  983. expect(sendMBSpy).to.have.been.calledThrice
  984. expect(sendMBSpy).to.have.been.calledWith('loads_v2_dash')
  985. expect(sendMBSpy).to.have.been.calledWith(
  986. 'project-list-page-interaction',
  987. {
  988. action: 'clone',
  989. page: '/',
  990. projectId: archiveableProject.id,
  991. isSmallDevice: true,
  992. }
  993. )
  994. expect(screen.queryByText(copiedProjectName)).to.be.null
  995. const yourProjectFilter = screen.getAllByText('Your projects')[0]
  996. fireEvent.click(yourProjectFilter)
  997. await screen.findByText(copiedProjectName)
  998. })
  999. })
  1000. })
  1001. })