project-list-root.test.tsx 40 KB

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