share-project-modal.test.js 23 KB

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