share-project-modal.test.jsx 24 KB

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