mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
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:
parent
d76c0e2688
commit
dc86b0285a
8 changed files with 294 additions and 2 deletions
|
@ -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.
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 there’s more…
|
||||||
|
</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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
|
@ -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,
|
||||||
|
|
|
@ -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 />
|
||||||
|
|
|
@ -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 () {
|
||||||
|
|
|
@ -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'
|
||||||
|
|
Loading…
Reference in a new issue