Explorar o código

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

Share modal split test

GitOrigin-RevId: 274450564e1cbfc3ba3ec7c2ca60dfeda552a536
Alexandre Bourdin %!s(int64=5) %!d(string=hai) anos
pai
achega
7e6839b0af

+ 22 - 8
services/web/app/src/Features/Project/ProjectController.js

@@ -38,6 +38,7 @@ const UserController = require('../User/UserController')
 const AnalyticsManager = require('../Analytics/AnalyticsManager')
 const Modules = require('../../infrastructure/Modules')
 const SplitTestHandler = require('../SplitTests/SplitTestHandler')
+const SplitTestV2Handler = require('../SplitTests/SplitTestV2Handler')
 const { getNewLogsUIVariantForUser } = require('../Helpers/NewLogsUI')
 
 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) {
           OError.tag(err, 'error getting details for project page')
           return next(err)
         }
-        const { project } = results
-        const { user } = results
-        const { subscription } = results
-        const { brandVariation } = results
-        const { pdfCachingFeatureFlag } = results
-
         const anonRequestToken = TokenAccessHandler.getRequestToken(
           req,
           projectId
         )
-        const { isTokenMember } = results
         const allowedImageNames = ProjectHelper.getAllowedImagesForUser(
           sessionUser
         )

+ 10 - 0
services/web/app/src/Features/SplitTests/SplitTestV2Handler.js

@@ -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) {
   const currentVersion = splitTest.getCurrentVersion()
   const phase = currentVersion.phase
@@ -171,7 +179,9 @@ async function _getUser(id) {
 
 module.exports = {
   getAssignment: callbackify(getAssignment),
+  assignInLocalsContext: callbackify(assignInLocalsContext),
   promises: {
     getAssignment,
+    assignInLocalsContext,
   },
 }

+ 1 - 0
services/web/app/views/layout.pug

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

+ 1 - 0
services/web/frontend/extracted-translations.json

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

+ 49 - 0
services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade-content-default.js

@@ -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>
+    </>
+  )
+}

+ 56 - 0
services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade-content-variant.js

@@ -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 />
+    </>
+  )
+}

+ 14 - 43
services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.js

@@ -1,63 +1,34 @@
 import { useState } from 'react'
-import { Trans, useTranslation } from 'react-i18next'
+import { Trans } from 'react-i18next'
 import { Button } from 'react-bootstrap'
 import PropTypes from 'prop-types'
 
 import { useUserContext } from '../../../shared/context/user-context'
 
-import Icon from '../../../shared/components/icon'
 import { upgradePlan } from '../../../main/account-upgrade'
 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() {
-  const { t } = useTranslation()
   const user = useUserContext({
     allowedFreeTrial: PropTypes.bool,
   })
 
   const [startedFreeTrial, setStartedFreeTrial] = useState(false)
+  const { splitTestVariants } = useSplitTestContext({
+    splitTestVariants: PropTypes.object,
+  })
+  const variant = splitTestVariants['project-share-modal-paywall']
 
   return (
-    <div className="add-collaborators-upgrade">
-      <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>
+    <div className={variant === 'default' ? 'add-collaborators-upgrade' : ''}>
+      {!variant || variant === 'default' ? (
+        <AddCollaboratorsUpgradeContentDefault />
+      ) : (
+        <AddCollaboratorsUpgradeContentVariant />
+      )}
 
       <p className="text-center row-spaced-thin">
         {user.allowedFreeTrial ? (

+ 5 - 1
services/web/frontend/js/features/share-project-modal/components/link-sharing.js

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

+ 105 - 24
services/web/frontend/js/features/share-project-modal/components/share-modal-body.js

@@ -7,35 +7,116 @@ import ViewMember from './view-member'
 import OwnerInfo from './owner-info'
 import SendInvitesNotice from './send-invites-notice'
 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'
 
 export default function ShareModalBody() {
   const { isAdmin } = useShareProjectContext()
+  const { splitTestVariants } = useSplitTestContext({
+    splitTestVariants: PropTypes.object,
+  })
 
   const project = useProjectContext()
 
-  return (
-    <>
-      {isAdmin && <LinkSharing />}
-
-      <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 />}
-      {!window.ExposedSettings.recaptchaDisabled?.invite && (
-        <RecaptchaConditions />
-      )}
-    </>
-  )
+  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 (
+        <>
+          {isAdmin && <LinkSharing />}
+
+          <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 />}
+
+          {!window.ExposedSettings.recaptchaDisabled?.invite && (
+            <RecaptchaConditions />
+          )}
+        </>
+      )
+  }
 }

+ 16 - 13
services/web/frontend/js/shared/context/root-context.js

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

+ 43 - 0
services/web/frontend/js/shared/context/split-test-context.js

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

+ 1 - 0
services/web/locales/en.json

@@ -981,6 +981,7 @@
   "share_with_your_collabs": "Share with your collaborators",
   "share": "Share",
   "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_consequences": "If you make your project public then anyone with the URL will be able to access it.",
   "allow_public_editing": "Allow public editing",

+ 12 - 9
services/web/test/frontend/helpers/render-with-context.js

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

+ 9 - 0
services/web/test/unit/src/Project/ProjectControllerTests.js

@@ -130,6 +130,14 @@ describe('ProjectController', function () {
       },
       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, {
       requires: {
@@ -137,6 +145,7 @@ describe('ProjectController', function () {
         '@overleaf/settings': this.settings,
         '@overleaf/metrics': this.Metrics,
         '../SplitTests/SplitTestHandler': this.SplitTestHandler,
+        '../SplitTests/SplitTestV2Handler': this.SplitTestV2Handler,
         './ProjectDeleter': this.ProjectDeleter,
         './ProjectDuplicator': this.ProjectDuplicator,
         './ProjectCreationHandler': this.ProjectCreationHandler,