Просмотр исходного кода

Default LaTeX beginners to the Visual Editor (#18917)

* open visual code if user havent used latex before

* test tooltip on code editor switch

* firstTimeLoadedEditor

* track editor.codeEditorOpened value

* lastEditorLoadedDate

* odc data loaded from mongo

* fix a typo

* use tutorial to check if it was dissmised

* use getInactiveTutorials fn

* fix test

* check if code editor was opened

* added translations

* pass classname to tooltip

* use signUpDate instead of lastEditorLoadedDate

* refactor visual fallback value

* use tutorial completed data only for tooltip

* set lastUsedMode in odc form

* safer usedLatex check

* getOnboardingDataValue helper function

* move tooltip to a separate component

* move classname to tooltipProps

* usedLatex in meta tag

* codeEdtiorOpened fallback value

* fix release date year

* fix 24 hours criteria for showing the tooltip

* fix tests

* hide tooltip when code editor is opened

* remove setting lastUsedMode in ODC form

* remove empty comment

* change date for checking signUpDate

* fix linting error

GitOrigin-RevId: 0a57ba3f4717492d4546633571117f667d3a05f8
Domagoj Kriskovic 2 лет назад
Родитель
Сommit
0766c91079

+ 16 - 2
services/web/app/src/Features/OnboardingDataCollection/OnboardingDataCollectionManager.js

@@ -1,16 +1,29 @@
 const {
   OnboardingDataCollection,
+  OnboardingDataCollectionSchema,
 } = require('../../models/OnboardingDataCollection')
 const OError = require('@overleaf/o-error')
 
-async function getOnboardingDataCollection(userId) {
+async function getOnboardingDataCollection(userId, projection = {}) {
   try {
-    return await OnboardingDataCollection.findOne({ _id: userId }).exec()
+    return await OnboardingDataCollection.findOne(
+      { _id: userId },
+      projection
+    ).exec()
   } catch (error) {
     throw OError.tag(error, 'Failed to get OnboardingDataCollection')
   }
 }
 
+async function getOnboardingDataValue(userId, key) {
+  if (!OnboardingDataCollectionSchema.paths[key]) {
+    throw new Error(`${key} is not a valid onboarding data key`)
+  }
+
+  const result = await getOnboardingDataCollection(userId, { [key]: 1 })
+  return result ? result[key] : null
+}
+
 async function upsertOnboardingDataCollection({
   userId,
   firstName,
@@ -60,4 +73,5 @@ module.exports = {
   getOnboardingDataCollection,
   upsertOnboardingDataCollection,
   deleteOnboardingDataCollection,
+  getOnboardingDataValue,
 }

+ 10 - 0
services/web/app/src/Features/Project/ProjectController.js

@@ -41,6 +41,7 @@ const ProjectAuditLogHandler = require('./ProjectAuditLogHandler')
 const PublicAccessLevels = require('../Authorization/PublicAccessLevels')
 const TagsHandler = require('../Tags/TagsHandler')
 const TutorialHandler = require('../Tutorial/TutorialHandler')
+const OnboardingDataCollectionManager = require('../OnboardingDataCollection/OnboardingDataCollectionManager')
 const UserUpdater = require('../User/UserUpdater')
 const Modules = require('../../infrastructure/Modules')
 const UserGetter = require('../User/UserGetter')
@@ -381,6 +382,13 @@ const _ProjectController = {
               userId,
               projectId
             ),
+          usedLatex: OnboardingDataCollectionManager.getOnboardingDataValue(
+            userId,
+            'usedLatex'
+          ).catch(err => {
+            logger.error({ err, userId })
+            return null
+          }),
         })
       )
     const splitTestAssignments = {}
@@ -428,6 +436,7 @@ const _ProjectController = {
         subscription,
         isTokenMember,
         isInvitedMember,
+        usedLatex,
       } = userValues
 
       // check if a user is not in the writefull-oauth-promotion, in which case they may be part of the auto trial group
@@ -724,6 +733,7 @@ const _ProjectController = {
         hasTrackChangesFeature: Features.hasFeature('track-changes'),
         projectTags,
         linkSharingWarning: linkSharingChanges.variant === 'active',
+        usedLatex,
       })
       timer.done()
     } catch (err) {

+ 1 - 0
services/web/app/src/Features/Tutorial/TutorialController.js

@@ -8,6 +8,7 @@ const VALID_KEYS = [
   'writefull-oauth-promotion',
   'bib-file-tpr-prompt',
   'ai-error-assistant-consent',
+  'code-editor-mode-prompt',
 ]
 
 async function completeTutorial(req, res, next) {

+ 1 - 0
services/web/app/views/project/editor/meta.pug

@@ -37,6 +37,7 @@ meta(name="ol-hasTrackChangesFeature", data-type="boolean" content=hasTrackChang
 meta(name="ol-inactiveTutorials", data-type="json" content=user.inactiveTutorials)
 meta(name="ol-projectTags" data-type="json" content=projectTags)
 meta(name="ol-linkSharingWarning" data-type="boolean" content=linkSharingWarning)
+meta(name="ol-usedLatex" data-type="string" content=usedLatex)
 
 // translations for the loading page, before i18n has loaded in the client
 meta(name="ol-loadingText", data-type="string" content=translate("loading"))

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

@@ -191,6 +191,8 @@
   "code_check_failed": "",
   "code_check_failed_explanation": "",
   "code_editor": "",
+  "code_editor_tooltip_message": "",
+  "code_editor_tooltip_title": "",
   "collaborate_online_and_offline": "",
   "collabs_per_proj": "",
   "collabs_per_proj_single": "",

+ 26 - 1
services/web/frontend/js/features/ide-react/scope-adapters/editor-manager-context-adapter.ts

@@ -1,5 +1,6 @@
 import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
 import customLocalStorage from '@/infrastructure/local-storage'
+import getMeta from '@/utils/meta'
 
 export function populateEditorScope(
   store: ReactScopeValueStore,
@@ -26,13 +27,24 @@ export function populateEditorScope(
   })
   store.persisted(
     'editor.showVisual',
-    showVisualFallbackValue(projectId),
+    getMeta('ol-usedLatex') === 'never' || showVisualFallbackValue(projectId),
     `editor.lastUsedMode`,
     {
       toPersisted: showVisual => (showVisual ? 'visual' : 'code'),
       fromPersisted: mode => mode === 'visual',
     }
   )
+
+  store.persisted(
+    'editor.codeEditorOpened',
+    codeEditorOpenedFallbackValue(),
+    'editor.codeEditorOpened'
+  )
+  store.watch('editor.showVisual', showVisual => {
+    if (store.get('editor.codeEditorOpened') !== true && showVisual === false) {
+      store.set('editor.codeEditorOpened', true)
+    }
+  })
 }
 
 function showVisualFallbackValue(projectId: string) {
@@ -46,3 +58,16 @@ function showVisualFallbackValue(projectId: string) {
 
   return editorModeVal === 'rich-text'
 }
+
+function codeEditorOpenedFallbackValue() {
+  const signUpDate = getMeta('ol-user').signUpDate
+  if (
+    typeof signUpDate === 'string' &&
+    new Date(signUpDate) < new Date('2024-08-02')
+  ) {
+    // if signUpDate is before releasing "codeEditorOpened" value
+    // it is assumed that the user has opened the code editor at some point
+    return true
+  }
+  return false
+}

+ 118 - 0
services/web/frontend/js/features/source-editor/components/editor-switch-beginner-tooltip.tsx

@@ -0,0 +1,118 @@
+import { ReactElement, useCallback, useEffect, useRef, useState } from 'react'
+import Tooltip from '../../../shared/components/tooltip'
+import Close from '@/shared/components/close'
+import { useEditorContext } from '@/shared/context/editor-context'
+import useTutorial from '@/shared/hooks/promotions/use-tutorial'
+import { useUserContext } from '@/shared/context/user-context'
+import useScopeValue from '@/shared/hooks/use-scope-value'
+import { useTranslation } from 'react-i18next'
+import getMeta from '@/utils/meta'
+
+const CODE_EDITOR_TOOLTIP_TIMEOUT = 1000
+export const codeEditorModePrompt = 'code-editor-mode-prompt'
+
+export const EditorSwitchBeginnerTooltip = ({
+  children,
+}: {
+  children: ReactElement
+}) => {
+  const toolbarRef = useRef<any>(null)
+  const user = useUserContext()
+  const { inactiveTutorials } = useEditorContext()
+  const { t } = useTranslation()
+  const [codeEditorOpened] = useScopeValue('editor.codeEditorOpened')
+  const { completeTutorial } = useTutorial(codeEditorModePrompt, {
+    location: 'logs',
+    name: codeEditorModePrompt,
+  })
+  const [tooltipShown, setTooltipShown] = useState(false)
+
+  const shouldShowCodeEditorTooltip = useCallback(() => {
+    if (inactiveTutorials.includes(codeEditorModePrompt)) {
+      return false
+    }
+
+    if (getMeta('ol-usedLatex') !== 'never') {
+      // only show tooltip to the users that never used LaTeX (submitted in onboarding data collection)
+      return false
+    }
+
+    if (codeEditorOpened) {
+      // dont show tooltip if code editor was opened at some point
+      return false
+    }
+
+    const msSinceSignedUp =
+      user.signUpDate && Date.now() - new Date(user.signUpDate).getTime()
+
+    if (msSinceSignedUp && msSinceSignedUp < 24 * 60 * 60 * 1000) {
+      // dont show tooltip if user has signed up is less than 24 hours
+      return false
+    }
+
+    return true
+  }, [codeEditorOpened, inactiveTutorials, user.signUpDate])
+
+  const showCodeEditorTooltip = useCallback(() => {
+    if (toolbarRef.current && 'show' in toolbarRef.current) {
+      toolbarRef.current.show()
+      setTooltipShown(true)
+    }
+  }, [])
+
+  const hideCodeEditorTooltip = useCallback(() => {
+    if (toolbarRef.current && 'hide' in toolbarRef.current) {
+      toolbarRef.current.hide()
+      setTooltipShown(false)
+    }
+  }, [])
+
+  useEffect(() => {
+    if (tooltipShown && codeEditorOpened) {
+      hideCodeEditorTooltip()
+    }
+  }, [codeEditorOpened, hideCodeEditorTooltip, tooltipShown])
+
+  useEffect(() => {
+    const timeout = setTimeout(() => {
+      if (shouldShowCodeEditorTooltip()) {
+        showCodeEditorTooltip()
+      }
+    }, CODE_EDITOR_TOOLTIP_TIMEOUT)
+
+    return () => clearTimeout(timeout)
+  }, [showCodeEditorTooltip, shouldShowCodeEditorTooltip])
+
+  return (
+    <Tooltip
+      id="editor-switch-tooltip"
+      description={
+        <div>
+          <Close
+            variant="dark"
+            onDismiss={() => {
+              hideCodeEditorTooltip()
+              completeTutorial({ event: 'promo-click', action: 'complete' })
+            }}
+          />
+          <div className="tooltip-title">{t('code_editor_tooltip_title')}</div>
+          <div>{t('code_editor_tooltip_message')}</div>
+        </div>
+      }
+      tooltipProps={{
+        className: 'editor-switch-tooltip',
+      }}
+      overlayProps={{
+        ref: toolbarRef,
+        placement: 'bottom',
+        shouldUpdatePosition: true,
+        // @ts-ignore
+        // trigger: null is used to prevent the tooltip from showing on hover
+        // but it is not allowed in the type definition
+        trigger: null,
+      }}
+    >
+      {children}
+    </Tooltip>
+  )
+}

+ 21 - 6
services/web/frontend/js/features/source-editor/components/editor-switch.tsx

@@ -1,16 +1,26 @@
 import { ChangeEvent, FC, memo, useCallback } from 'react'
-import useScopeValue from '../../../shared/hooks/use-scope-value'
-import Tooltip from '../../../shared/components/tooltip'
+import useScopeValue from '@/shared/hooks/use-scope-value'
+import Tooltip from '@/shared/components/tooltip'
+import useTutorial from '@/shared/hooks/promotions/use-tutorial'
 import { sendMB } from '../../../infrastructure/event-tracking'
 import isValidTeXFile from '../../../main/is-valid-tex-file'
 import { useTranslation } from 'react-i18next'
+import {
+  EditorSwitchBeginnerTooltip,
+  codeEditorModePrompt,
+} from './editor-switch-beginner-tooltip'
 
 function EditorSwitch() {
   const { t } = useTranslation()
   const [visual, setVisual] = useScopeValue('editor.showVisual')
   const [docName] = useScopeValue('editor.open_doc_name')
+  const [codeEditorOpened] = useScopeValue('editor.codeEditorOpened')
 
   const richTextAvailable = isValidTeXFile(docName)
+  const { completeTutorial } = useTutorial(codeEditorModePrompt, {
+    location: 'logs',
+    name: codeEditorModePrompt,
+  })
 
   const handleChange = useCallback(
     event => {
@@ -19,6 +29,9 @@ function EditorSwitch() {
       switch (editorType) {
         case 'cm6':
           setVisual(false)
+          if (!codeEditorOpened) {
+            completeTutorial({ event: 'promo-click', action: 'complete' })
+          }
           break
 
         case 'rich-text':
@@ -28,7 +41,7 @@ function EditorSwitch() {
 
       sendMB('editor-switch-change', { editorType })
     },
-    [setVisual]
+    [codeEditorOpened, completeTutorial, setVisual]
   )
 
   return (
@@ -45,9 +58,11 @@ function EditorSwitch() {
           checked={!richTextAvailable || !visual}
           onChange={handleChange}
         />
-        <label htmlFor="editor-switch-cm6" className="toggle-switch-label">
-          <span>{t('code_editor')}</span>
-        </label>
+        <EditorSwitchBeginnerTooltip>
+          <label htmlFor="editor-switch-cm6" className="toggle-switch-label">
+            <span>{t('code_editor')}</span>
+          </label>
+        </EditorSwitchBeginnerTooltip>
 
         <RichTextToggle
           checked={richTextAvailable && visual}

+ 1 - 0
services/web/frontend/js/utils/meta.ts

@@ -196,6 +196,7 @@ export interface Meta {
   'ol-translationMaintenance': string
   'ol-translationUnableToJoin': string
   'ol-useShareJsHash': boolean
+  'ol-usedLatex': 'never' | 'occasionally' | 'often' | undefined
   'ol-user': User
   'ol-userAffiliations': Affiliation[]
   'ol-userEmails': UserEmailData[]

+ 10 - 0
services/web/frontend/stylesheets/app/editor/toolbar.less

@@ -337,6 +337,16 @@
   }
 }
 
+.editor-switch-tooltip .tooltip-inner {
+  text-align: left;
+  max-width: 325px;
+  padding: 10px;
+
+  .tooltip-title {
+    font-weight: bold;
+    line-height: 22px;
+  }
+}
 /**************************************
 	Formatting buttons
 ***************************************/

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

@@ -277,6 +277,8 @@
   "code_check_failed": "Code check failed",
   "code_check_failed_explanation": "Your code has errors that need to be fixed before the auto-compile can run",
   "code_editor": "Code Editor",
+  "code_editor_tooltip_message": "You can see the code behind your project (and make edits to it) in the Code Editor",
+  "code_editor_tooltip_title": "Want to view and edit the LaTeX code?",
   "collaborate_easily_on_your_projects": "Collaborate easily on your projects. Work on longer or more complex docs.",
   "collaborate_online_and_offline": "Collaborate online and offline, using your own workflow",
   "collaboration": "Collaboration",

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

@@ -197,6 +197,9 @@ describe('ProjectController', function () {
     this.TutorialHandler = {
       getInactiveTutorials: sinon.stub().returns([]),
     }
+    this.OnboardingDataCollectionManager = {
+      getOnboardingDataValue: sinon.stub().resolves(null),
+    }
 
     this.ProjectController = SandboxedModule.require(MODULE_PATH, {
       requires: {
@@ -245,6 +248,8 @@ describe('ProjectController', function () {
         '../Survey/SurveyHandler': this.SurveyHandler,
         './ProjectAuditLogHandler': this.ProjectAuditLogHandler,
         '../Tutorial/TutorialHandler': this.TutorialHandler,
+        '../OnboardingDataCollection/OnboardingDataCollectionManager':
+          this.OnboardingDataCollectionManager,
         '../User/UserUpdater': {
           promises: {
             updateUser: sinon.stub().resolves(),

+ 1 - 0
services/web/types/user.ts

@@ -35,6 +35,7 @@ export type User = {
   alphaProgram?: boolean
   betaProgram?: boolean
   labsProgram?: boolean
+  isLatexBeginner?: boolean
   signUpDate?: string // date string
   features?: Features
   refProviders?: RefProviders