share-project-modal.test.js 25 KB

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