mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-07 20:31:06 -05:00
Merge pull request #16073 from overleaf/em-postpone-tutorials
Support postponing tutorials GitOrigin-RevId: fe662086c87cc1909d6d9eeac07f85e306d64418
This commit is contained in:
parent
d30e876999
commit
94b9d1fa48
13 changed files with 152 additions and 55 deletions
|
@ -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: {
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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"))
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
)
|
||||
|
||||
|
|
|
@ -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',
|
||||
})
|
||||
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
56
services/web/test/unit/src/Tutorial/TutorialHandlerTests.js
Normal file
56
services/web/test/unit/src/Tutorial/TutorialHandlerTests.js
Normal 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',
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Reference in a new issue