share-project-modal.test.js 23 KB

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