share-project-modal.test.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820
  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('/event/project-sharing-paywall-prompt', {})
  517. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  518. user: {
  519. id: '123abd',
  520. allowedFreeTrial: true,
  521. },
  522. scope: {
  523. project: {
  524. ...project,
  525. publicAccesLevel: 'tokenBased',
  526. features: {
  527. collaborators: 0,
  528. compileGroup: 'standard',
  529. },
  530. },
  531. },
  532. })
  533. expect(screen.queryByLabelText('Share with your collaborators')).to.be.null
  534. screen.getByText(
  535. /You need to upgrade your account to add more collaborators/
  536. )
  537. })
  538. it('handles server error responses', async function () {
  539. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  540. scope: {
  541. project: {
  542. ...project,
  543. publicAccesLevel: 'tokenBased',
  544. },
  545. },
  546. })
  547. // loading contacts
  548. await waitFor(() => {
  549. expect(fetchMock.called('express:/user/contacts')).to.be.true
  550. })
  551. const [inputElement] = await screen.findAllByLabelText(
  552. 'Share with your collaborators'
  553. )
  554. const submitButton = screen.getByRole('button', { name: 'Share' })
  555. const respondWithError = async function (errorReason) {
  556. fireEvent.focus(inputElement)
  557. fireEvent.change(inputElement, {
  558. target: { value: 'invited-author-1@example.com' },
  559. })
  560. fireEvent.blur(inputElement)
  561. fetchMock.postOnce(
  562. 'express:/project/:projectId/invite',
  563. {
  564. status: 400,
  565. body: { errorReason },
  566. },
  567. { overwriteRoutes: true }
  568. )
  569. expect(submitButton.disabled).to.be.false
  570. submitButton.click()
  571. await fetchMock.flush(true)
  572. expect(fetchMock.done()).to.be.true
  573. }
  574. await respondWithError('cannot_invite_non_user')
  575. await screen.findByText(
  576. `Can't send invite. Recipient must already have a Overleaf account`
  577. )
  578. await respondWithError('cannot_verify_user_not_robot')
  579. await screen.findByText(
  580. `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.`
  581. )
  582. await respondWithError('cannot_invite_self')
  583. await screen.findByText(`Can't send invite to yourself`)
  584. await respondWithError('invalid_email')
  585. await screen.findByText(`An email address is invalid`)
  586. await respondWithError('too_many_requests')
  587. await screen.findByText(
  588. `Too many requests were received in a short space of time. Please wait for a few moments and try again.`
  589. )
  590. })
  591. it('handles switching between access levels', async function () {
  592. fetchMock.post('express:/project/:projectId/settings/admin', 204)
  593. const watchCallbacks = {}
  594. const scopeWithProject = project => {
  595. return {
  596. $watch: (path, callback) => {
  597. watchCallbacks[path] = callback
  598. return () => {}
  599. },
  600. project,
  601. }
  602. }
  603. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  604. scope: scopeWithProject({ ...project, publicAccesLevel: 'private' }),
  605. })
  606. await screen.findByText(
  607. 'Link sharing is off, only invited users can view this project.'
  608. )
  609. const enableButton = await screen.findByRole('button', {
  610. name: 'Turn on link sharing',
  611. })
  612. fireEvent.click(enableButton)
  613. await waitFor(() => expect(enableButton.disabled).to.be.true)
  614. const { body: tokenBody } = fetchMock.lastOptions()
  615. expect(JSON.parse(tokenBody)).to.deep.equal({
  616. publicAccessLevel: 'tokenBased',
  617. })
  618. // NOTE: updating the scoped project data manually,
  619. // as the project data is usually updated via the websocket connection
  620. watchCallbacks.project({ ...project, publicAccesLevel: 'tokenBased' })
  621. await screen.findByText('Link sharing is on')
  622. const disableButton = await screen.findByRole('button', {
  623. name: 'Turn off link sharing',
  624. })
  625. fireEvent.click(disableButton)
  626. await waitFor(() => expect(disableButton.disabled).to.be.true)
  627. const { body: privateBody } = fetchMock.lastOptions()
  628. expect(JSON.parse(privateBody)).to.deep.equal({
  629. publicAccessLevel: 'private',
  630. })
  631. // NOTE: updating the scoped project data manually,
  632. // as the project data is usually updated via the websocket connection
  633. watchCallbacks.project({ ...project, publicAccesLevel: 'private' })
  634. await screen.findByText(
  635. 'Link sharing is off, only invited users can view this project.'
  636. )
  637. })
  638. it('avoids selecting unmatched contact', async function () {
  639. renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
  640. scope: { project },
  641. })
  642. const [inputElement] = await screen.findAllByLabelText(
  643. 'Share with your collaborators'
  644. )
  645. // Wait for contacts to load
  646. await waitFor(() => {
  647. expect(fetchMock.called('express:/user/contacts')).to.be.true
  648. })
  649. // Enter a prefix that matches a contact
  650. inputElement.focus()
  651. fireEvent.change(inputElement, { target: { value: 'ptolemy' } })
  652. // The matching contact should now be present and selected
  653. await screen.findByRole('option', {
  654. name: `Claudius Ptolemy <ptolemy@example.com>`,
  655. selected: true,
  656. })
  657. // Keep entering text so the contact no longer matches
  658. fireEvent.change(inputElement, {
  659. target: { value: 'ptolemy.new@example.com' },
  660. })
  661. // The matching contact should no longer be present
  662. expect(
  663. screen.queryByRole('option', {
  664. name: `Claudius Ptolemy <ptolemy@example.com>`,
  665. })
  666. ).to.be.null
  667. // No items should be added yet
  668. expect(screen.queryByRole('button', { name: 'Remove' })).to.be.null
  669. // Pressing Tab should add the entered item
  670. fireEvent.keyDown(inputElement, { key: 'Tab', code: 'Tab' })
  671. await waitFor(() => {
  672. expect(screen.getAllByRole('button', { name: 'Remove' })).to.have.length(
  673. 1
  674. )
  675. })
  676. // Blurring the input should not add another contact
  677. fireEvent.blur(inputElement)
  678. await waitFor(() => {
  679. expect(screen.getAllByRole('button', { name: 'Remove' })).to.have.length(
  680. 1
  681. )
  682. })
  683. })
  684. })