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

Merge pull request #14379 from overleaf/jdt-history-onboarding

history onboarding popover

GitOrigin-RevId: 4afe2ae54317341df64e83bed0b3c94875eb10a0
Jimmy Domagala-Tang 2 лет назад
Родитель
Сommit
462b7a2256

+ 1 - 0
services/web/app/views/project/editor/meta.pug

@@ -39,6 +39,7 @@ meta(name="ol-showPersonalAccessToken", data-type="boolean" content=showPersonal
 meta(name="ol-isReviewPanelReact", data-type="boolean" content=isReviewPanelReact)
 meta(name="ol-isReviewPanelReact", data-type="boolean" content=isReviewPanelReact)
 meta(name="ol-hasTrackChangesFeature", data-type="boolean" content=hasTrackChangesFeature)
 meta(name="ol-hasTrackChangesFeature", data-type="boolean" content=hasTrackChangesFeature)
 meta(name="ol-mathJax3Path" content=mathJax3Path)
 meta(name="ol-mathJax3Path" content=mathJax3Path)
+meta(name="ol-completedTutorials", data-type="json" content=user.completedTutorials)
 
 
 - var fileActionI18n = ['edited', 'renamed', 'created', 'deleted'].reduce((acc, i) => {acc[i] = translate('file_action_' + i); return acc}, {})
 - var fileActionI18n = ['edited', 'renamed', 'created', 'deleted'].reduce((acc, i) => {acc[i] = translate('file_action_' + i); return acc}, {})
 meta(name="ol-fileActionI18n" data-type="json" content=fileActionI18n)
 meta(name="ol-fileActionI18n" data-type="json" content=fileActionI18n)

+ 3 - 0
services/web/frontend/extracted-translations.json

@@ -798,6 +798,9 @@
   "quoted_text_in": "",
   "quoted_text_in": "",
   "raw_logs": "",
   "raw_logs": "",
   "raw_logs_description": "",
   "raw_logs_description": "",
+  "react_history_tutorial_content": "",
+  "react_history_tutorial_learn_more": "",
+  "react_history_tutorial_title": "",
   "reactivate_subscription": "",
   "reactivate_subscription": "",
   "read_only": "",
   "read_only": "",
   "read_only_token": "",
   "read_only_token": "",

+ 40 - 1
services/web/frontend/js/features/history/components/change-list/all-history-list.tsx

@@ -3,10 +3,23 @@ import HistoryVersion from './history-version'
 import LoadingSpinner from '../../../../shared/components/loading-spinner'
 import LoadingSpinner from '../../../../shared/components/loading-spinner'
 import { OwnerPaywallPrompt } from './owner-paywall-prompt'
 import { OwnerPaywallPrompt } from './owner-paywall-prompt'
 import { NonOwnerPaywallPrompt } from './non-owner-paywall-prompt'
 import { NonOwnerPaywallPrompt } from './non-owner-paywall-prompt'
-import { isVersionSelected } from '../../utils/history-details'
+import {
+  isVersionSelected,
+  ItemSelectionState,
+} from '../../utils/history-details'
 import { useUserContext } from '../../../../shared/context/user-context'
 import { useUserContext } from '../../../../shared/context/user-context'
 import useDropdownActiveItem from '../../hooks/use-dropdown-active-item'
 import useDropdownActiveItem from '../../hooks/use-dropdown-active-item'
 import { useHistoryContext } from '../../context/history-context'
 import { useHistoryContext } from '../../context/history-context'
+import getMeta from '../../../../utils/meta'
+
+type CompletedTutorials = {
+  'react-history-buttons-tutorial': Date
+}
+
+const unselectedStates: ItemSelectionState[] = [
+  'aboveSelected',
+  'belowSelected',
+]
 
 
 function AllHistoryList() {
 function AllHistoryList() {
   const { id: currentUserId } = useUserContext()
   const { id: currentUserId } = useUserContext()
@@ -84,6 +97,27 @@ function AllHistoryList() {
     }
     }
   }, [updatesLoadingState])
   }, [updatesLoadingState])
 
 
+  const completedTutorials: CompletedTutorials = getMeta(
+    'ol-completedTutorials'
+  )
+
+  // only show tutorial popover if they havent dismissed ("completed") it yet
+  const hasCompletedHistTutorial = Boolean(
+    completedTutorials?.['react-history-buttons-tutorial']
+  )
+
+  // only show tutorial popover on the first icon
+  const firstUnselectedIndex = visibleUpdates.findIndex(update => {
+    const selectionState = isVersionSelected(
+      selection,
+      update.fromV,
+      update.toV
+    )
+    return unselectedStates.includes(selectionState)
+  })
+
+  const [showTutorial, setShowTutorial] = useState(!hasCompletedHistTutorial)
+
   return (
   return (
     <div ref={scrollerRef} className="history-all-versions-scroller">
     <div ref={scrollerRef} className="history-all-versions-scroller">
       <div className="history-all-versions-container">
       <div className="history-all-versions-container">
@@ -110,6 +144,9 @@ function AllHistoryList() {
               selected === 'aboveSelected' ||
               selected === 'aboveSelected' ||
               selected === 'belowSelected')
               selected === 'belowSelected')
 
 
+          const hasTutorialOverlay =
+            index === firstUnselectedIndex && showTutorial
+
           return (
           return (
             <HistoryVersion
             <HistoryVersion
               key={`${update.fromV}_${update.toV}`}
               key={`${update.fromV}_${update.toV}`}
@@ -129,6 +166,8 @@ function AllHistoryList() {
                 activeDropdownItem.isOpened && compareDropdownActive
                 activeDropdownItem.isOpened && compareDropdownActive
               }
               }
               dropdownActive={dropdownActive}
               dropdownActive={dropdownActive}
+              hasTutorialOverlay={hasTutorialOverlay}
+              setShowTutorial={setShowTutorial}
             />
             />
           )
           )
         })}
         })}

+ 111 - 2
services/web/frontend/js/features/history/components/change-list/history-version.tsx

@@ -1,3 +1,15 @@
+import {
+  useRef,
+  useCallback,
+  memo,
+  useEffect,
+  useState,
+  ReactNode,
+} from 'react'
+import { Popover, Overlay } from 'react-bootstrap'
+import { useTranslation, Trans } from 'react-i18next'
+import Close from '../../../../shared/components/close'
+import MaterialIcon from '../../../../shared/components/material-icon'
 import HistoryVersionDetails from './history-version-details'
 import HistoryVersionDetails from './history-version-details'
 import TagTooltip from './tag-tooltip'
 import TagTooltip from './tag-tooltip'
 import Changes from './changes'
 import Changes from './changes'
@@ -13,10 +25,11 @@ import {
   ItemSelectionState,
   ItemSelectionState,
 } from '../../utils/history-details'
 } from '../../utils/history-details'
 import { ActiveDropdown } from '../../hooks/use-dropdown-active-item'
 import { ActiveDropdown } from '../../hooks/use-dropdown-active-item'
-import { memo, useCallback } from 'react'
+import useAsync from '../../../../shared/hooks/use-async'
 import { HistoryContextValue } from '../../context/types/history-context-value'
 import { HistoryContextValue } from '../../context/types/history-context-value'
 import VersionDropdownContent from './dropdown/version-dropdown-content'
 import VersionDropdownContent from './dropdown/version-dropdown-content'
 import CompareItems from './dropdown/menu-item/compare-items'
 import CompareItems from './dropdown/menu-item/compare-items'
+import { completeHistoryTutorial } from '../../services/api'
 import CompareVersionDropdown from './dropdown/compare-version-dropdown'
 import CompareVersionDropdown from './dropdown/compare-version-dropdown'
 import { CompareVersionDropdownContentAllHistory } from './dropdown/compare-version-dropdown-content'
 import { CompareVersionDropdownContentAllHistory } from './dropdown/compare-version-dropdown-content'
 
 
@@ -35,6 +48,8 @@ type HistoryVersionProps = {
   compareDropdownActive: boolean
   compareDropdownActive: boolean
   setActiveDropdownItem: ActiveDropdown['setActiveDropdownItem']
   setActiveDropdownItem: ActiveDropdown['setActiveDropdownItem']
   closeDropdownForItem: ActiveDropdown['closeDropdownForItem']
   closeDropdownForItem: ActiveDropdown['closeDropdownForItem']
+  hasTutorialOverlay?: boolean
+  setShowTutorial: (show: boolean) => void
 }
 }
 
 
 function HistoryVersion({
 function HistoryVersion({
@@ -52,8 +67,97 @@ function HistoryVersion({
   compareDropdownActive,
   compareDropdownActive,
   setActiveDropdownItem,
   setActiveDropdownItem,
   closeDropdownForItem,
   closeDropdownForItem,
+  hasTutorialOverlay = false,
+  setShowTutorial,
 }: HistoryVersionProps) {
 }: HistoryVersionProps) {
   const orderedLabels = orderBy(update.labels, ['created_at'], ['desc'])
   const orderedLabels = orderBy(update.labels, ['created_at'], ['desc'])
+  const iconRef = useRef<HTMLDivElement>(null)
+
+  const { runAsync } = useAsync()
+
+  const { t } = useTranslation()
+
+  const [popover, setPopover] = useState<ReactNode | null>(null)
+  // wait for the layout to settle before showing popover, to avoid a flash/ instant move
+  const [layoutSettled, setLayoutSettled] = useState(false)
+
+  useEffect(() => {
+    if (iconRef.current && hasTutorialOverlay && layoutSettled) {
+      const dismissModal = () => {
+        setShowTutorial(false)
+        runAsync(completeHistoryTutorial()).catch(console.error)
+      }
+
+      const compareIcon = (
+        <MaterialIcon
+          type="align_end"
+          className="material-symbols-rounded history-dropdown-icon-inverted"
+        />
+      )
+
+      setPopover(
+        <Overlay
+          placement="left"
+          show={hasTutorialOverlay}
+          target={iconRef.current ?? undefined}
+          shouldUpdatePosition
+        >
+          <Popover
+            id="popover-toolbar-overflow"
+            title={
+              <span>
+                {t('react_history_tutorial_title')}{' '}
+                <Close variant="dark" onDismiss={() => dismissModal()} />
+              </span>
+            }
+            className="dark-themed"
+          >
+            <Trans
+              i18nKey="react_history_tutorial_content"
+              components={[compareIcon]} // eslint-disable-line react/jsx-key
+            />
+            <a href="https://www.overleaf.com/learn/latex/Using_the_History_feature">
+              {' '}
+              {t('react_history_tutorial_learn_more')}
+            </a>
+          </Popover>
+        </Overlay>
+      )
+    }
+  }, [hasTutorialOverlay, runAsync, setShowTutorial, t, layoutSettled])
+
+  // give the components time to position before showing popover so we dont get a instant position change
+  useEffect(() => {
+    const timer = window.setTimeout(() => {
+      setLayoutSettled(true)
+    }, 500)
+
+    return () => clearTimeout(timer)
+  }, [setLayoutSettled])
+
+  useEffect(() => {
+    let timer: number | null = null
+
+    const handleResize = () => {
+      // Hide popover when a resize starts, then waiting for a gap of 500ms
+      // with no resizes before making it reappear
+      if (timer) {
+        window.clearTimeout(timer)
+      } else {
+        setShowTutorial(false)
+      }
+      timer = window.setTimeout(() => {
+        timer = null
+        setShowTutorial(true)
+      }, 500)
+    }
+
+    // only need a listener on the component that actually has the popover
+    if (hasTutorialOverlay) {
+      window.addEventListener('resize', handleResize)
+      return () => window.removeEventListener('resize', handleResize)
+    }
+  }, [hasTutorialOverlay, setShowTutorial])
 
 
   const closeDropdown = useCallback(() => {
   const closeDropdown = useCallback(() => {
     closeDropdownForItem(update, 'moreOptions')
     closeDropdownForItem(update, 'moreOptions')
@@ -63,6 +167,7 @@ function HistoryVersion({
 
 
   return (
   return (
     <>
     <>
+      {hasTutorialOverlay && popover}
       {showDivider ? (
       {showDivider ? (
         <div
         <div
           className={classNames({
           className={classNames({
@@ -121,7 +226,11 @@ function HistoryVersion({
           )}
           )}
 
 
           {selected !== 'selected' ? (
           {selected !== 'selected' ? (
-            <div data-testid="compare-icon-version" className="pull-right">
+            <div
+              data-testid="compare-icon-version"
+              className="pull-right"
+              ref={iconRef}
+            >
               {selected !== 'withinSelected' ? (
               {selected !== 'withinSelected' ? (
                 <CompareItems
                 <CompareItems
                   updateRange={updateRange}
                   updateRange={updateRange}

+ 4 - 0
services/web/frontend/js/features/history/services/api.ts

@@ -50,6 +50,10 @@ export function deleteLabel(
   return deleteJSON(`/project/${projectId}/labels/${labelId}`, { signal })
   return deleteJSON(`/project/${projectId}/labels/${labelId}`, { signal })
 }
 }
 
 
+export function completeHistoryTutorial() {
+  return postJSON('/tutorial/react-history-buttons-tutorial/complete')
+}
+
 export function diffFiles(
 export function diffFiles(
   projectId: string,
   projectId: string,
   fromV: number,
   fromV: number,

+ 1 - 1
services/web/frontend/js/features/project-list/components/notifications/notification.tsx

@@ -2,7 +2,7 @@ import { useState } from 'react'
 import { Alert, AlertProps } from 'react-bootstrap'
 import { Alert, AlertProps } from 'react-bootstrap'
 import Body from './body'
 import Body from './body'
 import Action from './action'
 import Action from './action'
-import Close from './close'
+import Close from '../../../../shared/components/close'
 import classnames from 'classnames'
 import classnames from 'classnames'
 
 
 type NotificationProps = {
 type NotificationProps = {

+ 1 - 1
services/web/frontend/js/features/project-list/components/notifications/system-message.tsx

@@ -1,4 +1,4 @@
-import Close from './close'
+import Close from '../../../../shared/components/close'
 import usePersistedState from '../../../../shared/hooks/use-persisted-state'
 import usePersistedState from '../../../../shared/hooks/use-persisted-state'
 
 
 type SystemMessageProps = {
 type SystemMessageProps = {

+ 1 - 1
services/web/frontend/js/features/project-list/components/notifications/translation-message.tsx

@@ -1,5 +1,5 @@
 import { Trans, useTranslation } from 'react-i18next'
 import { Trans, useTranslation } from 'react-i18next'
-import Close from './close'
+import Close from '../../../../shared/components/close'
 import usePersistedState from '../../../../shared/hooks/use-persisted-state'
 import usePersistedState from '../../../../shared/hooks/use-persisted-state'
 import getMeta from '../../../../utils/meta'
 import getMeta from '../../../../utils/meta'
 import { SuggestedLanguage } from '../../../../../../types/project/dashboard/system-message'
 import { SuggestedLanguage } from '../../../../../../types/project/dashboard/system-message'

+ 7 - 2
services/web/frontend/js/features/project-list/components/notifications/close.tsx → services/web/frontend/js/shared/components/close.tsx

@@ -2,13 +2,18 @@ import { useTranslation } from 'react-i18next'
 
 
 type CloseProps = {
 type CloseProps = {
   onDismiss: React.MouseEventHandler<HTMLButtonElement>
   onDismiss: React.MouseEventHandler<HTMLButtonElement>
+  variant?: 'light' | 'dark'
 }
 }
 
 
-function Close({ onDismiss }: CloseProps) {
+function Close({ onDismiss, variant = 'light' }: CloseProps) {
   const { t } = useTranslation()
   const { t } = useTranslation()
 
 
   return (
   return (
-    <button type="button" className="close pull-right" onClick={onDismiss}>
+    <button
+      type="button"
+      className={`close pull-right ${variant}`}
+      onClick={onDismiss}
+    >
       <span aria-hidden="true">&times;</span>
       <span aria-hidden="true">&times;</span>
       <span className="sr-only">{t('close')}</span>
       <span className="sr-only">{t('close')}</span>
     </button>
     </button>

+ 10 - 0
services/web/frontend/stylesheets/app/base.less

@@ -21,6 +21,16 @@
   text-shadow: none;
   text-shadow: none;
 }
 }
 
 
+.light.close {
+  color: #000;
+}
+
+.dark.close {
+  color: #fff;
+  opacity: 1;
+  text-shadow: none;
+}
+
 .clickable {
 .clickable {
   cursor: pointer;
   cursor: pointer;
 }
 }

+ 5 - 0
services/web/frontend/stylesheets/app/editor/history-react.less

@@ -406,6 +406,11 @@ history-root {
   color: @neutral-90;
   color: @neutral-90;
 }
 }
 
 
+.history-dropdown-icon-inverted {
+  color: @neutral-10;
+  vertical-align: top;
+}
+
 .history-compare-btn {
 .history-compare-btn {
   line-height: 1;
   line-height: 1;
   padding: 0;
   padding: 0;

+ 51 - 0
services/web/frontend/stylesheets/components/popovers.less

@@ -137,3 +137,54 @@
     }
     }
   }
   }
 }
 }
+
+.popover.dark-themed {
+  .popover-title {
+    color: @popover-dark-color;
+    background-color: @popover-dark-bg;
+    border-bottom: 0;
+    font-family: Lato, sans-serif;
+    font-weight: bold;
+  }
+
+  .popover-content {
+    color: @popover-dark-color;
+    background-color: @popover-dark-bg;
+  }
+
+  a {
+    color: #97b6e5;
+  }
+
+  padding: 0;
+  border: 1px solid @popover-dark-border-color;
+  max-width: @popover-dark-max-width;
+}
+
+.popover.dark-themed {
+  &.top > .arrow {
+    border-top-color: @popover-dark-arrow-outer-color;
+    &:after {
+      border-top-color: @popover-dark-arrow-color;
+    }
+  }
+  &.right > .arrow {
+    border-right-color: @popover-dark-arrow-outer-color;
+    &:after {
+      border-right-color: @popover-dark-arrow-color;
+    }
+  }
+  &.bottom > .arrow {
+    border-bottom-color: @popover-dark-arrow-outer-color;
+    &:after {
+      border-bottom-color: @popover-dark-arrow-color;
+    }
+  }
+
+  &.left > .arrow {
+    border-left-color: @popover-dark-arrow-outer-color;
+    &:after {
+      border-left-color: @popover-dark-arrow-color;
+    }
+  }
+}

+ 21 - 0
services/web/frontend/stylesheets/core/variables.less

@@ -595,6 +595,27 @@
   20%
   20%
 );
 );
 
 
+//== Popovers dark theme
+//
+//##
+//** Popover body background color
+@popover-dark-bg: #000;
+//** Popover font color
+@popover-dark-color: #fff;
+//** Popover maximum width
+@popover-dark-max-width: 512px;
+//** Popover border color
+@popover-dark-border-color: #000;
+
+//** Popover title background color
+@popover-dark-title-bg: #000;
+
+//** Popover arrow color
+@popover-dark-arrow-color: #000;
+
+//** Popover outer arrow color
+@popover-dark-arrow-outer-color: fadein(@popover-dark-border-color, 5%);
+
 //== Labels
 //== Labels
 //
 //
 //##
 //##

+ 21 - 0
services/web/frontend/stylesheets/variables/all.less

@@ -532,6 +532,27 @@
   20%
   20%
 );
 );
 
 
+//== Popovers dark theme
+//
+//##
+//** Popover body background color
+@popover-dark-bg: #000;
+//** Popover font color
+@popover-dark-color: #fff;
+//** Popover maximum width
+@popover-dark-max-width: 512px;
+//** Popover border color
+@popover-dark-border-color: #000;
+
+//** Popover title background color
+@popover-dark-title-bg: #000;
+
+//** Popover arrow color
+@popover-dark-arrow-color: #000;
+
+//** Popover outer arrow color
+@popover-dark-arrow-outer-color: fadein(@popover-dark-border-color, 5%);
+
 //== Labels
 //== Labels
 //
 //
 //##
 //##

+ 3 - 0
services/web/locales/en.json

@@ -1299,6 +1299,9 @@
   "quoted_text_in": "Quoted text in",
   "quoted_text_in": "Quoted text in",
   "raw_logs": "Raw logs",
   "raw_logs": "Raw logs",
   "raw_logs_description": "Raw logs from the LaTeX compiler",
   "raw_logs_description": "Raw logs from the LaTeX compiler",
+  "react_history_tutorial_content": "To compare a range of versions, use the  <0></0> on the versions you want at the start and end of the range. To add a label or to download a version use the options in the three-dot menu. Learn more about using Overleaf History.",
+  "react_history_tutorial_learn_more": "Learn more about using Overleaf History.",
+  "react_history_tutorial_title": "History actions have a new home",
   "reactivate_subscription": "Reactivate your subscription",
   "reactivate_subscription": "Reactivate your subscription",
   "read_only": "Read Only",
   "read_only": "Read Only",
   "read_only_token": "Read-Only Token",
   "read_only_token": "Read-Only Token",

+ 3 - 0
services/web/test/frontend/features/history/components/change-list.spec.tsx

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