Просмотр исходного кода

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

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

GitOrigin-RevId: 9096b36ac4f07d1fc862ecc3bd5df033348950ab
Mathias Jakobsen 2 лет назад
Родитель
Сommit
dc86b0285a

+ 11 - 0
services/web/app/src/Features/Project/ProjectController.js

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

+ 4 - 1
services/web/app/src/Features/Tutorial/TutorialController.js

@@ -2,7 +2,10 @@ const SessionManager = require('../Authentication/SessionManager')
 const TutorialHandler = require('./TutorialHandler')
 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) {
   const userId = SessionManager.getLoggedInUserId(req.session)

+ 2 - 1
services/web/frontend/js/features/source-editor/components/editor-switch.tsx

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

+ 269 - 0
services/web/frontend/js/features/source-editor/components/table-generator/promotion/popover.tsx

@@ -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&#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}
+    />
+  )
+})

+ 1 - 0
services/web/frontend/js/shared/context/user-context.js

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

+ 3 - 0
services/web/frontend/stories/source-editor/source-editor.stories.tsx

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

+ 3 - 0
services/web/test/frontend/features/source-editor/components/codemirror-editor-table-generator.spec.tsx

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

+ 1 - 0
services/web/types/user.ts

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