share-project-modal.test.jsx 30 KB

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