share-project-modal.test.jsx 25 KB

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