project-list-root.test.tsx 39 KB

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