share-project-modal.test.js 23 KB

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