share-project-modal.test.jsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894
  1. import { expect } from 'chai'
  2. import sinon from 'sinon'
  3. import {
  4. screen,
  5. fireEvent,
  6. render,
  7. waitFor,
  8. waitForElementToBeRemoved,
  9. } from '@testing-library/react'
  10. import fetchMock from 'fetch-mock'
  11. import userEvent from '@testing-library/user-event'
  12. import ShareProjectModal from '../../../../../frontend/js/features/share-project-modal/components/share-project-modal'
  13. import {
  14. renderWithEditorContext,
  15. cleanUpContext,
  16. } from '../../../helpers/render-with-context'
  17. import {
  18. EditorProviders,
  19. USER_EMAIL,
  20. USER_ID,
  21. } from '../../../helpers/editor-providers'
  22. import * as useLocationModule from '../../../../../frontend/js/shared/hooks/use-location'
  23. describe('<ShareProjectModal/>', function () {
  24. const project = {
  25. _id: 'test-project',
  26. name: 'Test Project',
  27. features: {
  28. collaborators: 10,
  29. compileGroup: 'standard',
  30. },
  31. owner: {
  32. _id: USER_ID,
  33. email: USER_EMAIL,
  34. },
  35. members: [],
  36. invites: [],
  37. }
  38. const contacts = [
  39. // user with edited name
  40. {
  41. type: 'user',
  42. email: 'test-user@example.com',
  43. first_name: 'Test',
  44. last_name: 'User',
  45. name: 'Test User',
  46. },
  47. // user with default name (email prefix)
  48. {
  49. type: 'user',
  50. email: 'test@example.com',
  51. first_name: 'test',
  52. },
  53. // no last name
  54. {
  55. type: 'user',
  56. first_name: 'Eratosthenes',
  57. email: 'eratosthenes@example.com',
  58. },
  59. // more users
  60. {
  61. type: 'user',
  62. first_name: 'Claudius',
  63. last_name: 'Ptolemy',
  64. email: 'ptolemy@example.com',
  65. },
  66. {
  67. type: 'user',
  68. first_name: 'Abd al-Rahman',
  69. last_name: 'Al-Sufi',
  70. email: 'al-sufi@example.com',
  71. },
  72. {
  73. type: 'user',
  74. first_name: 'Nicolaus',
  75. last_name: 'Copernicus',
  76. email: 'copernicus@example.com',
  77. },
  78. ]
  79. const modalProps = {
  80. show: true,
  81. handleHide: sinon.stub(),
  82. }
  83. beforeEach(function () {
  84. this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
  85. assign: sinon.stub(),
  86. reload: sinon.stub(),
  87. })
  88. fetchMock.get('/user/contacts', { contacts })
  89. window.metaAttributesCache = new Map()
  90. window.metaAttributesCache.set('ol-user', { allowedFreeTrial: true })
  91. window.metaAttributesCache.set('ol-showUpgradePrompt', true)
  92. })
  93. afterEach(function () {
  94. this.locationStub.restore()
  95. fetchMock.restore()
  96. cleanUpContext()
  97. window.metaAttributesCache = new Map()
  98. })
  99. it('renders the modal', async function () {
  100. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  101. scope: { project },
  102. })
  103. await screen.findByText('Share Project')
  104. })
  105. it('calls handleHide when a Close button is pressed', async function () {
  106. const handleHide = sinon.stub()
  107. renderWithEditorContext(
  108. <ShareProjectModal {...modalProps} handleHide={handleHide} />,
  109. { scope: { project } }
  110. )
  111. const [headerCloseButton, footerCloseButton] = await screen.findAllByRole(
  112. 'button',
  113. { name: 'Close' }
  114. )
  115. await userEvent.click(headerCloseButton)
  116. await userEvent.click(footerCloseButton)
  117. expect(handleHide.callCount).to.equal(2)
  118. })
  119. it('handles access level "private"', async function () {
  120. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  121. scope: { project: { ...project, publicAccesLevel: 'private' } },
  122. })
  123. await screen.findByText(
  124. 'Link sharing is off, only invited users can view this project.'
  125. )
  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('hides actions from non-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. // render as non-project-owner, link sharing on: actions should be missing and message should be present
  200. render(
  201. <EditorProviders
  202. scope={{
  203. project: {
  204. ...project,
  205. invites,
  206. publicAccesLevel: 'tokenBased',
  207. },
  208. }}
  209. user={{
  210. id: 'non-project-owner',
  211. email: 'non-project-owner@example.com',
  212. }}
  213. >
  214. <ShareProjectModal {...modalProps} />
  215. </EditorProviders>
  216. )
  217. await screen.findByText(
  218. 'To change access permissions, please ask the project owner'
  219. )
  220. expect(screen.queryByRole('button', { name: 'Turn off link sharing' })).to
  221. .be.null
  222. expect(screen.queryByRole('button', { name: 'Turn on link sharing' })).to.be
  223. .null
  224. expect(screen.queryByRole('button', { name: 'Resend' })).to.be.null
  225. // render as non-project-owner, link sharing off: actions should be missing and message should be present
  226. render(
  227. <EditorProviders
  228. scope={{
  229. project: {
  230. ...project,
  231. invites,
  232. publicAccesLevel: 'private',
  233. },
  234. }}
  235. user={{
  236. id: 'non-project-owner',
  237. email: 'non-project-owner@example.com',
  238. }}
  239. >
  240. <ShareProjectModal {...modalProps} />
  241. </EditorProviders>
  242. )
  243. await screen.findByText(
  244. 'To add more collaborators or turn on link sharing, please ask the project owner'
  245. )
  246. expect(screen.queryByRole('button', { name: 'Turn off link sharing' })).to
  247. .be.null
  248. expect(screen.queryByRole('button', { name: 'Turn on link sharing' })).to.be
  249. .null
  250. expect(screen.queryByRole('button', { name: 'Resend' })).to.be.null
  251. })
  252. it('only shows read-only token link to restricted token members', async function () {
  253. window.metaAttributesCache.set('ol-isRestrictedTokenMember', true)
  254. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  255. isRestrictedTokenMember: true,
  256. scope: { project: { ...project, publicAccesLevel: 'tokenBased' } },
  257. })
  258. // no buttons
  259. expect(screen.queryByRole('button', { name: 'Turn on link sharing' })).to.be
  260. .null
  261. expect(screen.queryByRole('button', { name: 'Turn off link sharing' })).to
  262. .be.null
  263. // only read-only token link
  264. await screen.findByText('Anyone with this link can view this project')
  265. expect(screen.queryByText('Anyone with this link can edit this project')).to
  266. .be.null
  267. })
  268. it('displays project members and invites', async function () {
  269. const members = [
  270. {
  271. _id: 'member-author',
  272. email: 'member-author@example.com',
  273. privileges: 'readAndWrite',
  274. },
  275. {
  276. _id: 'member-viewer',
  277. email: 'member-viewer@example.com',
  278. privileges: 'readOnly',
  279. },
  280. ]
  281. const invites = [
  282. {
  283. _id: 'invited-author',
  284. email: 'invited-author@example.com',
  285. privileges: 'readAndWrite',
  286. },
  287. {
  288. _id: 'invited-viewer',
  289. email: 'invited-viewer@example.com',
  290. privileges: 'readOnly',
  291. },
  292. ]
  293. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  294. scope: {
  295. project: {
  296. ...project,
  297. members,
  298. invites,
  299. publicAccesLevel: 'tokenBased',
  300. },
  301. },
  302. })
  303. const projectOwnerEmail = USER_EMAIL
  304. expect(screen.queryAllByText(projectOwnerEmail)).to.have.length(1)
  305. expect(screen.queryAllByText('member-author@example.com')).to.have.length(1)
  306. expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
  307. expect(screen.queryAllByText('invited-author@example.com')).to.have.length(
  308. 1
  309. )
  310. expect(screen.queryAllByText('invited-viewer@example.com')).to.have.length(
  311. 1
  312. )
  313. expect(screen.queryAllByText('Invite not yet accepted.')).to.have.length(
  314. invites.length
  315. )
  316. expect(screen.queryAllByRole('button', { name: 'Resend' })).to.have.length(
  317. invites.length
  318. )
  319. })
  320. it('resends an invite', async function () {
  321. fetchMock.postOnce(
  322. 'express:/project/:projectId/invite/:inviteId/resend',
  323. 204
  324. )
  325. const invites = [
  326. {
  327. _id: 'invited-author',
  328. email: 'invited-author@example.com',
  329. privileges: 'readAndWrite',
  330. },
  331. ]
  332. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  333. scope: {
  334. project: {
  335. ...project,
  336. invites,
  337. publicAccesLevel: 'tokenBased',
  338. },
  339. },
  340. })
  341. const [, closeButton] = screen.getAllByRole('button', {
  342. name: 'Close',
  343. })
  344. const resendButton = screen.getByRole('button', { name: 'Resend' })
  345. fireEvent.click(resendButton)
  346. await waitFor(() => expect(closeButton.disabled).to.be.true)
  347. expect(fetchMock.done()).to.be.true
  348. expect(closeButton.disabled).to.be.false
  349. })
  350. it('revokes an invite', async function () {
  351. fetchMock.deleteOnce('express:/project/:projectId/invite/:inviteId', 204)
  352. const invites = [
  353. {
  354. _id: 'invited-author',
  355. email: 'invited-author@example.com',
  356. privileges: 'readAndWrite',
  357. },
  358. ]
  359. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  360. scope: {
  361. project: {
  362. ...project,
  363. invites,
  364. publicAccesLevel: 'tokenBased',
  365. },
  366. },
  367. })
  368. const [, closeButton] = screen.getAllByRole('button', {
  369. name: 'Close',
  370. })
  371. const revokeButton = screen.getByRole('button', { name: 'Revoke' })
  372. fireEvent.click(revokeButton)
  373. await waitFor(() => expect(closeButton.disabled).to.be.true)
  374. expect(fetchMock.done()).to.be.true
  375. expect(closeButton.disabled).to.be.false
  376. })
  377. it('changes member privileges to read + write', async function () {
  378. fetchMock.putOnce('express:/project/:projectId/users/:userId', 204)
  379. const members = [
  380. {
  381. _id: 'member-viewer',
  382. email: 'member-viewer@example.com',
  383. privileges: 'readOnly',
  384. },
  385. ]
  386. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  387. scope: {
  388. project: {
  389. ...project,
  390. members,
  391. publicAccesLevel: 'tokenBased',
  392. },
  393. },
  394. })
  395. const [, closeButton] = await screen.getAllByRole('button', {
  396. name: 'Close',
  397. })
  398. expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
  399. const select = screen.getByDisplayValue('Read Only')
  400. await fireEvent.change(select, { target: { value: 'readAndWrite' } })
  401. const changeButton = screen.getByRole('button', { name: 'Change' })
  402. fireEvent.click(changeButton)
  403. await waitFor(() => expect(closeButton.disabled).to.be.true)
  404. const { body } = fetchMock.lastOptions()
  405. expect(JSON.parse(body)).to.deep.equal({ privilegeLevel: 'readAndWrite' })
  406. expect(fetchMock.done()).to.be.true
  407. expect(closeButton.disabled).to.be.false
  408. })
  409. it('removes a member from the project', async function () {
  410. fetchMock.deleteOnce('express:/project/:projectId/users/:userId', 204)
  411. const members = [
  412. {
  413. _id: 'member-viewer',
  414. email: 'member-viewer@example.com',
  415. privileges: 'readOnly',
  416. },
  417. ]
  418. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  419. scope: {
  420. project: {
  421. ...project,
  422. members,
  423. publicAccesLevel: 'tokenBased',
  424. },
  425. },
  426. })
  427. expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
  428. const removeButton = screen.getByRole('button', {
  429. name: 'Remove collaborator',
  430. })
  431. fireEvent.click(removeButton)
  432. const url = fetchMock.lastUrl()
  433. expect(url).to.equal('/project/test-project/users/member-viewer')
  434. expect(fetchMock.done()).to.be.true
  435. await waitForElementToBeRemoved(() =>
  436. screen.queryByText('member-viewer@example.com')
  437. )
  438. })
  439. it('changes member privileges to owner with confirmation', async function () {
  440. fetchMock.postOnce('express:/project/:projectId/transfer-ownership', 204)
  441. const members = [
  442. {
  443. _id: 'member-viewer',
  444. email: 'member-viewer@example.com',
  445. privileges: 'readOnly',
  446. },
  447. ]
  448. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  449. scope: {
  450. project: {
  451. ...project,
  452. members,
  453. publicAccesLevel: 'tokenBased',
  454. },
  455. },
  456. })
  457. expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
  458. const select = screen.getByDisplayValue('Read Only')
  459. fireEvent.change(select, { target: { value: 'owner' } })
  460. const changeButton = screen.getByRole('button', { name: 'Change' })
  461. await fireEvent.click(changeButton)
  462. screen.getByText((_, node) => {
  463. return (
  464. node.textContent ===
  465. 'Are you sure you want to make member-viewer@example.com the owner of Test Project?'
  466. )
  467. })
  468. const confirmButton = screen.getByRole('button', {
  469. name: 'Change owner',
  470. })
  471. fireEvent.click(confirmButton)
  472. await waitFor(() => expect(confirmButton.disabled).to.be.true)
  473. const { body } = fetchMock.lastOptions()
  474. expect(JSON.parse(body)).to.deep.equal({ user_id: 'member-viewer' })
  475. expect(fetchMock.done()).to.be.true
  476. })
  477. it('sends invites to input email addresses', async function () {
  478. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  479. scope: {
  480. project: {
  481. ...project,
  482. publicAccesLevel: 'tokenBased',
  483. },
  484. },
  485. })
  486. const [inputElement] = await screen.findAllByLabelText(
  487. 'Share with your collaborators'
  488. )
  489. // loading contacts
  490. await waitFor(() => {
  491. expect(fetchMock.called('express:/user/contacts')).to.be.true
  492. })
  493. // displaying a list of matching contacts
  494. inputElement.focus()
  495. fireEvent.change(inputElement, { target: { value: 'ptolemy' } })
  496. await screen.findByText(/ptolemy@example.com/)
  497. // sending invitations
  498. fetchMock.post('express:/project/:projectId/invite', (url, req) => {
  499. const data = JSON.parse(req.body)
  500. if (data.email === 'a@b.c') {
  501. return {
  502. status: 400,
  503. body: { errorReason: 'invalid_email' },
  504. }
  505. }
  506. return {
  507. status: 200,
  508. body: {
  509. invite: {
  510. ...data,
  511. _id: data.email,
  512. },
  513. },
  514. }
  515. })
  516. fireEvent.paste(inputElement, {
  517. clipboardData: {
  518. getData: () =>
  519. `test@example.com; foo@example.com
  520. bar@example.com, a@b.c`,
  521. },
  522. })
  523. const privilegesElement = screen.getByDisplayValue('Can Edit')
  524. fireEvent.change(privilegesElement, { target: { value: 'readOnly' } })
  525. const submitButton = screen.getByRole('button', { name: 'Share' })
  526. await userEvent.click(submitButton)
  527. let calls
  528. await waitFor(
  529. () => {
  530. calls = fetchMock.calls('express:/project/:projectId/invite')
  531. expect(calls).to.have.length(4)
  532. },
  533. { timeout: 5000 } // allow time for delay between each request
  534. )
  535. expect(calls[0][1].body).to.equal(
  536. JSON.stringify({ email: 'test@example.com', privileges: 'readOnly' })
  537. )
  538. expect(calls[1][1].body).to.equal(
  539. JSON.stringify({ email: 'foo@example.com', privileges: 'readOnly' })
  540. )
  541. expect(calls[2][1].body).to.equal(
  542. JSON.stringify({ email: 'bar@example.com', privileges: 'readOnly' })
  543. )
  544. expect(calls[3][1].body).to.equal(
  545. JSON.stringify({ email: 'a@b.c', privileges: 'readOnly' })
  546. )
  547. // error from the last invite
  548. screen.getByText('An email address is invalid')
  549. })
  550. it('displays a message when the collaborator limit is reached', async function () {
  551. fetchMock.post(
  552. '/event/paywall-prompt',
  553. {},
  554. { body: { 'paywall-type': 'project-sharing' } }
  555. )
  556. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  557. scope: {
  558. project: {
  559. ...project,
  560. publicAccesLevel: 'tokenBased',
  561. features: {
  562. collaborators: 0,
  563. compileGroup: 'standard',
  564. },
  565. },
  566. },
  567. })
  568. expect(screen.queryByLabelText('Share with your collaborators')).to.be.null
  569. screen.getByText(
  570. /You need to upgrade your account to add more collaborators/
  571. )
  572. })
  573. it('handles server error responses', async function () {
  574. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  575. scope: {
  576. project: {
  577. ...project,
  578. publicAccesLevel: 'tokenBased',
  579. },
  580. },
  581. })
  582. // loading contacts
  583. await waitFor(() => {
  584. expect(fetchMock.called('express:/user/contacts')).to.be.true
  585. })
  586. const [inputElement] = await screen.findAllByLabelText(
  587. 'Share with your collaborators'
  588. )
  589. const submitButton = screen.getByRole('button', { name: 'Share' })
  590. const respondWithError = async function (errorReason) {
  591. fireEvent.focus(inputElement)
  592. fireEvent.change(inputElement, {
  593. target: { value: 'invited-author-1@example.com' },
  594. })
  595. fireEvent.blur(inputElement)
  596. fetchMock.postOnce(
  597. 'express:/project/:projectId/invite',
  598. {
  599. status: 400,
  600. body: { errorReason },
  601. },
  602. { overwriteRoutes: true }
  603. )
  604. expect(submitButton.disabled).to.be.false
  605. await userEvent.click(submitButton)
  606. await fetchMock.flush(true)
  607. expect(fetchMock.done()).to.be.true
  608. }
  609. await respondWithError('cannot_invite_non_user')
  610. await screen.findByText(
  611. `Can’t send invite. Recipient must already have an Overleaf account`
  612. )
  613. await respondWithError('cannot_verify_user_not_robot')
  614. await screen.findByText(
  615. `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.`
  616. )
  617. await respondWithError('cannot_invite_self')
  618. await screen.findByText(`Can’t send invite to yourself`)
  619. await respondWithError('invalid_email')
  620. await screen.findByText(`An email address is invalid`)
  621. await respondWithError('too_many_requests')
  622. await screen.findByText(
  623. `Too many requests were received in a short space of time. Please wait for a few moments and try again.`
  624. )
  625. })
  626. it('handles switching between access levels', async function () {
  627. fetchMock.post('express:/project/:projectId/settings/admin', 204)
  628. const watchCallbacks = {}
  629. const scopeWithProject = project => {
  630. return {
  631. $watch: (path, callback) => {
  632. watchCallbacks[path] = callback
  633. return () => {}
  634. },
  635. project,
  636. }
  637. }
  638. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  639. scope: scopeWithProject({ ...project, publicAccesLevel: 'private' }),
  640. })
  641. await screen.findByText(
  642. 'Link sharing is off, only invited users can view this project.'
  643. )
  644. const enableButton = await screen.findByRole('button', {
  645. name: 'Turn on link sharing',
  646. })
  647. fireEvent.click(enableButton)
  648. await waitFor(() => expect(enableButton.disabled).to.be.true)
  649. const { body: tokenBody } = fetchMock.lastOptions()
  650. expect(JSON.parse(tokenBody)).to.deep.equal({
  651. publicAccessLevel: 'tokenBased',
  652. })
  653. // NOTE: updating the scoped project data manually,
  654. // as the project data is usually updated via the websocket connection
  655. watchCallbacks.project({ ...project, publicAccesLevel: 'tokenBased' })
  656. await screen.findByText('Link sharing is on')
  657. const disableButton = await screen.findByRole('button', {
  658. name: 'Turn off link sharing',
  659. })
  660. fireEvent.click(disableButton)
  661. await waitFor(() => expect(disableButton.disabled).to.be.true)
  662. const { body: privateBody } = fetchMock.lastOptions()
  663. expect(JSON.parse(privateBody)).to.deep.equal({
  664. publicAccessLevel: 'private',
  665. })
  666. // NOTE: updating the scoped project data manually,
  667. // as the project data is usually updated via the websocket connection
  668. watchCallbacks.project({ ...project, publicAccesLevel: 'private' })
  669. await screen.findByText(
  670. 'Link sharing is off, only invited users can view this project.'
  671. )
  672. })
  673. it('avoids selecting unmatched contact', async function () {
  674. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  675. scope: { project },
  676. })
  677. const [inputElement] = await screen.findAllByLabelText(
  678. 'Share with your collaborators'
  679. )
  680. // Wait for contacts to load
  681. await waitFor(() => {
  682. expect(fetchMock.called('express:/user/contacts')).to.be.true
  683. })
  684. // Enter a prefix that matches a contact
  685. inputElement.focus()
  686. fireEvent.change(inputElement, { target: { value: 'ptolemy' } })
  687. // The matching contact should now be present and selected
  688. await screen.findByRole('option', {
  689. name: `Claudius Ptolemy <ptolemy@example.com>`,
  690. selected: true,
  691. })
  692. // Keep entering text so the contact no longer matches
  693. fireEvent.change(inputElement, {
  694. target: { value: 'ptolemy.new@example.com' },
  695. })
  696. // The matching contact should no longer be present
  697. expect(
  698. screen.queryByRole('option', {
  699. name: `Claudius Ptolemy <ptolemy@example.com>`,
  700. })
  701. ).to.be.null
  702. // No items should be added yet
  703. expect(screen.queryByRole('button', { name: 'Remove' })).to.be.null
  704. // Pressing Tab should add the entered item
  705. fireEvent.keyDown(inputElement, { key: 'Tab', code: 'Tab' })
  706. await waitFor(() => {
  707. expect(screen.getAllByRole('button', { name: 'Remove' })).to.have.length(
  708. 1
  709. )
  710. })
  711. // Blurring the input should not add another contact
  712. fireEvent.blur(inputElement)
  713. await waitFor(() => {
  714. expect(screen.getAllByRole('button', { name: 'Remove' })).to.have.length(
  715. 1
  716. )
  717. })
  718. })
  719. it('selects contact by typing the entire email and blurring the input', async function () {
  720. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  721. scope: { project },
  722. })
  723. const [inputElement] = await screen.findAllByLabelText(
  724. 'Share with your collaborators'
  725. )
  726. // Wait for contacts to load
  727. await waitFor(() => {
  728. expect(fetchMock.called('express:/user/contacts')).to.be.true
  729. })
  730. // Enter a prefix that matches a contact
  731. await userEvent.type(inputElement, 'ptolemy@example.com')
  732. // The matching contact should now be present and selected
  733. await screen.findByRole('option', {
  734. name: `Claudius Ptolemy <ptolemy@example.com>`,
  735. selected: true,
  736. })
  737. // No items should be added yet
  738. expect(screen.queryByRole('button', { name: 'Remove' })).to.be.null
  739. // Click anywhere on the form to blur the input
  740. await userEvent.click(screen.getByRole('dialog'))
  741. // The contact should be added
  742. await waitFor(() => {
  743. expect(screen.getAllByRole('button', { name: 'Remove' })).to.have.length(
  744. 1
  745. )
  746. })
  747. })
  748. })