Merge pull request #4099 from overleaf/as-clean-up-share-modal-window

Pull `user` and `isRestrictedTokenMember` from `ApplicationContext`/`EditorContext` instead of `window`

GitOrigin-RevId: 9084d4f1b075123fe4b10b1156c7b844595827e2
This commit is contained in:
Alasdair Smith 2021-06-03 14:44:23 +01:00 committed by Copybot
parent 568e99ad47
commit 3f4fa4c6cc
7 changed files with 202 additions and 143 deletions

View file

@ -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() {
</ul>
<p className="text-center row-spaced-thin">
{window.user.allowedFreeTrial ? (
{user.allowedFreeTrial ? (
<StartFreeTrialButton
buttonStyle="success"
setStartedFreeTrial={setStartedFreeTrial}

View file

@ -6,6 +6,7 @@ import Icon from '../../../shared/components/icon'
import AccessibleModal from '../../../shared/components/accessible-modal'
import PropTypes from 'prop-types'
import { ReadOnlyTokenLink } from './link-sharing'
import { useEditorContext } from '../../../shared/context/editor-context'
export default function ShareProjectModalContent({
show,
@ -14,6 +15,10 @@ export default function ShareProjectModalContent({
inFlight,
error,
}) {
const { isRestrictedTokenMember } = useEditorContext({
isRestrictedTokenMember: PropTypes.bool,
})
return (
<AccessibleModal show={show} onHide={cancel} animation={animation}>
<Modal.Header closeButton>
@ -24,11 +29,7 @@ export default function ShareProjectModalContent({
<Modal.Body className="modal-body-share">
<Grid fluid>
{window.isRestrictedTokenMember ? (
<ReadOnlyTokenLink />
) : (
<ShareModalBody />
)}
{isRestrictedTokenMember ? <ReadOnlyTokenLink /> : <ShareModalBody />}
</Grid>
</Modal.Body>

View file

@ -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(

View file

@ -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

View file

@ -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 <ShareProjectModal {...args} ide={ideWithProject(project)} />
return renderWithContext(
<ShareProjectModal {...args} ide={ideWithProject(project)} />
)
}
export const LinkSharingOn = args => {
@ -103,7 +24,9 @@ export const LinkSharingOn = args => {
publicAccesLevel: 'tokenBased',
}
return <ShareProjectModal {...args} ide={ideWithProject(project)} />
return renderWithContext(
<ShareProjectModal {...args} ide={ideWithProject(project)} />
)
}
export const LinkSharingLoading = args => {
@ -115,7 +38,9 @@ export const LinkSharingLoading = args => {
tokens: undefined,
}
return <ShareProjectModal {...args} ide={ideWithProject(project)} />
return renderWithContext(
<ShareProjectModal {...args} ide={ideWithProject(project)} />
)
}
export const NonAdminLinkSharingOff = args => {
@ -124,7 +49,7 @@ export const NonAdminLinkSharingOff = args => {
publicAccesLevel: 'private',
}
return (
return renderWithContext(
<ShareProjectModal
{...args}
isAdmin={false}
@ -139,7 +64,7 @@ export const NonAdminLinkSharingOn = args => {
publicAccesLevel: 'tokenBased',
}
return (
return renderWithContext(
<ShareProjectModal
{...args}
isAdmin={false}
@ -149,20 +74,26 @@ export const NonAdminLinkSharingOn = args => {
}
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 <ShareProjectModal {...args} ide={ideWithProject(project)} />
return renderWithContext(
<ShareProjectModal {...args} ide={ideWithProject(project)} />
)
}
export const LegacyLinkSharingReadAndWrite = args => {
@ -173,7 +104,9 @@ export const LegacyLinkSharingReadAndWrite = args => {
publicAccesLevel: 'readAndWrite',
}
return <ShareProjectModal {...args} ide={ideWithProject(project)} />
return renderWithContext(
<ShareProjectModal {...args} ide={ideWithProject(project)} />
)
}
export const LegacyLinkSharingReadOnly = args => {
@ -184,7 +117,9 @@ export const LegacyLinkSharingReadOnly = args => {
publicAccesLevel: 'readOnly',
}
return <ShareProjectModal {...args} ide={ideWithProject(project)} />
return renderWithContext(
<ShareProjectModal {...args} ide={ideWithProject(project)} />
)
}
export const LimitedCollaborators = args => {
@ -198,7 +133,9 @@ export const LimitedCollaborators = args => {
},
}
return <ShareProjectModal {...args} ide={ideWithProject(project)} />
return renderWithContext(
<ShareProjectModal {...args} ide={ideWithProject(project)} />
)
}
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 (
<ContextRoot ide={window._ide} settings={{}}>
{Story}
</ContextRoot>
)
}
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,
},
}
}

View file

@ -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('<ShareProjectModal/>', function () {
@ -98,11 +101,11 @@ describe('<ShareProjectModal/>', function () {
afterEach(function () {
fetchMock.restore()
cleanup()
cleanUpContext()
})
it('renders the modal', async function () {
render(<ShareProjectModal {...modalProps} />)
renderWithEditorContext(<ShareProjectModal {...modalProps} />)
await screen.findByText('Share Project')
})
@ -110,7 +113,9 @@ describe('<ShareProjectModal/>', function () {
it('calls handleHide when a Close button is pressed', async function () {
const handleHide = sinon.stub()
render(<ShareProjectModal {...modalProps} handleHide={handleHide} />)
renderWithEditorContext(
<ShareProjectModal {...modalProps} handleHide={handleHide} />
)
const [
headerCloseButton,
@ -124,7 +129,7 @@ describe('<ShareProjectModal/>', function () {
})
it('handles access level "private"', async function () {
render(
renderWithEditorContext(
<ShareProjectModal
{...modalProps}
ide={ideWithProject({ ...project, publicAccesLevel: 'private' })}
@ -143,7 +148,7 @@ describe('<ShareProjectModal/>', function () {
})
it('handles access level "tokenBased"', async function () {
render(
renderWithEditorContext(
<ShareProjectModal
{...modalProps}
ide={ideWithProject({ ...project, publicAccesLevel: 'tokenBased' })}
@ -160,7 +165,7 @@ describe('<ShareProjectModal/>', function () {
})
it('handles legacy access level "readAndWrite"', async function () {
render(
renderWithEditorContext(
<ShareProjectModal
{...modalProps}
ide={ideWithProject({ ...project, publicAccesLevel: 'readAndWrite' })}
@ -174,7 +179,7 @@ describe('<ShareProjectModal/>', function () {
})
it('handles legacy access level "readOnly"', async function () {
render(
renderWithEditorContext(
<ShareProjectModal
{...modalProps}
ide={ideWithProject({ ...project, publicAccesLevel: 'readOnly' })}
@ -197,7 +202,7 @@ describe('<ShareProjectModal/>', function () {
]
// render as admin: actions should be present
const { rerender } = render(
const { rerender } = renderWithEditorContext(
<ShareProjectModal
{...modalProps}
ide={ideWithProject({
@ -212,7 +217,7 @@ describe('<ShareProjectModal/>', 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(
<ShareProjectModal
{...modalProps}
@ -260,17 +265,14 @@ describe('<ShareProjectModal/>', function () {
})
it('only shows read-only token link to restricted token members', async function () {
window.isRestrictedTokenMember = true
render(
renderWithEditorContext(
<ShareProjectModal
{...modalProps}
ide={ideWithProject({ ...project, publicAccesLevel: 'tokenBased' })}
/>
/>,
{ isRestrictedTokenMember: true }
)
window.isRestrictedTokenMember = false
// no buttons
expect(screen.queryByRole('button', { name: 'Turn on link sharing' })).to.be
.null
@ -310,7 +312,7 @@ describe('<ShareProjectModal/>', function () {
},
]
render(
renderWithEditorContext(
<ShareProjectModal
{...modalProps}
ide={ideWithProject({
@ -354,7 +356,7 @@ describe('<ShareProjectModal/>', function () {
},
]
render(
renderWithEditorContext(
<ShareProjectModal
{...modalProps}
ide={ideWithProject({
@ -389,7 +391,7 @@ describe('<ShareProjectModal/>', function () {
},
]
render(
renderWithEditorContext(
<ShareProjectModal
{...modalProps}
ide={ideWithProject({
@ -423,7 +425,7 @@ describe('<ShareProjectModal/>', function () {
},
]
render(
renderWithEditorContext(
<ShareProjectModal
{...modalProps}
ide={ideWithProject({
@ -466,7 +468,7 @@ describe('<ShareProjectModal/>', function () {
},
]
render(
renderWithEditorContext(
<ShareProjectModal
{...modalProps}
ide={ideWithProject({
@ -506,7 +508,7 @@ describe('<ShareProjectModal/>', function () {
},
]
render(
renderWithEditorContext(
<ShareProjectModal
{...modalProps}
ide={ideWithProject({
@ -549,7 +551,7 @@ describe('<ShareProjectModal/>', function () {
})
it('sends invites to input email addresses', async function () {
render(
renderWithEditorContext(
<ShareProjectModal
{...modalProps}
ide={ideWithProject({
@ -637,11 +639,7 @@ describe('<ShareProjectModal/>', function () {
})
it('displays a message when the collaborator limit is reached', async function () {
const originalUser = window.user
window.user = { allowedFreeTrial: true }
render(
renderWithEditorContext(
<ShareProjectModal
{...modalProps}
ide={ideWithProject({
@ -651,7 +649,13 @@ describe('<ShareProjectModal/>', function () {
collaborators: 0,
},
})}
/>
/>,
{
user: {
id: '123abd',
allowedFreeTrial: true,
},
}
)
expect(screen.queryByLabelText('Share with your collaborators')).to.be.null
@ -659,12 +663,10 @@ describe('<ShareProjectModal/>', 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(
<ShareProjectModal
{...modalProps}
ide={ideWithProject({
@ -747,7 +749,7 @@ describe('<ShareProjectModal/>', function () {
}
}
render(
renderWithEditorContext(
<ShareProjectModal
{...modalProps}
ide={ideWithProject({ ...project, publicAccesLevel: 'private' })}
@ -795,7 +797,7 @@ describe('<ShareProjectModal/>', function () {
})
it('avoids selecting unmatched contact', async function () {
render(<ShareProjectModal {...modalProps} />)
renderWithEditorContext(<ShareProjectModal {...modalProps} />)
const [inputElement] = await screen.findAllByLabelText(
'Share with your collaborators'

View file

@ -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(<EditorProviders {...props}>{children}</EditorProviders>)
export function renderWithEditorContext(component, contextProps) {
return render(component, {
// eslint-disable-next-line react/display-name
wrapper: ({ children }) => (
<EditorProviders {...contextProps}>{children}</EditorProviders>
),
})
}
export function ChatProviders({ children, ...props }) {
@ -59,8 +66,13 @@ export function ChatProviders({ children, ...props }) {
)
}
export function renderWithChatContext(children, props) {
return render(<ChatProviders {...props}>{children}</ChatProviders>)
export function renderWithChatContext(component, props) {
return render(component, {
// eslint-disable-next-line react/display-name
wrapper: ({ children }) => (
<ChatProviders {...props}>{children}</ChatProviders>
),
})
}
export function cleanUpContext() {