share-project-modal.test.jsx 30 KB

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