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() {