share-project-modal.test.js 23 KB

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