Merge pull request #4715 from overleaf/ab-project-members-modal-split-test

Share modal split test

GitOrigin-RevId: 274450564e1cbfc3ba3ec7c2ca60dfeda552a536
This commit is contained in:
Alexandre Bourdin 2021-08-19 17:42:49 +02:00 committed by Copybot
parent 60d1483150
commit 7e6839b0af
14 changed files with 340 additions and 94 deletions

View file

@ -38,6 +38,7 @@ const UserController = require('../User/UserController')
const AnalyticsManager = require('../Analytics/AnalyticsManager') const AnalyticsManager = require('../Analytics/AnalyticsManager')
const Modules = require('../../infrastructure/Modules') const Modules = require('../../infrastructure/Modules')
const SplitTestHandler = require('../SplitTests/SplitTestHandler') const SplitTestHandler = require('../SplitTests/SplitTestHandler')
const SplitTestV2Handler = require('../SplitTests/SplitTestV2Handler')
const { getNewLogsUIVariantForUser } = require('../Helpers/NewLogsUI') const { getNewLogsUIVariantForUser } = require('../Helpers/NewLogsUI')
const _ssoAvailable = (affiliation, session, linkedInstitutionIds) => { const _ssoAvailable = (affiliation, session, linkedInstitutionIds) => {
@ -725,23 +726,36 @@ const ProjectController = {
} }
) )
}, },
sharingModalSplitTest(cb) {
SplitTestV2Handler.assignInLocalsContext(
res,
userId,
'project-share-modal-paywall',
err => {
cb(err, null)
}
)
}, },
(err, results) => { },
(
err,
{
project,
user,
subscription,
isTokenMember,
brandVariation,
pdfCachingFeatureFlag,
}
) => {
if (err != null) { if (err != null) {
OError.tag(err, 'error getting details for project page') OError.tag(err, 'error getting details for project page')
return next(err) return next(err)
} }
const { project } = results
const { user } = results
const { subscription } = results
const { brandVariation } = results
const { pdfCachingFeatureFlag } = results
const anonRequestToken = TokenAccessHandler.getRequestToken( const anonRequestToken = TokenAccessHandler.getRequestToken(
req, req,
projectId projectId
) )
const { isTokenMember } = results
const allowedImageNames = ProjectHelper.getAllowedImagesForUser( const allowedImageNames = ProjectHelper.getAllowedImagesForUser(
sessionUser sessionUser
) )

View file

@ -81,6 +81,14 @@ async function getAssignment(userId, splitTestName, options) {
} }
} }
async function assignInLocalsContext(res, userId, splitTestName, options) {
const assignment = await getAssignment(userId, splitTestName, options)
if (!res.locals.splitTestVariants) {
res.locals.splitTestVariants = {}
}
res.locals.splitTestVariants[splitTestName] = assignment.variant
}
async function _getAssignmentMetadata(userId, splitTest) { async function _getAssignmentMetadata(userId, splitTest) {
const currentVersion = splitTest.getCurrentVersion() const currentVersion = splitTest.getCurrentVersion()
const phase = currentVersion.phase const phase = currentVersion.phase
@ -171,7 +179,9 @@ async function _getUser(id) {
module.exports = { module.exports = {
getAssignment: callbackify(getAssignment), getAssignment: callbackify(getAssignment),
assignInLocalsContext: callbackify(assignInLocalsContext),
promises: { promises: {
getAssignment, getAssignment,
assignInLocalsContext,
}, },
} }

View file

@ -85,6 +85,7 @@ html(
}) })
//- Expose some settings globally to the frontend //- Expose some settings globally to the frontend
meta(name="ol-ExposedSettings" data-type="json" content=ExposedSettings) meta(name="ol-ExposedSettings" data-type="json" content=ExposedSettings)
meta(name="ol-splitTestVariants" data-type="json" content=splitTestVariants || {})
if (typeof(settings.algolia) != "undefined") if (typeof(settings.algolia) != "undefined")
meta(name="ol-algolia" data-type="json" content={ meta(name="ol-algolia" data-type="json" content={

View file

@ -199,6 +199,7 @@
"navigate_log_source": "", "navigate_log_source": "",
"navigation": "", "navigation": "",
"need_to_upgrade_for_more_collabs": "", "need_to_upgrade_for_more_collabs": "",
"need_to_upgrade_for_more_collabs_variant": "",
"new_file": "", "new_file": "",
"new_folder": "", "new_folder": "",
"new_name": "", "new_name": "",

View file

@ -0,0 +1,49 @@
import Icon from '../../../shared/components/icon'
import { Trans, useTranslation } from 'react-i18next'
export default function AddCollaboratorsUpgradeContentDefault() {
const { t } = useTranslation()
return (
<>
<p className="text-center">
<Trans i18nKey="need_to_upgrade_for_more_collabs" />. {t('also')}:
</p>
<ul className="list-unstyled">
<li>
<Icon type="check" />
&nbsp;
<Trans i18nKey="unlimited_projects" />
</li>
<li>
<Icon type="check" />
&nbsp;
<Trans
i18nKey="collabs_per_proj"
values={{ collabcount: 'Multiple' }}
/>
</li>
<li>
<Icon type="check" />
&nbsp;
<Trans i18nKey="full_doc_history" />
</li>
<li>
<Icon type="check" />
&nbsp;
<Trans i18nKey="sync_to_dropbox" />
</li>
<li>
<Icon type="check" />
&nbsp;
<Trans i18nKey="sync_to_github" />
</li>
<li>
<Icon type="check" />
&nbsp;
<Trans i18nKey="compile_larger_projects" />
</li>
</ul>
</>
)
}

View file

@ -0,0 +1,56 @@
import Icon from '../../../shared/components/icon'
import { Trans } from 'react-i18next'
export default function AddCollaboratorsUpgradeContentVariant() {
return (
<>
<div className="row">
<div className="col-xs-10 col-xs-offset-1">
<p className="text-center">
<Trans i18nKey="need_to_upgrade_for_more_collabs_variant" />
</p>
</div>
</div>
<div className="row">
<ul className="list-unstyled col-xs-7">
<li>
<Icon type="check" />
&nbsp;
<Trans i18nKey="unlimited_projects" />
</li>
<li>
<Icon type="check" />
&nbsp;
<Trans
i18nKey="collabs_per_proj"
values={{ collabcount: 'Multiple' }}
/>
</li>
<li>
<Icon type="check" />
&nbsp;
<Trans i18nKey="full_doc_history" />
</li>
</ul>
<ul className="list-unstyled col-xs-5">
<li>
<Icon type="check" />
&nbsp;
<Trans i18nKey="sync_to_dropbox" />
</li>
<li>
<Icon type="check" />
&nbsp;
<Trans i18nKey="sync_to_github" />
</li>
<li>
<Icon type="check" />
&nbsp;
<Trans i18nKey="compile_larger_projects" />
</li>
</ul>
</div>
<br />
</>
)
}

View file

@ -1,63 +1,34 @@
import { useState } from 'react' import { useState } from 'react'
import { Trans, useTranslation } from 'react-i18next' import { Trans } from 'react-i18next'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { useUserContext } from '../../../shared/context/user-context' import { useUserContext } from '../../../shared/context/user-context'
import Icon from '../../../shared/components/icon'
import { upgradePlan } from '../../../main/account-upgrade' import { upgradePlan } from '../../../main/account-upgrade'
import StartFreeTrialButton from '../../../shared/components/start-free-trial-button' import StartFreeTrialButton from '../../../shared/components/start-free-trial-button'
import AddCollaboratorsUpgradeContentDefault from './add-collaborators-upgrade-content-default'
import AddCollaboratorsUpgradeContentVariant from './add-collaborators-upgrade-content-variant'
import { useSplitTestContext } from '../../../shared/context/split-test-context'
export default function AddCollaboratorsUpgrade() { export default function AddCollaboratorsUpgrade() {
const { t } = useTranslation()
const user = useUserContext({ const user = useUserContext({
allowedFreeTrial: PropTypes.bool, allowedFreeTrial: PropTypes.bool,
}) })
const [startedFreeTrial, setStartedFreeTrial] = useState(false) const [startedFreeTrial, setStartedFreeTrial] = useState(false)
const { splitTestVariants } = useSplitTestContext({
splitTestVariants: PropTypes.object,
})
const variant = splitTestVariants['project-share-modal-paywall']
return ( return (
<div className="add-collaborators-upgrade"> <div className={variant === 'default' ? 'add-collaborators-upgrade' : ''}>
<p className="text-center"> {!variant || variant === 'default' ? (
<Trans i18nKey="need_to_upgrade_for_more_collabs" />. {t('also')}: <AddCollaboratorsUpgradeContentDefault />
</p> ) : (
<AddCollaboratorsUpgradeContentVariant />
<ul className="list-unstyled"> )}
<li>
<Icon type="check" />
&nbsp;
<Trans i18nKey="unlimited_projects" />
</li>
<li>
<Icon type="check" />
&nbsp;
<Trans
i18nKey="collabs_per_proj"
values={{ collabcount: 'Multiple' }}
/>
</li>
<li>
<Icon type="check" />
&nbsp;
<Trans i18nKey="full_doc_history" />
</li>
<li>
<Icon type="check" />
&nbsp;
<Trans i18nKey="sync_to_dropbox" />
</li>
<li>
<Icon type="check" />
&nbsp;
<Trans i18nKey="sync_to_github" />
</li>
<li>
<Icon type="check" />
&nbsp;
<Trans i18nKey="compile_larger_projects" />
</li>
</ul>
<p className="text-center row-spaced-thin"> <p className="text-center row-spaced-thin">
{user.allowedFreeTrial ? ( {user.allowedFreeTrial ? (

View file

@ -7,6 +7,7 @@ import { useShareProjectContext } from './share-project-modal'
import { setProjectAccessLevel } from '../utils/api' import { setProjectAccessLevel } from '../utils/api'
import CopyLink from '../../../shared/components/copy-link' import CopyLink from '../../../shared/components/copy-link'
import { useProjectContext } from '../../../shared/context/project-context' import { useProjectContext } from '../../../shared/context/project-context'
import * as eventTracking from '../../../infrastructure/event-tracking'
export default function LinkSharing() { export default function LinkSharing() {
const [inflight, setInflight] = useState(false) const [inflight, setInflight] = useState(false)
@ -75,7 +76,10 @@ function PrivateSharing({ setAccessLevel, inflight }) {
type="button" type="button"
bsStyle="link" bsStyle="link"
className="btn-inline-link" className="btn-inline-link"
onClick={() => setAccessLevel('tokenBased')} onClick={() => {
setAccessLevel('tokenBased')
eventTracking.sendMB('link-sharing-click')
}}
disabled={inflight} disabled={inflight}
> >
<Trans i18nKey="turn_on_link_sharing" /> <Trans i18nKey="turn_on_link_sharing" />

View file

@ -7,13 +7,92 @@ import ViewMember from './view-member'
import OwnerInfo from './owner-info' import OwnerInfo from './owner-info'
import SendInvitesNotice from './send-invites-notice' import SendInvitesNotice from './send-invites-notice'
import { useProjectContext } from '../../../shared/context/project-context' import { useProjectContext } from '../../../shared/context/project-context'
import { useSplitTestContext } from '../../../shared/context/split-test-context'
import { Row } from 'react-bootstrap'
import PropTypes from 'prop-types'
import RecaptchaConditions from '../../../shared/components/recaptcha-conditions' import RecaptchaConditions from '../../../shared/components/recaptcha-conditions'
export default function ShareModalBody() { export default function ShareModalBody() {
const { isAdmin } = useShareProjectContext() const { isAdmin } = useShareProjectContext()
const { splitTestVariants } = useSplitTestContext({
splitTestVariants: PropTypes.object,
})
const project = useProjectContext() const project = useProjectContext()
switch (splitTestVariants['project-share-modal-paywall']) {
case 'new-copy-top':
return (
<>
{isAdmin ? (
<>
<SendInvites />
<Row className="public-access-level" />
</>
) : (
<SendInvitesNotice />
)}
<OwnerInfo />
{project.members.map(member =>
isAdmin ? (
<EditMember key={member._id} member={member} />
) : (
<ViewMember key={member._id} member={member} />
)
)}
{project.invites.map(invite => (
<Invite key={invite._id} invite={invite} isAdmin={isAdmin} />
))}
{isAdmin && (
<>
<br />
<LinkSharing />
</>
)}
{!window.ExposedSettings.recaptchaDisabled?.invite && (
<RecaptchaConditions />
)}
</>
)
case 'new-copy-middle':
return (
<>
<OwnerInfo />
{project.members.map(member =>
isAdmin ? (
<EditMember key={member._id} member={member} />
) : (
<ViewMember key={member._id} member={member} />
)
)}
{project.invites.map(invite => (
<Invite key={invite._id} invite={invite} isAdmin={isAdmin} />
))}
{isAdmin ? <SendInvites /> : <SendInvitesNotice />}
{isAdmin && (
<>
<br />
<LinkSharing />
</>
)}
{!window.ExposedSettings.recaptchaDisabled?.invite && (
<RecaptchaConditions />
)}
</>
)
case 'new-copy-bottom':
case 'default':
default:
return ( return (
<> <>
{isAdmin && <LinkSharing />} {isAdmin && <LinkSharing />}
@ -33,9 +112,11 @@ export default function ShareModalBody() {
))} ))}
{isAdmin ? <SendInvites /> : <SendInvitesNotice />} {isAdmin ? <SendInvites /> : <SendInvitesNotice />}
{!window.ExposedSettings.recaptchaDisabled?.invite && ( {!window.ExposedSettings.recaptchaDisabled?.invite && (
<RecaptchaConditions /> <RecaptchaConditions />
)} )}
</> </>
) )
}
} }

View file

@ -8,9 +8,11 @@ import { CompileProvider } from './compile-context'
import { LayoutProvider } from './layout-context' import { LayoutProvider } from './layout-context'
import { ChatProvider } from '../../features/chat/context/chat-context' import { ChatProvider } from '../../features/chat/context/chat-context'
import { ProjectProvider } from './project-context' import { ProjectProvider } from './project-context'
import { SplitTestProvider } from './split-test-context'
export function ContextRoot({ children, ide, settings }) { export function ContextRoot({ children, ide, settings }) {
return ( return (
<SplitTestProvider>
<IdeProvider ide={ide}> <IdeProvider ide={ide}>
<UserProvider> <UserProvider>
<ProjectProvider> <ProjectProvider>
@ -24,6 +26,7 @@ export function ContextRoot({ children, ide, settings }) {
</ProjectProvider> </ProjectProvider>
</UserProvider> </UserProvider>
</IdeProvider> </IdeProvider>
</SplitTestProvider>
) )
} }

View file

@ -0,0 +1,43 @@
import { createContext, useContext, useMemo } from 'react'
import PropTypes from 'prop-types'
import getMeta from '../../utils/meta'
export const SplitTestContext = createContext()
SplitTestContext.Provider.propTypes = {
value: PropTypes.shape({
splitTestVariants: PropTypes.object.isRequired,
}),
}
export function SplitTestProvider({ children }) {
const value = useMemo(
() => ({
splitTestVariants: getMeta('ol-splitTestVariants') || {},
}),
[]
)
return (
<SplitTestContext.Provider value={value}>
{children}
</SplitTestContext.Provider>
)
}
SplitTestProvider.propTypes = {
children: PropTypes.any,
}
export function useSplitTestContext(propTypes) {
const context = useContext(SplitTestContext)
PropTypes.checkPropTypes(
propTypes,
context,
'data',
'SplitTestContext.Provider'
)
return context
}

View file

@ -981,6 +981,7 @@
"share_with_your_collabs": "Share with your collaborators", "share_with_your_collabs": "Share with your collaborators",
"share": "Share", "share": "Share",
"need_to_upgrade_for_more_collabs": "You need to upgrade your account to add more collaborators", "need_to_upgrade_for_more_collabs": "You need to upgrade your account to add more collaborators",
"need_to_upgrade_for_more_collabs_variant": "You have reached the maximum number of collaborators. Upgrade your account to add more.",
"make_project_public": "Make project public", "make_project_public": "Make project public",
"make_project_public_consequences": "If you make your project public then anyone with the URL will be able to access it.", "make_project_public_consequences": "If you make your project public then anyone with the URL will be able to access it.",
"allow_public_editing": "Allow public editing", "allow_public_editing": "Allow public editing",

View file

@ -10,6 +10,7 @@ import { ChatProvider } from '../../../frontend/js/features/chat/context/chat-co
import { IdeProvider } from '../../../frontend/js/shared/context/ide-context' import { IdeProvider } from '../../../frontend/js/shared/context/ide-context'
import { get } from 'lodash' import { get } from 'lodash'
import { ProjectProvider } from '../../../frontend/js/shared/context/project-context' import { ProjectProvider } from '../../../frontend/js/shared/context/project-context'
import { SplitTestProvider } from '../../../frontend/js/shared/context/split-test-context'
export function EditorProviders({ export function EditorProviders({
user = { id: '123abd' }, user = { id: '123abd' },
@ -53,6 +54,7 @@ export function EditorProviders({
window._ide = { $scope, socket } window._ide = { $scope, socket }
return ( return (
<SplitTestProvider>
<IdeProvider ide={window._ide}> <IdeProvider ide={window._ide}>
<UserProvider> <UserProvider>
<ProjectProvider> <ProjectProvider>
@ -62,6 +64,7 @@ export function EditorProviders({
</ProjectProvider> </ProjectProvider>
</UserProvider> </UserProvider>
</IdeProvider> </IdeProvider>
</SplitTestProvider>
) )
} }

View file

@ -130,6 +130,14 @@ describe('ProjectController', function () {
}, },
getTestSegmentation: sinon.stub().yields(null, { enabled: false }), getTestSegmentation: sinon.stub().yields(null, { enabled: false }),
} }
this.SplitTestV2Handler = {
promises: {
getAssignment: sinon.stub().resolves({ active: false }),
assignInLocalsContext: sinon.stub().resolves(),
},
getAssignment: sinon.stub().yields(null, { active: false }),
assignInLocalsContext: sinon.stub().yields(null),
}
this.ProjectController = SandboxedModule.require(MODULE_PATH, { this.ProjectController = SandboxedModule.require(MODULE_PATH, {
requires: { requires: {
@ -137,6 +145,7 @@ describe('ProjectController', function () {
'@overleaf/settings': this.settings, '@overleaf/settings': this.settings,
'@overleaf/metrics': this.Metrics, '@overleaf/metrics': this.Metrics,
'../SplitTests/SplitTestHandler': this.SplitTestHandler, '../SplitTests/SplitTestHandler': this.SplitTestHandler,
'../SplitTests/SplitTestV2Handler': this.SplitTestV2Handler,
'./ProjectDeleter': this.ProjectDeleter, './ProjectDeleter': this.ProjectDeleter,
'./ProjectDuplicator': this.ProjectDuplicator, './ProjectDuplicator': this.ProjectDuplicator,
'./ProjectCreationHandler': this.ProjectCreationHandler, './ProjectCreationHandler': this.ProjectCreationHandler,