share-project-modal.test.jsx 30 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051
  1. import { expect } from 'chai'
  2. import sinon from 'sinon'
  3. import { screen, fireEvent, render, waitFor } from '@testing-library/react'
  4. import fetchMock from 'fetch-mock'
  5. import userEvent from '@testing-library/user-event'
  6. import ShareProjectModal from '../../../../../frontend/js/features/share-project-modal/components/share-project-modal'
  7. import {
  8. renderWithEditorContext,
  9. cleanUpContext,
  10. } from '../../../helpers/render-with-context'
  11. import {
  12. EditorProviders,
  13. USER_EMAIL,
  14. USER_ID,
  15. } from '../../../helpers/editor-providers'
  16. import { location } from '@/shared/components/location'
  17. async function changePrivilegeLevel(screen, { current, next }) {
  18. const select = screen.getByDisplayValue(current)
  19. fireEvent.click(select)
  20. const option = screen.getByRole('option', {
  21. name: next,
  22. })
  23. fireEvent.click(option)
  24. }
  25. describe('<ShareProjectModal/>', function () {
  26. const project = {
  27. _id: 'test-project',
  28. name: 'Test Project',
  29. features: {
  30. collaborators: 10,
  31. compileGroup: 'standard',
  32. },
  33. owner: {
  34. _id: USER_ID,
  35. email: USER_EMAIL,
  36. },
  37. members: [],
  38. invites: [],
  39. }
  40. const contacts = [
  41. // user with edited name
  42. {
  43. type: 'user',
  44. email: 'test-user@example.com',
  45. first_name: 'Test',
  46. last_name: 'User',
  47. name: 'Test User',
  48. },
  49. // user with default name (email prefix)
  50. {
  51. type: 'user',
  52. email: 'test@example.com',
  53. first_name: 'test',
  54. },
  55. // no last name
  56. {
  57. type: 'user',
  58. first_name: 'Eratosthenes',
  59. email: 'eratosthenes@example.com',
  60. },
  61. // more users
  62. {
  63. type: 'user',
  64. first_name: 'Claudius',
  65. last_name: 'Ptolemy',
  66. email: 'ptolemy@example.com',
  67. },
  68. {
  69. type: 'user',
  70. first_name: 'Abd al-Rahman',
  71. last_name: 'Al-Sufi',
  72. email: 'al-sufi@example.com',
  73. },
  74. {
  75. type: 'user',
  76. first_name: 'Nicolaus',
  77. last_name: 'Copernicus',
  78. email: 'copernicus@example.com',
  79. },
  80. ]
  81. const modalProps = {
  82. show: true,
  83. handleHide: sinon.stub(),
  84. }
  85. beforeEach(function () {
  86. this.locationWrapperSandbox = sinon.createSandbox()
  87. this.locationWrapperStub = this.locationWrapperSandbox.stub(location)
  88. fetchMock.get('/user/contacts', { contacts })
  89. window.metaAttributesCache.set('ol-user', { allowedFreeTrial: true })
  90. window.metaAttributesCache.set('ol-showUpgradePrompt', true)
  91. window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
  92. })
  93. afterEach(function () {
  94. this.locationWrapperSandbox.restore()
  95. fetchMock.removeRoutes().clearHistory()
  96. cleanUpContext()
  97. })
  98. it('renders the modal', async function () {
  99. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  100. scope: { project },
  101. })
  102. await screen.findByText('Share Project')
  103. })
  104. it('calls handleHide when a Close button is pressed', async function () {
  105. const handleHide = sinon.stub()
  106. renderWithEditorContext(
  107. <ShareProjectModal {...modalProps} handleHide={handleHide} />,
  108. { scope: { project } }
  109. )
  110. const [headerCloseButton, footerCloseButton] = await screen.findAllByRole(
  111. 'button',
  112. { name: 'Close' }
  113. )
  114. await userEvent.click(headerCloseButton)
  115. await userEvent.click(footerCloseButton)
  116. expect(handleHide.callCount).to.equal(2)
  117. })
  118. it('handles access level "private"', async function () {
  119. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  120. scope: { project: { ...project, publicAccesLevel: 'private' } },
  121. })
  122. await screen.findByText('Link sharing is off')
  123. await screen.findByRole('button', { name: 'Turn on link sharing' })
  124. expect(screen.queryByText('Anyone with this link can view this project')).to
  125. .be.null
  126. expect(screen.queryByText('Anyone with this link can edit this project')).to
  127. .be.null
  128. })
  129. it('handles access level "tokenBased"', async function () {
  130. const tokens = {
  131. readAndWrite: '6862414195fwtbrtrdtskb',
  132. readAndWritePrefix: '6862414195',
  133. readOnly: 'wrnjfzkysqkr',
  134. readAndWriteHashPrefix: 'taEVki',
  135. readOnlyHashPrefix: 'j2xYbL',
  136. }
  137. fetchMock.get(`/project/${project._id}/tokens`, tokens)
  138. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  139. scope: { project: { ...project, publicAccesLevel: 'tokenBased' } },
  140. })
  141. await screen.findByText('Link sharing is on')
  142. await screen.findByRole('button', { name: 'Turn off link sharing' })
  143. expect(screen.queryByText('Anyone with this link can view this project'))
  144. .not.to.be.null
  145. expect(screen.queryByText('Anyone with this link can edit this project'))
  146. .not.to.be.null
  147. screen.getByText(
  148. `https://www.test-overleaf.com/${tokens.readAndWrite}#${tokens.readAndWriteHashPrefix}`
  149. )
  150. screen.getByText(
  151. `https://www.test-overleaf.com/read/${tokens.readOnly}#${tokens.readOnlyHashPrefix}`
  152. )
  153. })
  154. it('handles legacy access level "readAndWrite"', async function () {
  155. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  156. scope: { project: { ...project, publicAccesLevel: 'readAndWrite' } },
  157. })
  158. await screen.findByText(
  159. 'This project is public and can be edited by anyone with the URL.'
  160. )
  161. await screen.findByRole('button', { name: 'Make private' })
  162. })
  163. it('handles legacy access level "readOnly"', async function () {
  164. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  165. scope: { project: { ...project, publicAccesLevel: 'readOnly' } },
  166. })
  167. await screen.findByText(
  168. 'This project is public and can be viewed but not edited by anyone with the URL'
  169. )
  170. await screen.findByRole('button', { name: 'Make private' })
  171. })
  172. it('displays actions for project-owners', async function () {
  173. fetchMock.get(`/project/${project._id}/tokens`, {})
  174. const invites = [
  175. {
  176. _id: 'invited-author',
  177. email: 'invited-author@example.com',
  178. privileges: 'readAndWrite',
  179. },
  180. ]
  181. // render as project owner: actions should be present
  182. render(
  183. <EditorProviders
  184. scope={{
  185. project: {
  186. ...project,
  187. invites,
  188. publicAccesLevel: 'tokenBased',
  189. },
  190. }}
  191. >
  192. <ShareProjectModal {...modalProps} />
  193. </EditorProviders>
  194. )
  195. await screen.findByRole('button', { name: 'Turn off link sharing' })
  196. await screen.findByRole('button', { name: 'Resend' })
  197. })
  198. it('hides actions from non-project-owners when link sharing on', async function () {
  199. const invites = [
  200. {
  201. _id: 'invited-author',
  202. email: 'invited-author@example.com',
  203. privileges: 'readAndWrite',
  204. },
  205. ]
  206. render(
  207. <EditorProviders
  208. scope={{
  209. project: {
  210. ...project,
  211. invites,
  212. publicAccesLevel: 'tokenBased',
  213. },
  214. }}
  215. user={{
  216. id: 'non-project-owner',
  217. email: 'non-project-owner@example.com',
  218. }}
  219. >
  220. <ShareProjectModal {...modalProps} />
  221. </EditorProviders>
  222. )
  223. await screen.findByText(
  224. 'To change access permissions, please ask the project owner'
  225. )
  226. expect(screen.queryByRole('button', { name: 'Turn off link sharing' })).to
  227. .be.null
  228. expect(screen.queryByRole('button', { name: 'Turn on link sharing' })).to.be
  229. .null
  230. expect(screen.queryByRole('button', { name: 'Resend' })).to.be.null
  231. })
  232. it('hides actions from non-project-owners when link sharing off', async function () {
  233. const invites = [
  234. {
  235. _id: 'invited-author',
  236. email: 'invited-author@example.com',
  237. privileges: 'readAndWrite',
  238. },
  239. ]
  240. render(
  241. <EditorProviders
  242. scope={{
  243. project: {
  244. ...project,
  245. invites,
  246. publicAccesLevel: 'private',
  247. },
  248. }}
  249. user={{
  250. id: 'non-project-owner',
  251. email: 'non-project-owner@example.com',
  252. }}
  253. >
  254. <ShareProjectModal {...modalProps} />
  255. </EditorProviders>
  256. )
  257. await screen.findByText(
  258. 'To add more collaborators or turn on link sharing, please ask the project owner'
  259. )
  260. expect(screen.queryByRole('button', { name: 'Turn off link sharing' })).to
  261. .be.null
  262. expect(screen.queryByRole('button', { name: 'Turn on link sharing' })).to.be
  263. .null
  264. expect(screen.queryByRole('button', { name: 'Resend' })).to.be.null
  265. })
  266. it('only shows read-only token link to restricted token members', async function () {
  267. window.metaAttributesCache.set('ol-isRestrictedTokenMember', true)
  268. fetchMock.get(`/project/${project._id}/tokens`, {})
  269. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  270. isRestrictedTokenMember: true,
  271. scope: { project: { ...project, publicAccesLevel: 'tokenBased' } },
  272. })
  273. // no buttons
  274. expect(screen.queryByRole('button', { name: 'Turn on link sharing' })).to.be
  275. .null
  276. expect(screen.queryByRole('button', { name: 'Turn off link sharing' })).to
  277. .be.null
  278. // only read-only token link
  279. await screen.findByText('Anyone with this link can view this project')
  280. expect(screen.queryByText('Anyone with this link can edit this project')).to
  281. .be.null
  282. })
  283. it('displays project members and invites', async function () {
  284. const members = [
  285. {
  286. _id: 'member-author',
  287. email: 'member-author@example.com',
  288. privileges: 'readAndWrite',
  289. },
  290. {
  291. _id: 'member-viewer',
  292. email: 'member-viewer@example.com',
  293. privileges: 'readOnly',
  294. },
  295. ]
  296. const invites = [
  297. {
  298. _id: 'invited-author',
  299. email: 'invited-author@example.com',
  300. privileges: 'readAndWrite',
  301. },
  302. {
  303. _id: 'invited-viewer',
  304. email: 'invited-viewer@example.com',
  305. privileges: 'readOnly',
  306. },
  307. ]
  308. fetchMock.get(`/project/${project._id}/tokens`, {})
  309. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  310. scope: {
  311. project: {
  312. ...project,
  313. members,
  314. invites,
  315. publicAccesLevel: 'tokenBased',
  316. },
  317. },
  318. })
  319. const projectOwnerEmail = USER_EMAIL
  320. await screen.findByText(projectOwnerEmail)
  321. expect(screen.queryAllByText(projectOwnerEmail)).to.have.length(1)
  322. expect(screen.queryAllByText('member-author@example.com')).to.have.length(1)
  323. expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
  324. expect(screen.queryAllByText('invited-author@example.com')).to.have.length(
  325. 1
  326. )
  327. expect(screen.queryAllByText('invited-viewer@example.com')).to.have.length(
  328. 1
  329. )
  330. expect(screen.queryAllByText('Invite not yet accepted.')).to.have.length(
  331. invites.length
  332. )
  333. expect(screen.queryAllByRole('button', { name: 'Resend' })).to.have.length(
  334. invites.length
  335. )
  336. })
  337. it('resends an invite', async function () {
  338. fetchMock.get(`/project/${project._id}/tokens`, {})
  339. fetchMock.postOnce(
  340. 'express:/project/:projectId/invite/:inviteId/resend',
  341. 204
  342. )
  343. const invites = [
  344. {
  345. _id: 'invited-author',
  346. email: 'invited-author@example.com',
  347. privileges: 'readAndWrite',
  348. },
  349. ]
  350. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  351. scope: {
  352. project: {
  353. ...project,
  354. invites,
  355. publicAccesLevel: 'tokenBased',
  356. },
  357. },
  358. })
  359. const [, closeButton] = screen.getAllByRole('button', {
  360. name: 'Close',
  361. })
  362. const resendButton = screen.getByRole('button', { name: 'Resend' })
  363. fireEvent.click(resendButton)
  364. await waitFor(() => expect(closeButton.disabled).to.be.true)
  365. expect(fetchMock.callHistory.done()).to.be.true
  366. await waitFor(() => expect(closeButton.disabled).to.be.false)
  367. })
  368. it('revokes an invite', async function () {
  369. fetchMock.get(`/project/${project._id}/tokens`, {})
  370. fetchMock.deleteOnce('express:/project/:projectId/invite/:inviteId', 204)
  371. const invites = [
  372. {
  373. _id: 'invited-author',
  374. email: 'invited-author@example.com',
  375. privileges: 'readAndWrite',
  376. },
  377. ]
  378. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  379. scope: {
  380. project: {
  381. ...project,
  382. invites,
  383. publicAccesLevel: 'tokenBased',
  384. },
  385. },
  386. })
  387. const [, closeButton] = screen.getAllByRole('button', {
  388. name: 'Close',
  389. })
  390. const revokeButton = screen.getByRole('button', { name: 'Revoke' })
  391. fireEvent.click(revokeButton)
  392. await waitFor(() => expect(closeButton.disabled).to.be.true)
  393. expect(fetchMock.callHistory.done()).to.be.true
  394. await waitFor(() => expect(closeButton.disabled).to.be.false)
  395. })
  396. it('changes member privileges to read + write', async function () {
  397. fetchMock.get(`/project/${project._id}/tokens`, {})
  398. fetchMock.putOnce('express:/project/:projectId/users/:userId', 204)
  399. const members = [
  400. {
  401. _id: 'member-viewer',
  402. email: 'member-viewer@example.com',
  403. privileges: 'readOnly',
  404. },
  405. ]
  406. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  407. scope: {
  408. project: {
  409. ...project,
  410. members,
  411. publicAccesLevel: 'tokenBased',
  412. },
  413. },
  414. })
  415. const [, closeButton] = await screen.getAllByRole('button', {
  416. name: 'Close',
  417. })
  418. expect(
  419. await screen.findAllByText('member-viewer@example.com')
  420. ).to.have.length(1)
  421. await changePrivilegeLevel(screen, { current: 'Viewer', next: 'Editor' })
  422. await waitFor(() => expect(closeButton.disabled).to.be.true)
  423. const { body } = fetchMock.callHistory.calls().at(-1).options
  424. expect(JSON.parse(body)).to.deep.equal({ privilegeLevel: 'readAndWrite' })
  425. expect(fetchMock.callHistory.done()).to.be.true
  426. await waitFor(() => expect(closeButton.disabled).to.be.false)
  427. })
  428. it('removes a member from the project', async function () {
  429. fetchMock.get(`/project/${project._id}/tokens`, {})
  430. fetchMock.deleteOnce('express:/project/:projectId/users/:userId', 204)
  431. const members = [
  432. {
  433. _id: 'member-viewer',
  434. email: 'member-viewer@example.com',
  435. privileges: 'readOnly',
  436. },
  437. ]
  438. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  439. scope: {
  440. project: {
  441. ...project,
  442. members,
  443. publicAccesLevel: 'tokenBased',
  444. },
  445. },
  446. })
  447. expect(
  448. await screen.findAllByText('member-viewer@example.com')
  449. ).to.have.length(1)
  450. await changePrivilegeLevel(screen, {
  451. current: 'Viewer',
  452. next: 'Remove access',
  453. })
  454. const removeButton = screen.getByRole('button', {
  455. name: 'Change',
  456. })
  457. fireEvent.click(removeButton)
  458. const url = fetchMock.callHistory.calls().at(-1).url
  459. expect(url).to.equal(
  460. 'https://www.test-overleaf.com/project/test-project/users/member-viewer'
  461. )
  462. expect(fetchMock.callHistory.done()).to.be.true
  463. })
  464. it('changes member privileges to owner with confirmation', async function () {
  465. fetchMock.get(`/project/${project._id}/tokens`, {})
  466. fetchMock.postOnce('express:/project/:projectId/transfer-ownership', 204)
  467. const members = [
  468. {
  469. _id: 'member-viewer',
  470. email: 'member-viewer@example.com',
  471. privileges: 'readOnly',
  472. },
  473. ]
  474. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  475. scope: {
  476. project: {
  477. ...project,
  478. members,
  479. publicAccesLevel: 'tokenBased',
  480. },
  481. },
  482. })
  483. await screen.findByText('member-viewer@example.com')
  484. expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
  485. await changePrivilegeLevel(screen, {
  486. current: 'Viewer',
  487. next: 'Make owner',
  488. })
  489. screen.getByText((_, node) => {
  490. return (
  491. node.textContent ===
  492. 'Are you sure you want to make member-viewer@example.com the owner of Test Project?'
  493. )
  494. })
  495. const confirmButton = screen.getByRole('button', {
  496. name: 'Change owner',
  497. })
  498. fireEvent.click(confirmButton)
  499. await waitFor(() => expect(confirmButton.disabled).to.be.true)
  500. const { body } = fetchMock.callHistory.calls().at(-1).options
  501. expect(JSON.parse(body)).to.deep.equal({ user_id: 'member-viewer' })
  502. expect(fetchMock.callHistory.done()).to.be.true
  503. })
  504. it('sends invites to input email addresses', async function () {
  505. fetchMock.get(`/project/${project._id}/tokens`, {})
  506. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  507. scope: {
  508. project: {
  509. ...project,
  510. publicAccesLevel: 'tokenBased',
  511. },
  512. },
  513. })
  514. const [inputElement] = await screen.findAllByLabelText('Add people')
  515. // loading contacts
  516. await waitFor(() => {
  517. expect(fetchMock.callHistory.called('express:/user/contacts')).to.be.true
  518. })
  519. // displaying a list of matching contacts
  520. inputElement.focus()
  521. fireEvent.change(inputElement, { target: { value: 'ptolemy' } })
  522. await screen.findByText(/ptolemy@example.com/)
  523. // sending invitations
  524. fetchMock.post(
  525. 'express:/project/:projectId/invite',
  526. ({ args: [url, req] }) => {
  527. const data = JSON.parse(req.body)
  528. if (data.email === 'a@b.c') {
  529. return {
  530. status: 400,
  531. body: { errorReason: 'invalid_email' },
  532. }
  533. }
  534. return {
  535. status: 200,
  536. body: {
  537. invite: {
  538. ...data,
  539. _id: data.email,
  540. },
  541. },
  542. }
  543. }
  544. )
  545. fireEvent.paste(inputElement, {
  546. clipboardData: {
  547. getData: () =>
  548. `test@example.com; foo@example.com
  549. bar@example.com, a@b.c`,
  550. },
  551. })
  552. const user = userEvent.setup()
  553. await user.click(screen.getByTestId('add-collaborator-select'))
  554. await user.click(screen.getByText('Viewer'))
  555. const submitButton = screen.getByRole('button', { name: 'Invite' })
  556. await userEvent.click(submitButton)
  557. let calls
  558. await waitFor(
  559. () => {
  560. calls = fetchMock.callHistory.calls(
  561. 'express:/project/:projectId/invite'
  562. )
  563. expect(calls).to.have.length(4)
  564. },
  565. { timeout: 5000 } // allow time for delay between each request
  566. )
  567. expect(calls[0].args[1].body).to.equal(
  568. JSON.stringify({ email: 'test@example.com', privileges: 'readOnly' })
  569. )
  570. expect(calls[1].args[1].body).to.equal(
  571. JSON.stringify({ email: 'foo@example.com', privileges: 'readOnly' })
  572. )
  573. expect(calls[2].args[1].body).to.equal(
  574. JSON.stringify({ email: 'bar@example.com', privileges: 'readOnly' })
  575. )
  576. expect(calls[3].args[1].body).to.equal(
  577. JSON.stringify({ email: 'a@b.c', privileges: 'readOnly' })
  578. )
  579. // error from the last invite
  580. screen.getByText('An email address is invalid')
  581. })
  582. it('displays a message when the collaborator limit is reached', async function () {
  583. fetchMock.get(`/project/${project._id}/tokens`, {})
  584. fetchMock.post(
  585. '/event/paywall-prompt',
  586. {},
  587. { body: { 'paywall-type': 'project-sharing' } }
  588. )
  589. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  590. scope: {
  591. project: {
  592. ...project,
  593. publicAccesLevel: 'tokenBased',
  594. features: {
  595. collaborators: 0,
  596. compileGroup: 'standard',
  597. },
  598. },
  599. },
  600. })
  601. await screen.findByText('Add more collaborators')
  602. const user = userEvent.setup()
  603. await user.click(screen.getByTestId('add-collaborator-select'))
  604. const editorOption = screen.getByText('Editor').closest('button')
  605. const reviewerOption = screen.getByText('Reviewer').closest('button')
  606. const viewerOption = screen.getByText('Viewer').closest('button')
  607. expect(editorOption.classList.contains('disabled')).to.be.true
  608. expect(reviewerOption.classList.contains('disabled')).to.be.true
  609. expect(viewerOption.classList.contains('disabled')).to.be.false
  610. screen.getByText(
  611. /Upgrade to add more collaborators and access collaboration features like track changes and full project history/
  612. )
  613. })
  614. it('counts reviewers towards the collaborator limit', async function () {
  615. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  616. scope: {
  617. project: {
  618. ...project,
  619. features: {
  620. collaborators: 1,
  621. },
  622. members: [
  623. {
  624. _id: 'reviewer-id',
  625. email: 'reviewer@example.com',
  626. privileges: 'review',
  627. },
  628. ],
  629. },
  630. },
  631. })
  632. await screen.findByText('Add more collaborators')
  633. const user = userEvent.setup()
  634. await user.click(screen.getByTestId('add-collaborator-select'))
  635. const editorOption = screen.getByText('Editor').closest('button')
  636. const reviewerOption = screen.getByText('Reviewer').closest('button')
  637. const viewerOption = screen.getByText('Viewer').closest('button')
  638. expect(editorOption.classList.contains('disabled')).to.be.true
  639. expect(reviewerOption.classList.contains('disabled')).to.be.true
  640. expect(viewerOption.classList.contains('disabled')).to.be.false
  641. screen.getByText(
  642. /Upgrade to add more collaborators and access collaboration features like track changes and full project history/
  643. )
  644. })
  645. it('handles server error responses', async function () {
  646. fetchMock.get(`/project/${project._id}/tokens`, {})
  647. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  648. scope: {
  649. project: {
  650. ...project,
  651. publicAccesLevel: 'tokenBased',
  652. },
  653. },
  654. })
  655. // loading contacts
  656. await waitFor(() => {
  657. expect(fetchMock.callHistory.called('express:/user/contacts')).to.be.true
  658. })
  659. const [inputElement] = await screen.findAllByLabelText('Add people')
  660. const submitButton = screen.getByRole('button', { name: 'Invite' })
  661. const respondWithError = async function (errorReason) {
  662. fireEvent.focus(inputElement)
  663. fireEvent.change(inputElement, {
  664. target: { value: 'invited-author-1@example.com' },
  665. })
  666. fireEvent.blur(inputElement)
  667. fetchMock.postOnce('express:/project/:projectId/invite', {
  668. status: 400,
  669. body: { errorReason },
  670. })
  671. expect(submitButton.disabled).to.be.false
  672. await userEvent.click(submitButton)
  673. await fetchMock.callHistory.flush(true)
  674. expect(fetchMock.callHistory.done()).to.be.true
  675. }
  676. await respondWithError('cannot_invite_non_user')
  677. await screen.findByText(
  678. `Can’t send invite. Recipient must already have an Overleaf account`
  679. )
  680. await respondWithError('cannot_verify_user_not_robot')
  681. await screen.findByText(
  682. `Sorry, we could not verify that you are not a robot. Please check that Google reCAPTCHA is not being blocked by an ad blocker or firewall.`
  683. )
  684. await respondWithError('cannot_invite_self')
  685. await screen.findByText(`Can’t send invite to yourself`)
  686. await respondWithError('invalid_email')
  687. await screen.findByText(`An email address is invalid`)
  688. await respondWithError('too_many_requests')
  689. await screen.findByText(
  690. `Too many requests were received in a short space of time. Please wait for a few moments and try again.`
  691. )
  692. })
  693. it('handles switching between access levels', async function () {
  694. fetchMock.get(`/project/${project._id}/tokens`, {})
  695. fetchMock.post('express:/project/:projectId/settings/admin', 204)
  696. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  697. scope: {
  698. project: { ...project, publicAccesLevel: 'private' },
  699. },
  700. })
  701. await screen.findByText('Link sharing is off')
  702. const enableButton = await screen.findByRole('button', {
  703. name: 'Turn on link sharing',
  704. })
  705. fireEvent.click(enableButton)
  706. await waitFor(() => expect(enableButton.disabled).to.be.true)
  707. const { body: tokenBody } = fetchMock.callHistory.calls().at(-1).options
  708. expect(JSON.parse(tokenBody)).to.deep.equal({
  709. publicAccessLevel: 'tokenBased',
  710. })
  711. // NOTE: updating the scoped project data manually,
  712. // as the project data is usually updated via the websocket connection
  713. window.overleaf.unstable.store.set('project', {
  714. ...project,
  715. publicAccesLevel: 'tokenBased',
  716. })
  717. // watchCallbacks.project({ ...project, publicAccesLevel: 'tokenBased' })
  718. await screen.findByText('Link sharing is on')
  719. const disableButton = await screen.findByRole('button', {
  720. name: 'Turn off link sharing',
  721. })
  722. fireEvent.click(disableButton)
  723. await waitFor(() => expect(disableButton.disabled).to.be.true)
  724. const { body: privateBody } = fetchMock.callHistory.calls().at(-1).options
  725. expect(JSON.parse(privateBody)).to.deep.equal({
  726. publicAccessLevel: 'private',
  727. })
  728. // NOTE: updating the scoped project data manually,
  729. // as the project data is usually updated via the websocket connection
  730. window.overleaf.unstable.store.set('project', {
  731. ...project,
  732. publicAccesLevel: 'private',
  733. })
  734. // watchCallbacks.project({ ...project, publicAccesLevel: 'private' })
  735. await screen.findByText('Link sharing is off')
  736. })
  737. it('avoids selecting unmatched contact', async function () {
  738. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  739. scope: { project },
  740. })
  741. const [inputElement] = await screen.findAllByLabelText('Add people')
  742. // Wait for contacts to load
  743. await waitFor(() => {
  744. expect(fetchMock.callHistory.called('express:/user/contacts')).to.be.true
  745. })
  746. // Enter a prefix that matches a contact
  747. inputElement.focus()
  748. fireEvent.change(inputElement, { target: { value: 'ptolemy' } })
  749. // The matching contact should now be present and selected
  750. await screen.findByRole('option', {
  751. name: `Claudius Ptolemy <ptolemy@example.com>`,
  752. selected: true,
  753. })
  754. // Keep entering text so the contact no longer matches
  755. fireEvent.change(inputElement, {
  756. target: { value: 'ptolemy.new@example.com' },
  757. })
  758. // The matching contact should no longer be present
  759. expect(
  760. screen.queryByRole('option', {
  761. name: `Claudius Ptolemy <ptolemy@example.com>`,
  762. })
  763. ).to.be.null
  764. // No items should be added yet
  765. expect(screen.queryByRole('button', { name: 'Remove' })).to.be.null
  766. // Pressing Tab should add the entered item
  767. fireEvent.keyDown(inputElement, { key: 'Tab', code: 'Tab' })
  768. await waitFor(() => {
  769. expect(screen.getAllByRole('button', { name: /Remove/ })).to.have.length(
  770. 1
  771. )
  772. })
  773. // Blurring the input should not add another contact
  774. fireEvent.blur(inputElement)
  775. await waitFor(() => {
  776. expect(screen.getAllByRole('button', { name: /Remove/ })).to.have.length(
  777. 1
  778. )
  779. })
  780. })
  781. it('selects contact by typing the entire email and blurring the input', async function () {
  782. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  783. scope: { project },
  784. })
  785. const [inputElement] = await screen.findAllByLabelText('Add people')
  786. // Wait for contacts to load
  787. await waitFor(() => {
  788. expect(fetchMock.callHistory.called('express:/user/contacts')).to.be.true
  789. })
  790. // Enter a prefix that matches a contact
  791. await userEvent.type(inputElement, 'ptolemy@example.com')
  792. // The matching contact should now be present and selected
  793. await screen.findByRole('option', {
  794. name: `Claudius Ptolemy <ptolemy@example.com>`,
  795. selected: true,
  796. })
  797. // No items should be added yet
  798. expect(screen.queryByRole('button', { name: /Remove/ })).to.be.null
  799. // Click anywhere on the form to blur the input
  800. await userEvent.click(screen.getByRole('dialog'))
  801. // The contact should be added
  802. await waitFor(() => {
  803. expect(screen.getAllByRole('button', { name: /Remove/ })).to.have.length(
  804. 1
  805. )
  806. })
  807. })
  808. it('selects contact by typing a partial email and selecting the suggestion', async function () {
  809. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  810. scope: { project },
  811. })
  812. const [inputElement] = await screen.findAllByLabelText('Add people')
  813. // Wait for contacts to load
  814. await waitFor(() => {
  815. expect(fetchMock.callHistory.called('express:/user/contacts')).to.be.true
  816. })
  817. // Enter a prefix that matches a contact
  818. await userEvent.type(inputElement, 'pto')
  819. // The matching contact should now be present and selected
  820. await userEvent.click(
  821. screen.getByRole('option', {
  822. name: `Claudius Ptolemy <ptolemy@example.com>`,
  823. selected: true,
  824. })
  825. )
  826. // Click anywhere on the form to blur the input
  827. await userEvent.click(screen.getByRole('dialog'))
  828. // The contact should be added
  829. await waitFor(() => {
  830. expect(screen.getAllByRole('button', { name: /Remove/ })).to.have.length(
  831. 1
  832. )
  833. })
  834. })
  835. it('allows an email address to be selected, removed, then re-added', async function () {
  836. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  837. scope: { project },
  838. })
  839. const [inputElement] = await screen.findAllByLabelText('Add people')
  840. // Wait for contacts to load
  841. await waitFor(() => {
  842. expect(fetchMock.callHistory.called('express:/user/contacts')).to.be.true
  843. })
  844. // Enter a prefix that matches a contact
  845. await userEvent.type(inputElement, 'pto')
  846. // Select the suggested contact
  847. await userEvent.click(
  848. screen.getByRole('option', {
  849. name: `Claudius Ptolemy <ptolemy@example.com>`,
  850. selected: true,
  851. })
  852. )
  853. // Click anywhere on the form to blur the input
  854. await userEvent.click(screen.getByRole('dialog'))
  855. // Remove the just-added collaborator
  856. await userEvent.click(screen.getByRole('button', { name: /Remove/ }))
  857. // Remove button should now be gone
  858. expect(screen.queryByRole('button', { name: /Remove/ })).to.be.null
  859. // Add the same collaborator again
  860. await userEvent.type(inputElement, 'pto')
  861. // Click the suggested contact again
  862. await userEvent.click(
  863. screen.getByRole('option', {
  864. name: `Claudius Ptolemy <ptolemy@example.com>`,
  865. selected: true,
  866. })
  867. )
  868. // Click anywhere on the form to blur the input
  869. await userEvent.click(screen.getByRole('dialog'))
  870. // The contact should be added
  871. await waitFor(() => {
  872. expect(screen.getAllByRole('button', { name: /Remove/ })).to.have.length(
  873. 1
  874. )
  875. })
  876. })
  877. })