share-project-modal.test.jsx 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035
  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.restore()
  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.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.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.lastOptions()
  421. expect(JSON.parse(body)).to.deep.equal({ privilegeLevel: 'readAndWrite' })
  422. expect(fetchMock.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.lastUrl()
  455. expect(url).to.equal('/project/test-project/users/member-viewer')
  456. expect(fetchMock.done()).to.be.true
  457. })
  458. it('changes member privileges to owner with confirmation', async function () {
  459. fetchMock.postOnce('express:/project/:projectId/transfer-ownership', 204)
  460. const members = [
  461. {
  462. _id: 'member-viewer',
  463. email: 'member-viewer@example.com',
  464. privileges: 'readOnly',
  465. },
  466. ]
  467. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  468. scope: {
  469. project: {
  470. ...project,
  471. members,
  472. publicAccesLevel: 'tokenBased',
  473. },
  474. },
  475. })
  476. await screen.findByText('member-viewer@example.com')
  477. expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
  478. await changePrivilegeLevel(screen, {
  479. current: 'Viewer',
  480. next: 'Make owner',
  481. })
  482. screen.getByText((_, node) => {
  483. return (
  484. node.textContent ===
  485. 'Are you sure you want to make member-viewer@example.com the owner of Test Project?'
  486. )
  487. })
  488. const confirmButton = screen.getByRole('button', {
  489. name: 'Change owner',
  490. })
  491. fireEvent.click(confirmButton)
  492. await waitFor(() => expect(confirmButton.disabled).to.be.true)
  493. const { body } = fetchMock.lastOptions()
  494. expect(JSON.parse(body)).to.deep.equal({ user_id: 'member-viewer' })
  495. expect(fetchMock.done()).to.be.true
  496. })
  497. it('sends invites to input email addresses', async function () {
  498. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  499. scope: {
  500. project: {
  501. ...project,
  502. publicAccesLevel: 'tokenBased',
  503. },
  504. },
  505. })
  506. const [inputElement] = await screen.findAllByLabelText('Add people')
  507. // loading contacts
  508. await waitFor(() => {
  509. expect(fetchMock.called('express:/user/contacts')).to.be.true
  510. })
  511. // displaying a list of matching contacts
  512. inputElement.focus()
  513. fireEvent.change(inputElement, { target: { value: 'ptolemy' } })
  514. await screen.findByText(/ptolemy@example.com/)
  515. // sending invitations
  516. fetchMock.post('express:/project/:projectId/invite', (url, req) => {
  517. const data = JSON.parse(req.body)
  518. if (data.email === 'a@b.c') {
  519. return {
  520. status: 400,
  521. body: { errorReason: 'invalid_email' },
  522. }
  523. }
  524. return {
  525. status: 200,
  526. body: {
  527. invite: {
  528. ...data,
  529. _id: data.email,
  530. },
  531. },
  532. }
  533. })
  534. fireEvent.paste(inputElement, {
  535. clipboardData: {
  536. getData: () =>
  537. `test@example.com; foo@example.com
  538. bar@example.com, a@b.c`,
  539. },
  540. })
  541. const user = userEvent.setup()
  542. await user.click(screen.getByTestId('add-collaborator-select'))
  543. await user.click(screen.getByText('Viewer'))
  544. const submitButton = screen.getByRole('button', { name: 'Invite' })
  545. await userEvent.click(submitButton)
  546. let calls
  547. await waitFor(
  548. () => {
  549. calls = fetchMock.calls('express:/project/:projectId/invite')
  550. expect(calls).to.have.length(4)
  551. },
  552. { timeout: 5000 } // allow time for delay between each request
  553. )
  554. expect(calls[0][1].body).to.equal(
  555. JSON.stringify({ email: 'test@example.com', privileges: 'readOnly' })
  556. )
  557. expect(calls[1][1].body).to.equal(
  558. JSON.stringify({ email: 'foo@example.com', privileges: 'readOnly' })
  559. )
  560. expect(calls[2][1].body).to.equal(
  561. JSON.stringify({ email: 'bar@example.com', privileges: 'readOnly' })
  562. )
  563. expect(calls[3][1].body).to.equal(
  564. JSON.stringify({ email: 'a@b.c', privileges: 'readOnly' })
  565. )
  566. // error from the last invite
  567. screen.getByText('An email address is invalid')
  568. })
  569. it('displays a message when the collaborator limit is reached', async function () {
  570. fetchMock.post(
  571. '/event/paywall-prompt',
  572. {},
  573. { body: { 'paywall-type': 'project-sharing' } }
  574. )
  575. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  576. scope: {
  577. project: {
  578. ...project,
  579. publicAccesLevel: 'tokenBased',
  580. features: {
  581. collaborators: 0,
  582. compileGroup: 'standard',
  583. },
  584. },
  585. },
  586. })
  587. await screen.findByText('Add more editors')
  588. const user = userEvent.setup()
  589. await user.click(screen.getByTestId('add-collaborator-select'))
  590. const editorOption = screen.getByText('Editor').closest('button')
  591. const reviewerOption = screen.getByText('Reviewer').closest('button')
  592. const viewerOption = screen.getByText('Viewer').closest('button')
  593. expect(editorOption.classList.contains('disabled')).to.be.true
  594. expect(reviewerOption.classList.contains('disabled')).to.be.true
  595. expect(viewerOption.classList.contains('disabled')).to.be.false
  596. screen.getByText(
  597. /Upgrade to add more editors and access collaboration features like track changes and full project history/
  598. )
  599. })
  600. it('counts reviewers towards the collaborator limit', async function () {
  601. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  602. scope: {
  603. project: {
  604. ...project,
  605. features: {
  606. collaborators: 1,
  607. },
  608. members: [
  609. {
  610. _id: 'reviewer-id',
  611. email: 'reviewer@example.com',
  612. privileges: 'review',
  613. },
  614. ],
  615. },
  616. },
  617. })
  618. await screen.findByText('Add more editors')
  619. const user = userEvent.setup()
  620. await user.click(screen.getByTestId('add-collaborator-select'))
  621. const editorOption = screen.getByText('Editor').closest('button')
  622. const reviewerOption = screen.getByText('Reviewer').closest('button')
  623. const viewerOption = screen.getByText('Viewer').closest('button')
  624. expect(editorOption.classList.contains('disabled')).to.be.true
  625. expect(reviewerOption.classList.contains('disabled')).to.be.true
  626. expect(viewerOption.classList.contains('disabled')).to.be.false
  627. screen.getByText(
  628. /Upgrade to add more editors and access collaboration features like track changes and full project history/
  629. )
  630. })
  631. it('handles server error responses', async function () {
  632. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  633. scope: {
  634. project: {
  635. ...project,
  636. publicAccesLevel: 'tokenBased',
  637. },
  638. },
  639. })
  640. // loading contacts
  641. await waitFor(() => {
  642. expect(fetchMock.called('express:/user/contacts')).to.be.true
  643. })
  644. const [inputElement] = await screen.findAllByLabelText('Add people')
  645. const submitButton = screen.getByRole('button', { name: 'Invite' })
  646. const respondWithError = async function (errorReason) {
  647. fireEvent.focus(inputElement)
  648. fireEvent.change(inputElement, {
  649. target: { value: 'invited-author-1@example.com' },
  650. })
  651. fireEvent.blur(inputElement)
  652. fetchMock.postOnce(
  653. 'express:/project/:projectId/invite',
  654. {
  655. status: 400,
  656. body: { errorReason },
  657. },
  658. { overwriteRoutes: true }
  659. )
  660. expect(submitButton.disabled).to.be.false
  661. await userEvent.click(submitButton)
  662. await fetchMock.flush(true)
  663. expect(fetchMock.done()).to.be.true
  664. }
  665. await respondWithError('cannot_invite_non_user')
  666. await screen.findByText(
  667. `Can’t send invite. Recipient must already have an Overleaf account`
  668. )
  669. await respondWithError('cannot_verify_user_not_robot')
  670. await screen.findByText(
  671. `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.`
  672. )
  673. await respondWithError('cannot_invite_self')
  674. await screen.findByText(`Can’t send invite to yourself`)
  675. await respondWithError('invalid_email')
  676. await screen.findByText(`An email address is invalid`)
  677. await respondWithError('too_many_requests')
  678. await screen.findByText(
  679. `Too many requests were received in a short space of time. Please wait for a few moments and try again.`
  680. )
  681. })
  682. it('handles switching between access levels', async function () {
  683. fetchMock.post('express:/project/:projectId/settings/admin', 204)
  684. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  685. scope: {
  686. project: { ...project, publicAccesLevel: 'private' },
  687. },
  688. })
  689. await screen.findByText('Link sharing is off')
  690. const enableButton = await screen.findByRole('button', {
  691. name: 'Turn on link sharing',
  692. })
  693. fireEvent.click(enableButton)
  694. await waitFor(() => expect(enableButton.disabled).to.be.true)
  695. const { body: tokenBody } = fetchMock.lastOptions()
  696. expect(JSON.parse(tokenBody)).to.deep.equal({
  697. publicAccessLevel: 'tokenBased',
  698. })
  699. // NOTE: updating the scoped project data manually,
  700. // as the project data is usually updated via the websocket connection
  701. window.overleaf.unstable.store.set('project', {
  702. ...project,
  703. publicAccesLevel: 'tokenBased',
  704. })
  705. // watchCallbacks.project({ ...project, publicAccesLevel: 'tokenBased' })
  706. await screen.findByText('Link sharing is on')
  707. const disableButton = await screen.findByRole('button', {
  708. name: 'Turn off link sharing',
  709. })
  710. fireEvent.click(disableButton)
  711. await waitFor(() => expect(disableButton.disabled).to.be.true)
  712. const { body: privateBody } = fetchMock.lastOptions()
  713. expect(JSON.parse(privateBody)).to.deep.equal({
  714. publicAccessLevel: 'private',
  715. })
  716. // NOTE: updating the scoped project data manually,
  717. // as the project data is usually updated via the websocket connection
  718. window.overleaf.unstable.store.set('project', {
  719. ...project,
  720. publicAccesLevel: 'private',
  721. })
  722. // watchCallbacks.project({ ...project, publicAccesLevel: 'private' })
  723. await screen.findByText('Link sharing is off')
  724. })
  725. it('avoids selecting unmatched contact', async function () {
  726. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  727. scope: { project },
  728. })
  729. const [inputElement] = await screen.findAllByLabelText('Add people')
  730. // Wait for contacts to load
  731. await waitFor(() => {
  732. expect(fetchMock.called('express:/user/contacts')).to.be.true
  733. })
  734. // Enter a prefix that matches a contact
  735. inputElement.focus()
  736. fireEvent.change(inputElement, { target: { value: 'ptolemy' } })
  737. // The matching contact should now be present and selected
  738. await screen.findByRole('option', {
  739. name: `Claudius Ptolemy <ptolemy@example.com>`,
  740. selected: true,
  741. })
  742. // Keep entering text so the contact no longer matches
  743. fireEvent.change(inputElement, {
  744. target: { value: 'ptolemy.new@example.com' },
  745. })
  746. // The matching contact should no longer be present
  747. expect(
  748. screen.queryByRole('option', {
  749. name: `Claudius Ptolemy <ptolemy@example.com>`,
  750. })
  751. ).to.be.null
  752. // No items should be added yet
  753. expect(screen.queryByRole('button', { name: 'Remove' })).to.be.null
  754. // Pressing Tab should add the entered item
  755. fireEvent.keyDown(inputElement, { key: 'Tab', code: 'Tab' })
  756. await waitFor(() => {
  757. expect(screen.getAllByRole('button', { name: /Remove/ })).to.have.length(
  758. 1
  759. )
  760. })
  761. // Blurring the input should not add another contact
  762. fireEvent.blur(inputElement)
  763. await waitFor(() => {
  764. expect(screen.getAllByRole('button', { name: /Remove/ })).to.have.length(
  765. 1
  766. )
  767. })
  768. })
  769. it('selects contact by typing the entire email and blurring the input', async function () {
  770. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  771. scope: { project },
  772. })
  773. const [inputElement] = await screen.findAllByLabelText('Add people')
  774. // Wait for contacts to load
  775. await waitFor(() => {
  776. expect(fetchMock.called('express:/user/contacts')).to.be.true
  777. })
  778. // Enter a prefix that matches a contact
  779. await userEvent.type(inputElement, 'ptolemy@example.com')
  780. // The matching contact should now be present and selected
  781. await screen.findByRole('option', {
  782. name: `Claudius Ptolemy <ptolemy@example.com>`,
  783. selected: true,
  784. })
  785. // No items should be added yet
  786. expect(screen.queryByRole('button', { name: /Remove/ })).to.be.null
  787. // Click anywhere on the form to blur the input
  788. await userEvent.click(screen.getByRole('dialog'))
  789. // The contact should be added
  790. await waitFor(() => {
  791. expect(screen.getAllByRole('button', { name: /Remove/ })).to.have.length(
  792. 1
  793. )
  794. })
  795. })
  796. it('selects contact by typing a partial email and selecting the suggestion', async function () {
  797. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  798. scope: { project },
  799. })
  800. const [inputElement] = await screen.findAllByLabelText('Add people')
  801. // Wait for contacts to load
  802. await waitFor(() => {
  803. expect(fetchMock.called('express:/user/contacts')).to.be.true
  804. })
  805. // Enter a prefix that matches a contact
  806. await userEvent.type(inputElement, 'pto')
  807. // The matching contact should now be present and selected
  808. await userEvent.click(
  809. screen.getByRole('option', {
  810. name: `Claudius Ptolemy <ptolemy@example.com>`,
  811. selected: true,
  812. })
  813. )
  814. // Click anywhere on the form to blur the input
  815. await userEvent.click(screen.getByRole('dialog'))
  816. // The contact should be added
  817. await waitFor(() => {
  818. expect(screen.getAllByRole('button', { name: /Remove/ })).to.have.length(
  819. 1
  820. )
  821. })
  822. })
  823. it('allows an email address to be selected, removed, then re-added', async function () {
  824. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  825. scope: { project },
  826. })
  827. const [inputElement] = await screen.findAllByLabelText('Add people')
  828. // Wait for contacts to load
  829. await waitFor(() => {
  830. expect(fetchMock.called('express:/user/contacts')).to.be.true
  831. })
  832. // Enter a prefix that matches a contact
  833. await userEvent.type(inputElement, 'pto')
  834. // Select the suggested contact
  835. await userEvent.click(
  836. screen.getByRole('option', {
  837. name: `Claudius Ptolemy <ptolemy@example.com>`,
  838. selected: true,
  839. })
  840. )
  841. // Click anywhere on the form to blur the input
  842. await userEvent.click(screen.getByRole('dialog'))
  843. // Remove the just-added collaborator
  844. await userEvent.click(screen.getByRole('button', { name: /Remove/ }))
  845. // Remove button should now be gone
  846. expect(screen.queryByRole('button', { name: /Remove/ })).to.be.null
  847. // Add the same collaborator again
  848. await userEvent.type(inputElement, 'pto')
  849. // Click the suggested contact again
  850. await userEvent.click(
  851. screen.getByRole('option', {
  852. name: `Claudius Ptolemy <ptolemy@example.com>`,
  853. selected: true,
  854. })
  855. )
  856. // Click anywhere on the form to blur the input
  857. await userEvent.click(screen.getByRole('dialog'))
  858. // The contact should be added
  859. await waitFor(() => {
  860. expect(screen.getAllByRole('button', { name: /Remove/ })).to.have.length(
  861. 1
  862. )
  863. })
  864. })
  865. })