Merge pull request #14663 from overleaf/mj-table-generator-promo

[cm6] Add promotion popover for table generator and pasting formatted text

GitOrigin-RevId: 9096b36ac4f07d1fc862ecc3bd5df033348950ab
This commit is contained in:
Mathias Jakobsen 2023-09-27 10:58:08 +01:00 committed by Copybot
parent d76c0e2688
commit dc86b0285a
8 changed files with 294 additions and 2 deletions

View file

@ -677,6 +677,17 @@ const ProjectController = {
cb() cb()
}) })
}, },
tableGeneratorPromotionAssignment(cb) {
SplitTestHandler.getAssignment(
req,
res,
'table-generator-promotion',
() => {
// We'll pick up the assignment from the res.locals assignment.
cb()
}
)
},
pasteHtmlAssignment(cb) { pasteHtmlAssignment(cb) {
SplitTestHandler.getAssignment(req, res, 'paste-html', () => { SplitTestHandler.getAssignment(req, res, 'paste-html', () => {
// We'll pick up the assignment from the res.locals assignment. // We'll pick up the assignment from the res.locals assignment.

View file

@ -2,7 +2,10 @@ const SessionManager = require('../Authentication/SessionManager')
const TutorialHandler = require('./TutorialHandler') const TutorialHandler = require('./TutorialHandler')
const { expressify } = require('../../util/promises') const { expressify } = require('../../util/promises')
const VALID_KEYS = ['react-history-buttons-tutorial'] const VALID_KEYS = [
'react-history-buttons-tutorial',
'table-generator-promotion',
]
async function completeTutorial(req, res, next) { async function completeTutorial(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session) const userId = SessionManager.getLoggedInUserId(req.session)

View file

@ -5,6 +5,7 @@ import { sendMB } from '../../../infrastructure/event-tracking'
import isValidTeXFile from '../../../main/is-valid-tex-file' import isValidTeXFile from '../../../main/is-valid-tex-file'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import SplitTestBadge from '../../../shared/components/split-test-badge' import SplitTestBadge from '../../../shared/components/split-test-badge'
import { PromotionOverlay } from './table-generator/promotion/popover'
function EditorSwitch() { function EditorSwitch() {
const { t } = useTranslation() const { t } = useTranslation()
@ -114,7 +115,7 @@ const RichTextToggle: FC<{
) )
} }
return toggle return <PromotionOverlay>{toggle}</PromotionOverlay>
} }
export default memo(EditorSwitch) export default memo(EditorSwitch)

View file

@ -0,0 +1,269 @@
import {
FC,
Ref,
forwardRef,
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import PropTypes from 'prop-types'
import { useEditorContext } from '../../../../../shared/context/editor-context'
import { Button, Overlay, Popover } from 'react-bootstrap'
import Close from '../../../../../shared/components/close'
import { postJSON } from '../../../../../infrastructure/fetch-json'
import { sendMB } from '../../../../../infrastructure/event-tracking'
import { useSplitTestContext } from '../../../../../shared/context/split-test-context'
import { User } from '../../../../../../../types/user'
import { useUserContext } from '../../../../../shared/context/user-context'
import grammarlyExtensionPresent from '../../../../../shared/utils/grammarly'
import { debugConsole } from '../../../../../utils/debugging'
const DELAY_BEFORE_SHOWING_PROMOTION = 1000
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
}
const editorContextPropTypes = {
completedTutorials: PropTypes.shape({
'table-generator-promotion': PropTypes.oneOfType([
PropTypes.instanceOf(Date),
PropTypes.string,
]),
}),
setCompletedTutorial: PropTypes.func.isRequired,
}
export const PromotionOverlay: FC = ({ children }) => {
const ref = useRef<HTMLSpanElement>(null)
const { completedTutorials }: EditorTutorials = useEditorContext(
editorContextPropTypes
)
const {
splitTestVariants,
}: { splitTestVariants: Record<string, string | undefined> } =
useSplitTestContext()
const user = useUserContext() as User | undefined
const userRegistrationTime = useMemo(() => {
if (user?.signUpDate) {
return new Date(user.signUpDate).getTime()
}
}, [user])
const hideBecauseNewUser =
!userRegistrationTime || userRegistrationTime > NEW_USER_CUTOFF_TIME
const showPromotion =
splitTestVariants['table-generator-promotion'] === 'enabled' &&
!completedTutorials?.['table-generator-promotion'] &&
!hideBecauseNewUser
if (!showPromotion) {
return <>{children}</>
}
return (
<>
<PromotionOverlayContent ref={ref} />
<span ref={ref}>{children}</span>
</>
)
}
const PromotionOverlayContent = memo(
forwardRef<HTMLSpanElement>(function PromotionOverlayContent(
_props,
ref: Ref<HTMLSpanElement>
) {
const { setCompletedTutorial }: EditorTutorials = useEditorContext(
editorContextPropTypes
)
const [timeoutExpired, setTimeoutExpired] = useState(false)
const onClose = useCallback(() => {
setCompletedTutorial('table-generator-promotion')
postJSON('/tutorial/table-generator-promotion/complete').catch(
debugConsole.error
)
}, [setCompletedTutorial])
const onDismiss = useCallback(() => {
onClose()
sendMB('table-generator-promotion-dismissed')
}, [onClose])
const onComplete = useCallback(() => {
onClose()
sendMB('table-generator-promotion-completed')
}, [onClose])
useEffect(() => {
const interval = setTimeout(() => {
setTimeoutExpired(true)
}, DELAY_BEFORE_SHOWING_PROMOTION)
return () => clearTimeout(interval)
}, [])
const [currentPage, setCurrentPage] = useState<number>(0)
const nextPage = useCallback(() => {
setCurrentPage(cur => clamp(cur + 1))
sendMB('table-generator-promotion-next-page')
}, [])
const page = PROMOTION_PAGES[clamp(currentPage)]
const PageComponent = page.body
const pageTitle = page.title
const isAtLastPage = currentPage >= PROMOTION_PAGES.length - 1
const hideBecauseOfGrammarly =
grammarlyExtensionPresent() && NOW_TIME < GRAMMARLY_CUTOFF_TIME
if (
!timeoutExpired ||
!ref ||
typeof ref === 'function' ||
!ref.current ||
hideBecauseOfGrammarly
) {
return null
}
return (
<Overlay
placement="bottom"
show
target={ref.current}
shouldUpdatePosition
>
<Popover
id="table-generator-and-pasting-formatted-text-promotion"
title={
<span>
{pageTitle}
<Close variant="dark" onDismiss={onDismiss} />
</span>
}
className="dark-themed"
>
<PromotionBody>
<PageComponent />
</PromotionBody>
<Footer
isAtLastPage={isAtLastPage}
onComplete={onComplete}
onNext={nextPage}
/>
</Popover>
</Overlay>
)
})
)
const PromotionBody: FC = function PromotionBody({ children }) {
useEffect(() => {
sendMB('table-generator-promotion-prompt')
}, [])
return <div style={{ maxWidth: '440px' }}>{children}</div>
}
const Footer = memo<{
isAtLastPage: boolean
onComplete: () => void
onNext: () => void
}>(function Footer({ isAtLastPage, onComplete, onNext }) {
return (
<div
style={{
display: 'flex',
gap: '8px',
justifyContent: 'space-between',
flexDirection: 'row-reverse',
marginTop: '9px',
}}
>
{!isAtLastPage ? (
<Button bsStyle={null} className="btn-secondary" onClick={onNext}>
Next new feature
</Button>
) : (
<Button bsStyle={null} className="btn-secondary" onClick={onComplete}>
Close
</Button>
)}
</div>
)
})
const TablePromotionPage: FC = memo(function TablePromotionPage() {
return (
<>
<p>
You can now insert tables with just a few clicks and edit them without
code in <b>Visual Editor</b>. And theres more&#8230;
</p>
<Video src="https://videos.ctfassets.net/nrgyaltdicpt/4NlPEKtrm6ElDN51KwUmkk/5a12df93b79cbded85e26a75a3fd1232/table_440.mp4" />
</>
)
})
const PastingPromotionPage: FC = memo(function PastingPromotionPage() {
return (
<>
<p>
You can also paste tables (and formatted text!) straight into{' '}
<b>Visual Editor</b> without losing the formatting.
</p>
<Video src="https://videos.ctfassets.net/nrgyaltdicpt/57lKn5gFNsgz7nCvOJh6a4/e9f8ae6d41a357102363f04a0b3587b9/paste_440.mp4" />
</>
)
})
const PROMOTION_PAGES: { title: string; body: FC }[] = [
{
title: 'Big news! Insert tables from the toolbar',
body: TablePromotionPage,
},
{
title: 'Paste a table straight into Visual Editor',
body: PastingPromotionPage,
},
]
const clamp = (num: number) =>
Math.min(Math.max(num, 0), PROMOTION_PAGES.length - 1)
interface VideoProps extends React.VideoHTMLAttributes<HTMLVideoElement> {
src: string
}
const Video = memo<VideoProps>(function Video({ src, ...props }: VideoProps) {
return (
<video
src={src}
preload="auto"
autoPlay
muted
loop
controls={false}
style={{
width: '100%',
margin: 'auto',
display: 'block',
}}
{...props}
/>
)
})

View file

@ -16,6 +16,7 @@ UserContext.Provider.propTypes = {
alphaProgram: PropTypes.boolean, alphaProgram: PropTypes.boolean,
betaProgram: PropTypes.boolean, betaProgram: PropTypes.boolean,
labsProgram: PropTypes.boolean, labsProgram: PropTypes.boolean,
signUpDate: PropTypes.string,
features: PropTypes.shape({ features: PropTypes.shape({
dropbox: PropTypes.boolean, dropbox: PropTypes.boolean,
github: PropTypes.boolean, github: PropTypes.boolean,

View file

@ -102,6 +102,9 @@ export const Visual = (args: any, { globals: { theme } }: any) => {
'paste-html': 'enabled', 'paste-html': 'enabled',
'table-generator': 'enabled', 'table-generator': 'enabled',
}, },
'ol-completedTutorials': {
'table-generator-promotion': '2023-09-01T00:00:00.000Z',
},
}) })
return <SourceEditor /> return <SourceEditor />

View file

@ -114,6 +114,9 @@ describe('<CodeMirrorEditor/> Table editor', function () {
window.metaAttributesCache.set('ol-splitTestVariants', { window.metaAttributesCache.set('ol-splitTestVariants', {
'table-generator': 'enabled', 'table-generator': 'enabled',
}) })
window.metaAttributesCache.set('ol-completedTutorials', {
'table-generator-promotion': '2023-09-01T00:00:00.000Z',
})
}) })
describe('Table rendering', function () { describe('Table rendering', function () {

View file

@ -2,6 +2,7 @@ export type User = {
id: string id: string
email: string email: string
allowedFreeTrial?: boolean allowedFreeTrial?: boolean
signUpDate?: string // date string
features?: { features?: {
collaborators?: number collaborators?: number
compileGroup?: 'standard' | 'priority' compileGroup?: 'standard' | 'priority'