diff --git a/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.js b/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.js
index fcd670a35e..5ea142854b 100644
--- a/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.js
+++ b/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.js
@@ -1,12 +1,19 @@
import React, { useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { Button } from 'react-bootstrap'
+import PropTypes from 'prop-types'
+
+import { useApplicationContext } from '../../../shared/context/application-context'
+
import Icon from '../../../shared/components/icon'
import { upgradePlan } from '../../../main/account-upgrade'
import StartFreeTrialButton from '../../../shared/components/start-free-trial-button'
export default function AddCollaboratorsUpgrade() {
const { t } = useTranslation()
+ const { user } = useApplicationContext({
+ user: PropTypes.shape({ allowedFreeTrial: PropTypes.boolean }),
+ })
const [startedFreeTrial, setStartedFreeTrial] = useState(false)
@@ -53,7 +60,7 @@ export default function AddCollaboratorsUpgrade() {
- {window.user.allowedFreeTrial ? (
+ {user.allowedFreeTrial ? (
@@ -24,11 +29,7 @@ export default function ShareProjectModalContent({
- {window.isRestrictedTokenMember ? (
-
- ) : (
-
- )}
+ {isRestrictedTokenMember ? : }
diff --git a/services/web/frontend/js/features/share-project-modal/controllers/react-share-project-modal-controller.js b/services/web/frontend/js/features/share-project-modal/controllers/react-share-project-modal-controller.js
index 8f34610c77..5fcc87d96b 100644
--- a/services/web/frontend/js/features/share-project-modal/controllers/react-share-project-modal-controller.js
+++ b/services/web/frontend/js/features/share-project-modal/controllers/react-share-project-modal-controller.js
@@ -2,11 +2,16 @@ import App from '../../../base'
import { react2angular } from 'react2angular'
import ShareProjectModal from '../components/share-project-modal'
+import { rootContext } from '../../../shared/context/root-context'
import { listProjectInvites, listProjectMembers } from '../utils/api'
App.component(
'shareProjectModal',
- react2angular(ShareProjectModal, undefined, ['ide'])
+ react2angular(
+ rootContext.use(ShareProjectModal),
+ Object.keys(ShareProjectModal.propTypes),
+ ['ide']
+ )
)
export default App.controller(
diff --git a/services/web/frontend/js/shared/context/root-context.js b/services/web/frontend/js/shared/context/root-context.js
index c8675f85e8..e7e0033a11 100644
--- a/services/web/frontend/js/shared/context/root-context.js
+++ b/services/web/frontend/js/shared/context/root-context.js
@@ -1,11 +1,12 @@
import React from 'react'
import PropTypes from 'prop-types'
+import createSharedContext from 'react2angular-shared-context'
+
import { ApplicationProvider } from './application-context'
import { EditorProvider } from './editor-context'
-import createSharedContext from 'react2angular-shared-context'
-import { ChatProvider } from '../../features/chat/context/chat-context'
-import { LayoutProvider } from './layout-context'
import { CompileProvider } from './compile-context'
+import { LayoutProvider } from './layout-context'
+import { ChatProvider } from '../../features/chat/context/chat-context'
export function ContextRoot({ children, ide, settings }) {
const isAnonymousUser = window.user.id == null
diff --git a/services/web/frontend/stories/share-project-modal.stories.js b/services/web/frontend/stories/share-project-modal.stories.js
index 5d34cad6b0..7a595c8a99 100644
--- a/services/web/frontend/stories/share-project-modal.stories.js
+++ b/services/web/frontend/stories/share-project-modal.stories.js
@@ -1,89 +1,8 @@
import React, { useEffect } from 'react'
import ShareProjectModal from '../js/features/share-project-modal/components/share-project-modal'
+import { ContextRoot } from '../js/shared/context/root-context'
import useFetchMock from './hooks/use-fetch-mock'
-const contacts = [
- // user with edited name
- {
- type: 'user',
- email: 'test-user@example.com',
- first_name: 'Test',
- last_name: 'User',
- name: 'Test User',
- },
- // user with default name (email prefix)
- {
- type: 'user',
- email: 'test@example.com',
- first_name: 'test',
- },
- // no last name
- {
- type: 'user',
- first_name: 'Eratosthenes',
- email: 'eratosthenes@example.com',
- },
- // more users
- {
- type: 'user',
- first_name: 'Claudius',
- last_name: 'Ptolemy',
- email: 'ptolemy@example.com',
- },
- {
- type: 'user',
- first_name: 'Abd al-Rahman',
- last_name: 'Al-Sufi',
- email: 'al-sufi@example.com',
- },
- {
- type: 'user',
- first_name: 'Nicolaus',
- last_name: 'Copernicus',
- email: 'copernicus@example.com',
- },
-]
-
-const setupFetchMock = fetchMock => {
- const delay = 1000
-
- fetchMock
- // list contacts
- .get('express:/user/contacts', { contacts }, { delay })
- // change privacy setting
- .post('express:/project/:projectId/settings/admin', 200, { delay })
- // update project member (e.g. set privilege level)
- .put('express:/project/:projectId/users/:userId', 200, { delay })
- // remove project member
- .delete('express:/project/:projectId/users/:userId', 200, { delay })
- // transfer ownership
- .post('express:/project/:projectId/transfer-ownership', 200, {
- delay,
- })
- // send invite
- .post('express:/project/:projectId/invite', 200, { delay })
- // delete invite
- .delete('express:/project/:projectId/invite/:inviteId', 204, {
- delay,
- })
- // resend invite
- .post('express:/project/:projectId/invite/:inviteId/resend', 200, {
- delay,
- })
- // send analytics event
- .post('express:/event/:key', 200)
-}
-
-const ideWithProject = project => {
- return {
- $scope: {
- $watch: () => () => {},
- $applyAsync: () => {},
- project,
- },
- }
-}
-
export const LinkSharingOff = args => {
useFetchMock(setupFetchMock)
@@ -92,7 +11,9 @@ export const LinkSharingOff = args => {
publicAccesLevel: 'private',
}
- return
+ return renderWithContext(
+
+ )
}
export const LinkSharingOn = args => {
@@ -103,7 +24,9 @@ export const LinkSharingOn = args => {
publicAccesLevel: 'tokenBased',
}
- return
+ return renderWithContext(
+
+ )
}
export const LinkSharingLoading = args => {
@@ -115,7 +38,9 @@ export const LinkSharingLoading = args => {
tokens: undefined,
}
- return
+ return renderWithContext(
+
+ )
}
export const NonAdminLinkSharingOff = args => {
@@ -124,7 +49,7 @@ export const NonAdminLinkSharingOff = args => {
publicAccesLevel: 'private',
}
- return (
+ return renderWithContext(
{
publicAccesLevel: 'tokenBased',
}
- return (
+ return renderWithContext(
{
}
export const RestrictedTokenMember = args => {
+ // Override isRestrictedTokenMember to be true, then revert it back to the
+ // original value on unmount
+ // Currently this is necessary because the context value is set from window,
+ // however in the future we should change this to set via props
+ const originalIsRestrictedTokenMember = window.isRestrictedTokenMember
window.isRestrictedTokenMember = true
-
useEffect(() => {
return () => {
- window.isRestrictedTokenMember = false
+ window.isRestrictedTokenMember = originalIsRestrictedTokenMember
}
- }, [])
+ })
const project = {
...args.project,
publicAccesLevel: 'tokenBased',
}
- return
+ return renderWithContext(
+
+ )
}
export const LegacyLinkSharingReadAndWrite = args => {
@@ -173,7 +104,9 @@ export const LegacyLinkSharingReadAndWrite = args => {
publicAccesLevel: 'readAndWrite',
}
- return
+ return renderWithContext(
+
+ )
}
export const LegacyLinkSharingReadOnly = args => {
@@ -184,7 +117,9 @@ export const LegacyLinkSharingReadOnly = args => {
publicAccesLevel: 'readOnly',
}
- return
+ return renderWithContext(
+
+ )
}
export const LimitedCollaborators = args => {
@@ -198,7 +133,9 @@ export const LimitedCollaborators = args => {
},
}
- return
+ return renderWithContext(
+
+ )
}
const project = {
@@ -261,3 +198,97 @@ export default {
handleHide: { action: 'hide' },
},
}
+
+// Unfortunately, we cannot currently use decorators here, since we need to
+// set a value on window, before the contexts are rendered.
+// When using decorators, the contexts are rendered before the story, so we
+// don't have the opportunity to set the window value first.
+function renderWithContext(Story) {
+ return (
+
+ {Story}
+
+ )
+}
+
+const contacts = [
+ // user with edited name
+ {
+ type: 'user',
+ email: 'test-user@example.com',
+ first_name: 'Test',
+ last_name: 'User',
+ name: 'Test User',
+ },
+ // user with default name (email prefix)
+ {
+ type: 'user',
+ email: 'test@example.com',
+ first_name: 'test',
+ },
+ // no last name
+ {
+ type: 'user',
+ first_name: 'Eratosthenes',
+ email: 'eratosthenes@example.com',
+ },
+ // more users
+ {
+ type: 'user',
+ first_name: 'Claudius',
+ last_name: 'Ptolemy',
+ email: 'ptolemy@example.com',
+ },
+ {
+ type: 'user',
+ first_name: 'Abd al-Rahman',
+ last_name: 'Al-Sufi',
+ email: 'al-sufi@example.com',
+ },
+ {
+ type: 'user',
+ first_name: 'Nicolaus',
+ last_name: 'Copernicus',
+ email: 'copernicus@example.com',
+ },
+]
+
+function setupFetchMock(fetchMock) {
+ const delay = 1000
+
+ fetchMock
+ // list contacts
+ .get('express:/user/contacts', { contacts }, { delay })
+ // change privacy setting
+ .post('express:/project/:projectId/settings/admin', 200, { delay })
+ // update project member (e.g. set privilege level)
+ .put('express:/project/:projectId/users/:userId', 200, { delay })
+ // remove project member
+ .delete('express:/project/:projectId/users/:userId', 200, { delay })
+ // transfer ownership
+ .post('express:/project/:projectId/transfer-ownership', 200, {
+ delay,
+ })
+ // send invite
+ .post('express:/project/:projectId/invite', 200, { delay })
+ // delete invite
+ .delete('express:/project/:projectId/invite/:inviteId', 204, {
+ delay,
+ })
+ // resend invite
+ .post('express:/project/:projectId/invite/:inviteId/resend', 200, {
+ delay,
+ })
+ // send analytics event
+ .post('express:/event/:key', 200)
+}
+
+function ideWithProject(project) {
+ return {
+ $scope: {
+ $watch: () => () => {},
+ $applyAsync: () => {},
+ project,
+ },
+ }
+}
diff --git a/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.js b/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.js
index dc1e1ad273..51263e7b34 100644
--- a/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.js
+++ b/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.js
@@ -2,8 +2,6 @@ import { expect } from 'chai'
import sinon from 'sinon'
import React from 'react'
import {
- cleanup,
- render,
screen,
fireEvent,
waitFor,
@@ -11,7 +9,12 @@ import {
} from '@testing-library/react'
import fetchMock from 'fetch-mock'
import { get } from 'lodash'
+
import ShareProjectModal from '../../../../../frontend/js/features/share-project-modal/components/share-project-modal'
+import {
+ renderWithEditorContext,
+ cleanUpContext,
+} from '../../../helpers/render-with-context'
import * as locationModule from '../../../../../frontend/js/features/share-project-modal/utils/location'
describe('', function () {
@@ -98,11 +101,11 @@ describe('', function () {
afterEach(function () {
fetchMock.restore()
- cleanup()
+ cleanUpContext()
})
it('renders the modal', async function () {
- render()
+ renderWithEditorContext()
await screen.findByText('Share Project')
})
@@ -110,7 +113,9 @@ describe('', function () {
it('calls handleHide when a Close button is pressed', async function () {
const handleHide = sinon.stub()
- render()
+ renderWithEditorContext(
+
+ )
const [
headerCloseButton,
@@ -124,7 +129,7 @@ describe('', function () {
})
it('handles access level "private"', async function () {
- render(
+ renderWithEditorContext(
', function () {
})
it('handles access level "tokenBased"', async function () {
- render(
+ renderWithEditorContext(
', function () {
})
it('handles legacy access level "readAndWrite"', async function () {
- render(
+ renderWithEditorContext(
', function () {
})
it('handles legacy access level "readOnly"', async function () {
- render(
+ renderWithEditorContext(
', function () {
]
// render as admin: actions should be present
- const { rerender } = render(
+ const { rerender } = renderWithEditorContext(
', function () {
await screen.findByRole('button', { name: 'Turn off link sharing' })
await screen.findByRole('button', { name: 'Resend' })
- // render as non-admin, link sharing on: actions should be missing and message should be present
+ // render as non-admin (non-owner), link sharing on: actions should be missing and message should be present
rerender(
', function () {
})
it('only shows read-only token link to restricted token members', async function () {
- window.isRestrictedTokenMember = true
-
- render(
+ renderWithEditorContext(
+ />,
+ { isRestrictedTokenMember: true }
)
- window.isRestrictedTokenMember = false
-
// no buttons
expect(screen.queryByRole('button', { name: 'Turn on link sharing' })).to.be
.null
@@ -310,7 +312,7 @@ describe('', function () {
},
]
- render(
+ renderWithEditorContext(
', function () {
},
]
- render(
+ renderWithEditorContext(
', function () {
},
]
- render(
+ renderWithEditorContext(
', function () {
},
]
- render(
+ renderWithEditorContext(
', function () {
},
]
- render(
+ renderWithEditorContext(
', function () {
},
]
- render(
+ renderWithEditorContext(
', function () {
})
it('sends invites to input email addresses', async function () {
- render(
+ renderWithEditorContext(
', function () {
})
it('displays a message when the collaborator limit is reached', async function () {
- const originalUser = window.user
-
- window.user = { allowedFreeTrial: true }
-
- render(
+ renderWithEditorContext(
', function () {
collaborators: 0,
},
})}
- />
+ />,
+ {
+ user: {
+ id: '123abd',
+ allowedFreeTrial: true,
+ },
+ }
)
expect(screen.queryByLabelText('Share with your collaborators')).to.be.null
@@ -659,12 +663,10 @@ describe('', function () {
screen.getByText(
/You need to upgrade your account to add more collaborators/
)
-
- window.user = originalUser
})
it('handles server error responses', async function () {
- render(
+ renderWithEditorContext(
', function () {
}
}
- render(
+ renderWithEditorContext(
', function () {
})
it('avoids selecting unmatched contact', async function () {
- render()
+ renderWithEditorContext()
const [inputElement] = await screen.findAllByLabelText(
'Share with your collaborators'
diff --git a/services/web/test/frontend/helpers/render-with-context.js b/services/web/test/frontend/helpers/render-with-context.js
index a0f98b821d..f705889dde 100644
--- a/services/web/test/frontend/helpers/render-with-context.js
+++ b/services/web/test/frontend/helpers/render-with-context.js
@@ -16,11 +16,13 @@ export function EditorProviders({
on: sinon.stub(),
removeListener: sinon.stub(),
},
+ isRestrictedTokenMember = false,
children,
}) {
window.user = user || window.user
window.gitBridgePublicBaseUrl = 'git.overleaf.test'
window.project_id = projectId != null ? projectId : window.project_id
+ window.isRestrictedTokenMember = isRestrictedTokenMember
window._ide = {
$scope: {
@@ -47,8 +49,13 @@ export function EditorProviders({
)
}
-export function renderWithEditorContext(children, props) {
- return render({children})
+export function renderWithEditorContext(component, contextProps) {
+ return render(component, {
+ // eslint-disable-next-line react/display-name
+ wrapper: ({ children }) => (
+ {children}
+ ),
+ })
}
export function ChatProviders({ children, ...props }) {
@@ -59,8 +66,13 @@ export function ChatProviders({ children, ...props }) {
)
}
-export function renderWithChatContext(children, props) {
- return render({children})
+export function renderWithChatContext(component, props) {
+ return render(component, {
+ // eslint-disable-next-line react/display-name
+ wrapper: ({ children }) => (
+ {children}
+ ),
+ })
}
export function cleanUpContext() {