Merge pull request #16073 from overleaf/em-postpone-tutorials

Support postponing tutorials

GitOrigin-RevId: fe662086c87cc1909d6d9eeac07f85e306d64418
This commit is contained in:
Eric Mc Sween 2023-12-05 07:59:55 -05:00 committed by Copybot
parent d30e876999
commit 94b9d1fa48
13 changed files with 152 additions and 55 deletions

View file

@ -38,6 +38,7 @@ const InstitutionsFeatures = require('../Institutions/InstitutionsFeatures')
const ProjectAuditLogHandler = require('./ProjectAuditLogHandler')
const PublicAccessLevels = require('../Authorization/PublicAccessLevels')
const TagsHandler = require('../Tags/TagsHandler')
const TutorialHandler = require('../Tutorial/TutorialHandler')
/**
* @typedef {import("./types").GetProjectsRequest} GetProjectsRequest
@ -847,7 +848,7 @@ const ProjectController = {
alphaProgram: user.alphaProgram,
betaProgram: user.betaProgram,
labsProgram: user.labsProgram,
completedTutorials: user.completedTutorials,
inactiveTutorials: TutorialHandler.getInactiveTutorials(user),
isAdmin: hasAdminAccess(user),
},
userSettings: {

View file

@ -5,6 +5,7 @@ const { expressify } = require('@overleaf/promise-utils')
const VALID_KEYS = [
'react-history-buttons-tutorial',
'table-generator-promotion',
'writefull-integration',
]
async function completeTutorial(req, res, next) {
@ -12,13 +13,26 @@ async function completeTutorial(req, res, next) {
const tutorialKey = req.params.tutorialKey
if (!VALID_KEYS.includes(tutorialKey)) {
return res.sendStatus(400)
return res.sendStatus(404)
}
await TutorialHandler.saveCompletion(userId, tutorialKey)
await TutorialHandler.setTutorialState(userId, tutorialKey, 'completed')
res.sendStatus(204)
}
async function postponeTutorial(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
const tutorialKey = req.params.tutorialKey
if (!VALID_KEYS.includes(tutorialKey)) {
return res.sendStatus(404)
}
await TutorialHandler.setTutorialState(userId, tutorialKey, 'postponed')
res.sendStatus(204)
}
module.exports = {
completeTutorial: expressify(completeTutorial),
postponeTutorial: expressify(postponeTutorial),
}

View file

@ -1,13 +1,45 @@
const UserUpdater = require('../User/UserUpdater')
async function saveCompletion(userId, tutorialKey) {
const completionDate = new Date()
const POSTPONE_DURATION_MS = 24 * 60 * 60 * 1000 // 1 day
/**
* Change the tutorial state
*
* @param {string} userId
* @param {string} tutorialKey
* @param {'completed' | 'postponed'} state
*/
async function setTutorialState(userId, tutorialKey, state) {
await UserUpdater.promises.updateUser(userId, {
$set: {
[`completedTutorials.${tutorialKey}`]: completionDate,
[`completedTutorials.${tutorialKey}`]: { state, updatedAt: new Date() },
},
})
}
module.exports = { saveCompletion }
/**
* Returns a list of inactive tutorials for a given user
*
* The user must be loaded with the completedTutorials property.
*/
function getInactiveTutorials(user, tutorialKey) {
const inactiveTutorials = []
for (const [key, record] of Object.entries(user.completedTutorials ?? {})) {
if (record instanceof Date) {
// Legacy format: single date means the tutorial was completed
inactiveTutorials.push(key)
} else if (record.state === 'postponed') {
const postponedUntil = new Date(
record.updatedAt.getTime() + POSTPONE_DURATION_MS
)
if (new Date() < postponedUntil) {
inactiveTutorials.push(key)
}
} else {
inactiveTutorials.push(key)
}
}
return inactiveTutorials
}
module.exports = { setTutorialState, getInactiveTutorials }

View file

@ -426,6 +426,12 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
TutorialController.completeTutorial
)
webRouter.post(
'/tutorial/:tutorialKey/postpone',
AuthenticationController.requireLogin(),
TutorialController.postponeTutorial
)
webRouter.get(
'/user/projects',
AuthenticationController.requireLogin(),

View file

@ -33,7 +33,7 @@ meta(name="ol-showPersonalAccessToken", data-type="boolean" content=showPersonal
meta(name="ol-optionalPersonalAccessToken", data-type="boolean" content=optionalPersonalAccessToken)
meta(name="ol-hasTrackChangesFeature", data-type="boolean" content=hasTrackChangesFeature)
meta(name="ol-mathJax3Path" content=mathJax3Path)
meta(name="ol-completedTutorials", data-type="json" content=user.completedTutorials)
meta(name="ol-inactiveTutorials", data-type="json" content=user.inactiveTutorials)
meta(name="ol-projectTags" data-type="json" content=projectTags)
meta(name="ol-idePageReact", data-type="boolean" content=idePageReact)
meta(name="ol-loadingText", data-type="string" content=translate("loading"))

View file

@ -16,12 +16,9 @@ import useAsync from '@/shared/hooks/use-async'
import { completeHistoryTutorial } from '../../services/api'
import { debugConsole } from '@/utils/debugging'
type CompletedTutorials = {
'react-history-buttons-tutorial': Date
}
type EditorTutorials = {
completedTutorials: CompletedTutorials
setCompletedTutorial: (key: string) => void
inactiveTutorials: [string]
deactivateTutorial: (key: string) => void
}
function AllHistoryList() {
@ -100,18 +97,18 @@ function AllHistoryList() {
}
}, [updatesLoadingState])
const { completedTutorials, setCompletedTutorial }: EditorTutorials =
const { inactiveTutorials, deactivateTutorial }: EditorTutorials =
useEditorContext()
const [showPopover, setShowPopover] = useState(() => {
// only show tutorial popover if they haven't dismissed ("completed") it yet
return !completedTutorials?.['react-history-buttons-tutorial']
return !inactiveTutorials.includes('react-history-buttons-tutorial')
})
const completeTutorial = useCallback(() => {
setShowPopover(false)
setCompletedTutorial('react-history-buttons-tutorial')
}, [setCompletedTutorial])
deactivateTutorial('react-history-buttons-tutorial')
}, [deactivateTutorial])
const { runAsync } = useAsync()

View file

@ -26,28 +26,20 @@ const NEW_USER_CUTOFF_TIME = new Date(2023, 8, 20).getTime()
const NOW_TIME = new Date().getTime()
const GRAMMARLY_CUTOFF_TIME = new Date(2023, 9, 10).getTime()
type CompletedTutorials = {
'table-generator-promotion'?: Date | string
}
type EditorTutorials = {
completedTutorials?: CompletedTutorials
setCompletedTutorial: (key: string) => void
inactiveTutorials: [string]
deactivateTutorial: (key: string) => void
}
const editorContextPropTypes = {
completedTutorials: PropTypes.shape({
'table-generator-promotion': PropTypes.oneOfType([
PropTypes.instanceOf(Date),
PropTypes.string,
]),
}),
setCompletedTutorial: PropTypes.func.isRequired,
inactiveTutorials: PropTypes.arrayOf(PropTypes.string).isRequired,
deactivateTutorial: PropTypes.func.isRequired,
}
export const PromotionOverlay: FC = ({ children }) => {
const ref = useRef<HTMLSpanElement>(null)
const { completedTutorials }: EditorTutorials = useEditorContext(
const { inactiveTutorials }: EditorTutorials = useEditorContext(
editorContextPropTypes
)
const {
@ -68,7 +60,7 @@ export const PromotionOverlay: FC = ({ children }) => {
const showPromotion =
splitTestVariants['table-generator-promotion'] === 'enabled' &&
!completedTutorials?.['table-generator-promotion'] &&
!inactiveTutorials.includes('table-generator-promotion') &&
!hideBecauseNewUser
if (!showPromotion) {
@ -88,17 +80,17 @@ const PromotionOverlayContent = memo(
_props,
ref: Ref<HTMLSpanElement>
) {
const { setCompletedTutorial }: EditorTutorials = useEditorContext(
const { deactivateTutorial }: EditorTutorials = useEditorContext(
editorContextPropTypes
)
const [timeoutExpired, setTimeoutExpired] = useState(false)
const onClose = useCallback(() => {
setCompletedTutorial('table-generator-promotion')
deactivateTutorial('table-generator-promotion')
postJSON('/tutorial/table-generator-promotion/complete').catch(
debugConsole.error
)
}, [setCompletedTutorial])
}, [deactivateTutorial])
const onDismiss = useCallback(() => {
onClose()

View file

@ -86,18 +86,15 @@ export function EditorProvider({ children }) {
const [showSymbolPalette] = useScopeValue('editor.showSymbolPalette')
const [toggleSymbolPalette] = useScopeValue('editor.toggleSymbolPalette')
const [completedTutorials, setCompletedTutorials] = useState(() =>
getMeta('ol-completedTutorials')
const [inactiveTutorials, setInactiveTutorials] = useState(() =>
getMeta('ol-inactiveTutorials', [])
)
const setCompletedTutorial = useCallback(
const deactivateTutorial = useCallback(
tutorialKey => {
setCompletedTutorials({
...completedTutorials,
[tutorialKey]: new Date(),
})
setInactiveTutorials([...inactiveTutorials, tutorialKey])
},
[completedTutorials]
[inactiveTutorials]
)
useEffect(() => {
@ -172,8 +169,8 @@ export function EditorProvider({ children }) {
showSymbolPalette,
toggleSymbolPalette,
insertSymbol,
completedTutorials,
setCompletedTutorial,
inactiveTutorials,
deactivateTutorial,
}),
[
cobranding,
@ -186,8 +183,8 @@ export function EditorProvider({ children }) {
showSymbolPalette,
toggleSymbolPalette,
insertSymbol,
completedTutorials,
setCompletedTutorial,
inactiveTutorials,
deactivateTutorial,
]
)

View file

@ -126,9 +126,7 @@ export const Visual = (args: any, { globals: { theme } }: any) => {
useMeta({
'ol-showSymbolPalette': true,
'ol-mathJax3Path': 'https://unpkg.com/mathjax@3.2.2/es5/tex-svg-full.js',
'ol-completedTutorials': {
'table-generator-promotion': '2023-09-01T00:00:00.000Z',
},
'ol-inactiveTutorials': ['table-generator-promotion'],
'ol-project_id': '63e21c07946dd8c76505f85a',
})

View file

@ -51,9 +51,9 @@ describe('change list', function () {
cy.intercept('GET', '/project/*/filetree/diff*', {
body: { diff: [{ pathname: 'main.tex' }, { pathname: 'name.tex' }] },
}).as('diff')
window.metaAttributesCache.set('ol-completedTutorials', {
'react-history-buttons-tutorial': Date.now(),
})
window.metaAttributesCache.set('ol-inactiveTutorials', [
'react-history-buttons-tutorial',
])
})
describe('toggle switch', function () {

View file

@ -111,9 +111,9 @@ describe('<CodeMirrorEditor/> Table editor', function () {
cy.interceptMathJax()
cy.interceptCompile('compile', Number.MAX_SAFE_INTEGER)
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
window.metaAttributesCache.set('ol-completedTutorials', {
'table-generator-promotion': '2023-09-01T00:00:00.000Z',
})
window.metaAttributesCache.set('ol-inactiveTutorials', [
'table-generator-promotion',
])
})
describe('Table rendering', function () {

View file

@ -150,6 +150,9 @@ describe('ProjectController', function () {
this.ProjectAuditLogHandler = {
addEntry: sinon.stub().yields(),
}
this.TutorialHandler = {
getInactiveTutorials: sinon.stub().returns([]),
}
this.ProjectController = SandboxedModule.require(MODULE_PATH, {
requires: {
@ -192,6 +195,7 @@ describe('ProjectController', function () {
'../Institutions/InstitutionsFeatures': this.InstitutionsFeatures,
'../Survey/SurveyHandler': this.SurveyHandler,
'./ProjectAuditLogHandler': this.ProjectAuditLogHandler,
'../Tutorial/TutorialHandler': this.TutorialHandler,
},
})

View file

@ -0,0 +1,56 @@
const SandboxedModule = require('sandboxed-module')
const { expect } = require('chai')
const sinon = require('sinon')
const { ObjectId } = require('mongodb')
const MODULE_PATH = '../../../../app/src/Features/Tutorial/TutorialHandler'
describe('TutorialHandler', function () {
beforeEach(function () {
this.clock = sinon.useFakeTimers()
this.user = {
_id: new ObjectId(),
completedTutorials: {
'legacy-format': new Date(Date.now() - 1000),
completed: {
state: 'completed',
updatedAt: new Date(Date.now() - 1000),
},
'postponed-recently': {
state: 'postponed',
updatedAt: new Date(Date.now() - 1000),
},
'postponed-long-ago': {
state: 'postponed',
updatedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
},
},
}
this.UserUpdater = {
promises: {
updateUser: sinon.stub().resolves(),
},
}
this.TutorialHandler = SandboxedModule.require(MODULE_PATH, {
requires: {
'../User/UserUpdater': this.UserUpdater,
},
})
})
describe('getInactiveTutorials', function () {
it('returns all recorded tutorials except when they were posponed long ago', function () {
const hiddenTutorials = this.TutorialHandler.getInactiveTutorials(
this.user
)
expect(hiddenTutorials).to.have.members([
'legacy-format',
'completed',
'postponed-recently',
])
})
})
})