theluyuan 38ba663466
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled
init
2025-09-02 14:49:16 +08:00

373 lines
12 KiB
TypeScript

import { Theme } from '@mui/material';
import React, { isValidElement, useCallback, useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { keyframes } from 'tss-react';
import { makeStyles } from 'tss-react/mui';
import Icon from '../../../base/icons/components/Icon';
import {
IconCheck,
IconCloseLarge,
IconInfo,
IconMessage,
IconUser,
IconUsers,
IconWarningCircle
} from '../../../base/icons/svg';
import Message from '../../../base/react/components/web/Message';
import { getSupportUrl } from '../../../base/react/functions';
import { NOTIFICATION_ICON, NOTIFICATION_TYPE } from '../../constants';
import { INotificationProps } from '../../types';
import { NotificationsTransitionContext } from '../NotificationsTransition';
interface IProps extends INotificationProps {
/**
* Callback invoked when the user clicks to dismiss the notification.
*/
onDismissed: Function;
}
/**
* Secondary colors for notification icons.
*
* @type {{error, info, normal, success, warning}}
*/
const useStyles = makeStyles()((theme: Theme) => {
return {
container: {
backgroundColor: theme.palette.ui10,
padding: '8px 16px 8px 20px',
display: 'flex',
position: 'relative' as const,
borderRadius: `${theme.shape.borderRadius}px`,
boxShadow: '0px 6px 20px rgba(0, 0, 0, 0.25)',
marginBottom: theme.spacing(2),
'&:last-of-type': {
marginBottom: 0
},
animation: `${keyframes`
0% {
opacity: 0;
transform: translateX(-80%);
}
100% {
opacity: 1;
transform: translateX(0);
}
`} 0.2s forwards ease`,
'&.unmount': {
animation: `${keyframes`
0% {
opacity: 1;
transform: translateX(0);
}
100% {
opacity: 0;
transform: translateX(-80%);
}
`} 0.2s forwards ease`
}
},
ribbon: {
width: '4px',
height: 'calc(100% - 16px)',
position: 'absolute' as const,
left: 0,
top: '8px',
borderRadius: '4px',
'&.normal': {
backgroundColor: theme.palette.action01
},
'&.error': {
backgroundColor: theme.palette.iconError
},
'&.success': {
backgroundColor: theme.palette.success01
},
'&.warning': {
backgroundColor: theme.palette.warning01
}
},
content: {
display: 'flex',
alignItems: 'flex-start',
padding: '8px 0',
flex: 1,
maxWidth: '100%'
},
textContainer: {
display: 'flex',
flexDirection: 'column' as const,
justifyContent: 'space-between',
color: theme.palette.text04,
flex: 1,
margin: '0 8px',
// maxWidth: 100% minus the icon on left (20px) minus the close icon on the right (20px) minus the margins
maxWidth: 'calc(100% - 40px - 16px)',
maxHeight: '150px'
},
title: {
...theme.typography.bodyShortBold
},
description: {
...theme.typography.bodyShortRegular,
overflow: 'auto',
overflowWrap: 'break-word',
userSelect: 'all',
'&:not(:empty)': {
marginTop: theme.spacing(1)
}
},
actionsContainer: {
display: 'flex',
width: '100%',
'&:not(:empty)': {
marginTop: theme.spacing(2)
}
},
action: {
border: 0,
outline: 0,
backgroundColor: 'transparent',
color: theme.palette.action01,
...theme.typography.bodyShortBold,
marginRight: theme.spacing(3),
padding: 0,
cursor: 'pointer',
'&:last-of-type': {
marginRight: 0
},
'&.destructive': {
color: theme.palette.textError
},
'&:focus-visible': {
outline: `2px solid ${theme.palette.action01}`,
outlineOffset: 2
}
},
closeIcon: {
cursor: 'pointer'
}
};
});
const Notification = ({
appearance = NOTIFICATION_TYPE.NORMAL,
customActionHandler,
customActionNameKey,
customActionType,
description,
descriptionArguments,
descriptionKey,
disableClosing,
hideErrorSupportLink,
icon,
onDismissed,
title,
titleArguments,
titleKey,
uid
}: IProps) => {
const { classes, cx, theme } = useStyles();
const { t } = useTranslation();
const { unmounting } = useContext(NotificationsTransitionContext);
const supportUrl = useSelector(getSupportUrl);
const isErrorOrWarning = useMemo(
() => appearance === NOTIFICATION_TYPE.ERROR || appearance === NOTIFICATION_TYPE.WARNING,
[ appearance ]
);
const ICON_COLOR = {
error: theme.palette.iconError,
normal: theme.palette.action01,
success: theme.palette.success01,
warning: theme.palette.warning01
};
const onDismiss = useCallback(() => {
onDismissed(uid);
}, [ uid ]);
// eslint-disable-next-line react/no-multi-comp
const renderDescription = useCallback(() => {
const descriptionArray = [];
descriptionKey
&& descriptionArray.push(t(descriptionKey, descriptionArguments));
description && typeof description === 'string' && descriptionArray.push(description);
// Keeping in mind that:
// - Notifications that use the `translateToHtml` function get an element-based description array with one entry
// - Message notifications receive string-based description arrays that might need additional parsing
// We look for ready-to-render elements, and if present, we roll with them
// Otherwise, we use the Message component that accepts a string `text` prop
const shouldRenderHtml = descriptionArray.length === 1 && isValidElement(descriptionArray[0]);
// the id is used for testing the UI
return (
<div
className = { classes.description }
data-testid = { descriptionKey } >
{shouldRenderHtml ? descriptionArray : <Message text = { descriptionArray.join(' ') } />}
{typeof description === 'object' && description}
</div>
);
}, [ description, descriptionArguments, descriptionKey, classes ]);
const _onOpenSupportLink = useCallback(() => {
window.open(supportUrl, '_blank', 'noopener');
}, [ supportUrl ]);
const mapAppearanceToButtons = useCallback((): {
content: string; onClick: () => void; testId?: string; type?: string; }[] => {
switch (appearance) {
case NOTIFICATION_TYPE.ERROR: {
const buttons = [
{
content: t('dialog.dismiss'),
onClick: onDismiss
}
];
if (!hideErrorSupportLink && supportUrl) {
buttons.push({
content: t('dialog.contactSupport'),
onClick: _onOpenSupportLink
});
}
return buttons;
}
case NOTIFICATION_TYPE.WARNING:
return [
{
content: t('dialog.Ok'),
onClick: onDismiss
}
];
default:
if (customActionNameKey?.length && customActionHandler?.length) {
return customActionNameKey.map((customAction: string, customActionIndex: number) => {
return {
content: t(customAction),
onClick: () => {
if (customActionHandler?.[customActionIndex]()) {
onDismiss();
}
},
type: customActionType?.[customActionIndex],
testId: customAction
};
});
}
return [];
}
}, [ appearance, onDismiss, customActionHandler, customActionNameKey, hideErrorSupportLink, supportUrl ]);
const getIcon = useCallback(() => {
let iconToDisplay;
switch (icon || appearance) {
case NOTIFICATION_ICON.ERROR:
case NOTIFICATION_ICON.WARNING:
iconToDisplay = IconWarningCircle;
break;
case NOTIFICATION_ICON.SUCCESS:
iconToDisplay = IconCheck;
break;
case NOTIFICATION_ICON.MESSAGE:
iconToDisplay = IconMessage;
break;
case NOTIFICATION_ICON.PARTICIPANT:
iconToDisplay = IconUser;
break;
case NOTIFICATION_ICON.PARTICIPANTS:
iconToDisplay = IconUsers;
break;
default:
iconToDisplay = IconInfo;
break;
}
return iconToDisplay;
}, [ icon, appearance ]);
return (
<div
aria-atomic = { true }
aria-live = { isErrorOrWarning ? 'assertive' : 'polite' }
className = { cx(classes.container, (unmounting.get(uid ?? '') && 'unmount') as string | undefined) }
data-testid = { titleKey || descriptionKey }
id = { uid }
role = { isErrorOrWarning ? 'alert' : 'status' }>
<div className = { cx(classes.ribbon, appearance) } />
<div className = { classes.content }>
<div className = { icon }>
<Icon
color = { ICON_COLOR[appearance as keyof typeof ICON_COLOR] }
size = { 20 }
src = { getIcon() } />
</div>
<div className = { classes.textContainer }>
<span className = { classes.title }>{title || t(titleKey ?? '', titleArguments)}</span>
{renderDescription()}
<div className = { classes.actionsContainer }>
{mapAppearanceToButtons().map(({ content, onClick, type, testId }) => (
<button
aria-label = { content }
className = { cx(classes.action, type) }
data-testid = { testId }
key = { content }
onClick = { onClick }
type = 'button'>
{content}
</button>
))}
</div>
</div>
{ !disableClosing && (
<Icon
className = { classes.closeIcon }
color = { theme.palette.icon04 }
id = 'close-notification'
onClick = { onDismiss }
size = { 20 }
src = { IconCloseLarge }
tabIndex = { 0 }
testId = { `${titleKey || descriptionKey}-dismiss` } />
)}
</div>
</div>
);
};
export default Notification;