mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
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:
parent
8cba7935b7
commit
0766c91079
13 changed files with 214 additions and 9 deletions
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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"))
|
||||
|
|
|
@ -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": "",
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
<EditorSwitchBeginnerTooltip>
|
||||
<label htmlFor="editor-switch-cm6" className="toggle-switch-label">
|
||||
<span>{t('code_editor')}</span>
|
||||
</label>
|
||||
</EditorSwitchBeginnerTooltip>
|
||||
|
||||
<RichTextToggle
|
||||
checked={richTextAvailable && visual}
|
||||
|
|
|
@ -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[]
|
||||
|
|
|
@ -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
|
||||
***************************************/
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -35,6 +35,7 @@ export type User = {
|
|||
alphaProgram?: boolean
|
||||
betaProgram?: boolean
|
||||
labsProgram?: boolean
|
||||
isLatexBeginner?: boolean
|
||||
signUpDate?: string // date string
|
||||
features?: Features
|
||||
refProviders?: RefProviders
|
||||
|
|
Loading…
Reference in a new issue