share-project-modal.test.jsx 28 KB

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