Prechádzať zdrojové kódy

[web] Add toast notifications to editor (#21567)

GitOrigin-RevId: 7f7ab83d4615f564c0e79bc2a05ca6cb9e5239fd
Mathias Jakobsen 1 rok pred
rodič
commit
cb72799fff

+ 1 - 0
services/web/config/settings.defaults.js

@@ -963,6 +963,7 @@ module.exports = {
     settingsEntries: [],
     autoCompleteExtensions: [],
     sectionTitleGenerators: [],
+    toastGenerators: [],
   },
 
   moduleImportSequence: [

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

@@ -14,6 +14,10 @@
   "a_fatal_compile_error_that_completely_blocks_compilation": "",
   "a_file_with_that_name_already_exists_and_will_be_overriden": "",
   "a_more_comprehensive_list_of_keyboard_shortcuts": "",
+  "a_new_reference_was_added": "",
+  "a_new_reference_was_added_from_provider": "",
+  "a_new_reference_was_added_to_file": "",
+  "a_new_reference_was_added_to_file_from_provider": "",
   "about_to_archive_projects": "",
   "about_to_delete_cert": "",
   "about_to_delete_projects": "",

+ 99 - 0
services/web/frontend/js/features/ide-react/components/global-toasts.tsx

@@ -0,0 +1,99 @@
+import { OLToast, OLToastProps } from '@/features/ui/components/ol/ol-toast'
+import useEventListener from '@/shared/hooks/use-event-listener'
+import { Fragment, ReactElement, useCallback, useState } from 'react'
+
+import { debugConsole } from '@/utils/debugging'
+import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
+import { OLToastContainer } from '@/features/ui/components/ol/ol-toast-container'
+
+const moduleGeneratorsImport = importOverleafModules('toastGenerators') as {
+  import: { default: GlobalToastGeneratorEntry[] }
+}[]
+
+const moduleGenerators = moduleGeneratorsImport.map(
+  ({ import: { default: listEntry } }) => listEntry
+)
+
+export type GlobalToastGeneratorEntry = {
+  key: string
+  generator: GlobalToastGenerator
+}
+
+type GlobalToastGenerator = (
+  args: Record<string, any>
+) => Omit<OLToastProps, 'onDismiss'>
+
+const GENERATOR_LIST: GlobalToastGeneratorEntry[] = moduleGenerators.flat()
+const GENERATOR_MAP: Map<string, GlobalToastGenerator> = new Map(
+  GENERATOR_LIST.map(({ key, generator }) => [key, generator])
+)
+
+let toastCounter = 1
+
+export const GlobalToasts = () => {
+  const [toasts, setToasts] = useState<
+    { component: ReactElement; id: string }[]
+  >([])
+
+  const removeToast = useCallback((id: string) => {
+    setToasts(current => current.filter(toast => toast.id !== id))
+  }, [])
+
+  const createToast = useCallback(
+    (id: string, key: string, data: any): ReactElement | null => {
+      const generator = GENERATOR_MAP.get(key)
+      if (!generator) {
+        debugConsole.error('No toast generator found for key:', key)
+        return null
+      }
+
+      const props = generator(data)
+
+      if (!props.autoHide && !props.isDismissible) {
+        // We don't want any toasts that are not dismissible and don't auto-hide
+        props.isDismissible = true
+      }
+      if (props.autoHide && !props.isDismissible && props.delay !== undefined) {
+        // If the toast is auto-hiding but not dismissible, we need to make sure the delay is not too long
+        props.delay = Math.min(props.delay, 60_000)
+      }
+
+      return <OLToast {...props} onDismiss={() => removeToast(id)} />
+    },
+    [removeToast]
+  )
+
+  const addToast = useCallback(
+    (key: string, data?: any) => {
+      const id = `toast-${toastCounter++}`
+      const component = createToast(id, key, data)
+      if (!component) {
+        return
+      }
+      setToasts(current => [...current, { id, component }])
+    },
+    [createToast]
+  )
+
+  const showToastListener = useCallback(
+    (event: CustomEvent) => {
+      if (!event.detail?.key) {
+        debugConsole.error('No key provided for toast')
+        return
+      }
+      const { key, ...rest } = event.detail
+      addToast(key, rest)
+    },
+    [addToast]
+  )
+
+  useEventListener('ide:show-toast', showToastListener)
+
+  return (
+    <OLToastContainer className="global-toasts">
+      {toasts.map(({ component, id }) => (
+        <Fragment key={id}>{component}</Fragment>
+      ))}
+    </OLToastContainer>
+  )
+}

+ 2 - 0
services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx

@@ -9,6 +9,7 @@ import { useRegisterUserActivity } from '@/features/ide-react/hooks/use-register
 import { useHasLintingError } from '@/features/ide-react/hooks/use-has-linting-error'
 import { Modals } from '@/features/ide-react/components/modals/modals'
 import { GlobalAlertsProvider } from '@/features/ide-react/context/global-alerts-context'
+import { GlobalToasts } from '../global-toasts'
 
 export default function IdePage() {
   useLayoutEventTracking() // sent event when the layout changes
@@ -24,6 +25,7 @@ export default function IdePage() {
       <Modals />
       <EditorLeftMenu />
       <MainLayout />
+      <GlobalToasts />
     </GlobalAlertsProvider>
   )
 }

+ 14 - 0
services/web/frontend/js/features/ui/components/bootstrap-3/toast-container.tsx

@@ -0,0 +1,14 @@
+import classNames from 'classnames'
+import { FC, HTMLProps } from 'react'
+
+export const ToastContainer: FC<HTMLProps<HTMLDivElement>> = ({
+  children,
+  className,
+  ...props
+}) => {
+  return (
+    <div className={classNames('toast-container', className)} {...props}>
+      {children}
+    </div>
+  )
+}

+ 51 - 0
services/web/frontend/js/features/ui/components/bootstrap-3/toast.tsx

@@ -0,0 +1,51 @@
+import classNames from 'classnames'
+import { FC, useCallback, useEffect, useRef } from 'react'
+
+type ToastProps = {
+  onClose?: () => void
+  onExited?: () => void
+  autohide?: boolean
+  delay?: number
+  show: boolean
+  className?: string
+}
+export const Toast: FC<ToastProps> = ({
+  children,
+  delay = 5000,
+  onClose,
+  onExited,
+  autohide,
+  show,
+  className,
+}) => {
+  const delayRef = useRef(delay)
+  const onCloseRef = useRef(onClose)
+  const onExitedRef = useRef(onExited)
+  const shouldAutoHide = Boolean(autohide && show)
+
+  const handleTimeout = useCallback(() => {
+    if (shouldAutoHide) {
+      onCloseRef.current?.()
+      onExitedRef.current?.()
+    }
+  }, [shouldAutoHide])
+
+  useEffect(() => {
+    const timeout = window.setTimeout(handleTimeout, delayRef.current)
+    return () => window.clearTimeout(timeout)
+  }, [handleTimeout])
+
+  if (!show) {
+    return null
+  }
+
+  return (
+    <div
+      className={classNames('toast', show ? 'show' : 'hide', className)}
+      aria-live="assertive"
+      role="alert"
+    >
+      {children}
+    </div>
+  )
+}

+ 31 - 0
services/web/frontend/js/features/ui/components/ol/ol-toast-container.tsx

@@ -0,0 +1,31 @@
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+
+import { CSSProperties, FC } from 'react'
+import { ToastContainer as BS5ToastContainer } from 'react-bootstrap-5'
+import { ToastContainer as BS3ToastContainer } from '../bootstrap-3/toast-container'
+
+type OLToastContainerProps = {
+  style?: CSSProperties
+  className?: string
+}
+
+export const OLToastContainer: FC<OLToastContainerProps> = ({
+  children,
+  className,
+  style,
+}) => {
+  return (
+    <BootstrapVersionSwitcher
+      bs5={
+        <BS5ToastContainer className={className} style={style}>
+          {children}
+        </BS5ToastContainer>
+      }
+      bs3={
+        <BS3ToastContainer className={className} style={style}>
+          {children}
+        </BS3ToastContainer>
+      }
+    />
+  )
+}

+ 106 - 0
services/web/frontend/js/features/ui/components/ol/ol-toast.tsx

@@ -0,0 +1,106 @@
+import classNames from 'classnames'
+import { Toast as BS5Toast } from 'react-bootstrap-5'
+import {
+  NotificationIcon,
+  NotificationType,
+} from '../../../../shared/components/notification'
+import { useTranslation } from 'react-i18next'
+import MaterialIcon from '../../../../shared/components/material-icon'
+import { ReactNode, useCallback, useState } from 'react'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import { Toast as BS3Toast } from '../bootstrap-3/toast'
+
+export type OLToastProps = {
+  type: NotificationType
+  className?: string
+  title?: string
+  content: string | ReactNode
+  isDismissible?: boolean
+  onDismiss?: () => void
+  autoHide?: boolean
+  delay?: number
+}
+
+export const OLToast = ({
+  type = 'info',
+  className = '',
+  content,
+  title,
+  isDismissible,
+  onDismiss,
+  autoHide,
+  delay,
+}: OLToastProps) => {
+  const { t } = useTranslation()
+  const [show, setShow] = useState(true)
+
+  const toastClassName = classNames(
+    'notification',
+    `notification-type-${type}`,
+    className,
+    'toast-content'
+  )
+
+  const handleClose = useCallback(() => {
+    setShow(false)
+  }, [])
+
+  const handleOnHidden = useCallback(() => {
+    if (onDismiss) onDismiss()
+  }, [onDismiss])
+
+  const toastElement = (
+    <div className={toastClassName}>
+      <NotificationIcon notificationType={type} />
+
+      <div className="notification-content-and-cta">
+        <div className="notification-content">
+          {title && (
+            <p>
+              <b>{title}</b>
+            </p>
+          )}
+          {content}
+        </div>
+      </div>
+
+      {isDismissible && (
+        <div className="notification-close-btn">
+          <button
+            aria-label={t('close')}
+            data-bs-dismiss="toast"
+            onClick={handleClose}
+          >
+            <MaterialIcon type="close" />
+          </button>
+        </div>
+      )}
+    </div>
+  )
+  return (
+    <BootstrapVersionSwitcher
+      bs5={
+        <BS5Toast
+          onClose={handleClose}
+          autohide={autoHide}
+          onExited={handleOnHidden}
+          delay={delay}
+          show={show}
+        >
+          {toastElement}
+        </BS5Toast>
+      }
+      bs3={
+        <BS3Toast
+          onClose={handleClose}
+          autohide={autoHide}
+          delay={delay}
+          onExited={handleOnHidden}
+          show={show}
+        >
+          {toastElement}
+        </BS3Toast>
+      }
+    />
+  )
+}

+ 1 - 1
services/web/frontend/js/shared/components/notification.tsx

@@ -27,7 +27,7 @@ export type NotificationProps = {
   id?: string
 }
 
-function NotificationIcon({
+export function NotificationIcon({
   notificationType,
   customIcon,
 }: {

+ 1 - 0
services/web/frontend/stylesheets/app/editor.less

@@ -7,6 +7,7 @@
 @import './editor/error-boundary.less';
 @import './editor/share.less';
 @import './editor/chat.less';
+@import './editor/toast.less';
 @import './editor/file-view.less';
 @import './editor/search.less';
 @import './editor/publish-template.less';

+ 22 - 0
services/web/frontend/stylesheets/app/editor/toast.less

@@ -0,0 +1,22 @@
+.toast-container {
+  pointer-events: none;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  justify-content: space-between;
+}
+
+.toast-content {
+  pointer-events: auto;
+}
+
+.global-toasts {
+  position: fixed;
+  bottom: 12px;
+  left: 50%;
+  transform: translateX(-50%);
+
+  [role='alert'] {
+    z-index: 20;
+  }
+}

+ 1 - 0
services/web/frontend/stylesheets/bootstrap-5/pages/all.scss

@@ -16,6 +16,7 @@
 @import 'editor/figure-modal';
 @import 'editor/review-panel';
 @import 'editor/chat';
+@import 'editor/toast';
 @import 'editor/history';
 @import 'subscription';
 @import 'editor/pdf';

+ 26 - 0
services/web/frontend/stylesheets/bootstrap-5/pages/editor/toast.scss

@@ -0,0 +1,26 @@
+.toast-container {
+  pointer-events: none;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  justify-content: space-between;
+}
+
+.toast-content {
+  pointer-events: auto;
+}
+
+.toast.showing {
+  opacity: 0;
+}
+
+.global-toasts {
+  position: fixed;
+  bottom: 12px;
+  left: 50%;
+  transform: translateX(-50%);
+
+  [role='alert'] {
+    z-index: 20;
+  }
+}

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

@@ -16,6 +16,10 @@
   "a_fatal_compile_error_that_completely_blocks_compilation": "A <0>fatal compile error</0> that completely blocks the compilation.",
   "a_file_with_that_name_already_exists_and_will_be_overriden": "A file with that name already exists. That file will be overwritten.",
   "a_more_comprehensive_list_of_keyboard_shortcuts": "A more comprehensive list of keyboard shortcuts can be found in <0>this __appName__ project template</0>",
+  "a_new_reference_was_added": "A new reference was added",
+  "a_new_reference_was_added_from_provider": "A new reference was added from __provider__",
+  "a_new_reference_was_added_to_file": "A new reference was added to <0>__filePath__</0>",
+  "a_new_reference_was_added_to_file_from_provider": "A new reference was added to <0>__filePath__</0> from __provider__",
   "about": "About",
   "about_to_archive_projects": "You are about to archive the following projects:",
   "about_to_delete_cert": "You are about to delete the following certificate:",