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
This commit is contained in:
Domagoj Kriskovic 2024-08-01 14:19:58 +02:00 committed by Copybot
parent 8cba7935b7
commit 0766c91079
13 changed files with 214 additions and 9 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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": "",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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