share-project-modal.test.js 23 KB

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