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

GitOrigin-RevId: 7f7ab83d4615f564c0e79bc2a05ca6cb9e5239fd
This commit is contained in:
Mathias Jakobsen 2024-11-08 15:01:54 +02:00 committed by Copybot
parent a50d76f4ea
commit cb72799fff
14 changed files with 363 additions and 1 deletions

View file

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

View file

@ -14,6 +14,10 @@
"a_fatal_compile_error_that_completely_blocks_compilation": "", "a_fatal_compile_error_that_completely_blocks_compilation": "",
"a_file_with_that_name_already_exists_and_will_be_overriden": "", "a_file_with_that_name_already_exists_and_will_be_overriden": "",
"a_more_comprehensive_list_of_keyboard_shortcuts": "", "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_archive_projects": "",
"about_to_delete_cert": "", "about_to_delete_cert": "",
"about_to_delete_projects": "", "about_to_delete_projects": "",

View file

@ -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>
)
}

View file

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

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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;
}
}

View file

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

View file

@ -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;
}
}

View file

@ -16,6 +16,10 @@
"a_fatal_compile_error_that_completely_blocks_compilation": "A <0>fatal compile error</0> that completely blocks the compilation.", "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_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_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": "About",
"about_to_archive_projects": "You are about to archive the following projects:", "about_to_archive_projects": "You are about to archive the following projects:",
"about_to_delete_cert": "You are about to delete the following certificate:", "about_to_delete_cert": "You are about to delete the following certificate:",