project-list-root.test.tsx 40 KB

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