share-project-modal.test.jsx 29 KB

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