feat(notifications) Use our own component

Use our component instead of atlaskit
Implement redesign
Add unmount animation
Change mount animation from external lib to CSS
Remove @atlaskit/flag
Remove react-transition-group
Add unmount animation on native
Remove AbstractNotification
This commit is contained in:
robertpin
2023-03-13 15:15:32 +02:00
committed by Calinteodor
parent 2c8c98aaf3
commit 7156df5b99
11 changed files with 486 additions and 690 deletions

View File

@@ -1,117 +1,243 @@
import Flag from '@atlaskit/flag';
import React, { isValidElement } from 'react';
import { Theme } from '@mui/material';
import React, { isValidElement, useCallback, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { keyframes } from 'tss-react';
import { makeStyles } from 'tss-react/mui';
import { translate } from '../../../base/i18n/functions';
import Icon from '../../../base/icons/components/Icon';
import { IconCheck, IconInfo, IconMessage, IconUser, IconUsers, IconWarningCircle } from '../../../base/icons/svg';
import {
IconCheck,
IconCloseLarge,
IconInfo,
IconMessage,
IconUser,
IconUsers,
IconWarningCircle
} from '../../../base/icons/svg';
import Message from '../../../base/react/components/web/Message';
import { colors } from '../../../base/ui/Tokens';
import { withPixelLineHeight } from '../../../base/styles/functions.web';
import { NOTIFICATION_ICON, NOTIFICATION_TYPE } from '../../constants';
import AbstractNotification, { IProps } from '../AbstractNotification';
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 ICON_COLOR = {
error: colors.error06,
normal: colors.primary06,
success: colors.success05,
warning: colors.warning05
};
/**
* Implements a React {@link Component} to display a notification.
*
* @augments Component
*/
class Notification extends AbstractNotification<IProps> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
const {
hideErrorSupportLink,
t,
title,
titleArguments,
titleKey,
uid
} = this.props;
return (
<Flag
actions = { this._mapAppearanceToButtons(hideErrorSupportLink) }
description = { this._renderDescription() }
icon = { this._mapAppearanceToIcon() }
id = { uid }
testId = { titleKey || this._getDescriptionKey() }
title = { title || t(titleKey, titleArguments) } />
);
}
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),
/**
* Creates a {@code ReactElement} for displaying the contents of the
* notification.
*
* @private
* @returns {ReactElement}
*/
_renderDescription() {
const description = this._getDescription();
'&: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
},
textContainer: {
display: 'flex',
flexDirection: 'column' as const,
justifyContent: 'space-between',
color: theme.palette.text04,
flex: 1,
margin: '0 8px'
},
title: {
...withPixelLineHeight(theme.typography.bodyShortBold)
},
description: {
...withPixelLineHeight(theme.typography.bodyShortRegular),
'&: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,
...withPixelLineHeight(theme.typography.bodyShortBold),
marginRight: theme.spacing(3),
padding: 0,
cursor: 'pointer',
'&:last-of-type': {
marginRight: 0
},
'&.destructive': {
color: theme.palette.textError
}
},
closeIcon: {
cursor: 'pointer'
}
};
});
const Notification = ({
appearance = NOTIFICATION_TYPE.NORMAL,
customActionHandler,
customActionNameKey,
customActionType,
description,
descriptionArguments,
descriptionKey,
hideErrorSupportLink,
icon,
onDismissed,
title,
titleArguments,
titleKey,
uid
}: IProps) => {
const { classes, cx, theme } = useStyles();
const { t } = useTranslation();
const { unmounting } = useContext(NotificationsTransitionContext);
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 && 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 = description.length === 1 && isValidElement(description[0]);
const shouldRenderHtml = descriptionArray.length === 1 && isValidElement(descriptionArray[0]);
// the id is used for testing the UI
return (
<p data-testid = { this._getDescriptionKey() } >
{ shouldRenderHtml ? description : <Message text = { description.join(' ') } /> }
<p
className = { classes.description }
data-testid = { descriptionKey } >
{shouldRenderHtml ? descriptionArray : <Message text = { descriptionArray.join(' ') } />}
</p>
);
}
}, [ description, descriptionArguments, descriptionKey, classes ]);
/**
* Opens the support page.
*
* @returns {void}
* @private
*/
_onOpenSupportLink() {
const _onOpenSupportLink = () => {
window.open(interfaceConfig.SUPPORT_URL, '_blank', 'noopener');
}
};
/**
* Creates action button configurations for the notification based on
* notification appearance.
*
* @param {boolean} hideErrorSupportLink - Indicates if the support link
* should be hidden in the error messages.
* @private
* @returns {Object[]}
*/
_mapAppearanceToButtons(hideErrorSupportLink: boolean) {
switch (this.props.appearance) {
const mapAppearanceToButtons = useCallback((): {
content: string; onClick: () => void; testId?: string; type?: string; }[] => {
switch (appearance) {
case NOTIFICATION_TYPE.ERROR: {
const buttons = [
{
content: this.props.t('dialog.dismiss'),
onClick: this._onDismissed
content: t('dialog.dismiss'),
onClick: onDismiss
}
];
if (!hideErrorSupportLink && interfaceConfig.SUPPORT_URL) {
buttons.push({
content: this.props.t('dialog.contactSupport'),
onClick: this._onOpenSupportLink
content: t('dialog.contactSupport'),
onClick: _onOpenSupportLink
});
}
@@ -120,21 +246,22 @@ class Notification extends AbstractNotification<IProps> {
case NOTIFICATION_TYPE.WARNING:
return [
{
content: this.props.t('dialog.Ok'),
onClick: this._onDismissed
content: t('dialog.Ok'),
onClick: onDismiss
}
];
default:
if (this.props.customActionNameKey?.length && this.props.customActionHandler?.length) {
return this.props.customActionNameKey.map((customAction: string, customActionIndex: number) => {
if (customActionNameKey?.length && customActionHandler?.length) {
return customActionNameKey.map((customAction: string, customActionIndex: number) => {
return {
content: this.props.t(customAction),
content: t(customAction),
onClick: () => {
if (this.props.customActionHandler[customActionIndex]()) {
this._onDismissed();
if (customActionHandler?.[customActionIndex]()) {
onDismiss();
}
},
type: customActionType?.[customActionIndex],
testId: customAction
};
});
@@ -142,61 +269,75 @@ class Notification extends AbstractNotification<IProps> {
return [];
}
}
}, [ appearance, onDismiss, customActionHandler, customActionNameKey, hideErrorSupportLink ]);
/**
* Returns the Icon type component to be used, based on icon or appearance.
*
* @returns {ReactElement}
*/
_getIcon() {
let icon;
const getIcon = useCallback(() => {
let iconToDisplay;
switch (this.props.icon || this.props.appearance) {
switch (icon || appearance) {
case NOTIFICATION_ICON.ERROR:
case NOTIFICATION_ICON.WARNING:
icon = IconWarningCircle;
iconToDisplay = IconWarningCircle;
break;
case NOTIFICATION_ICON.SUCCESS:
icon = IconCheck;
iconToDisplay = IconCheck;
break;
case NOTIFICATION_ICON.MESSAGE:
icon = IconMessage;
iconToDisplay = IconMessage;
break;
case NOTIFICATION_ICON.PARTICIPANT:
icon = IconUser;
iconToDisplay = IconUser;
break;
case NOTIFICATION_ICON.PARTICIPANTS:
icon = IconUsers;
iconToDisplay = IconUsers;
break;
default:
icon = IconInfo;
iconToDisplay = IconInfo;
break;
}
return icon;
}
return iconToDisplay;
}, [ icon, appearance ]);
/**
* Creates an icon component depending on the configured notification
* appearance.
*
* @private
* @returns {ReactElement}
*/
_mapAppearanceToIcon() {
const { appearance, icon } = this.props;
const iconColor = ICON_COLOR[appearance as keyof typeof ICON_COLOR];
const iconSrc = this._getIcon();
return (
<div
className = { cx(classes.container, unmounting.get(uid ?? '') && 'unmount') }
data-testid = { titleKey || descriptionKey }
id = { uid }>
<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
className = { cx(classes.action, type) }
data-testid = { testId }
key = { content }
onClick = { onClick }>
{content}
</button>
))}
</div>
</div>
<Icon
className = { classes.closeIcon }
color = { theme.palette.icon04 }
id = 'close-notification'
onClick = { onDismiss }
size = { 20 }
src = { IconCloseLarge }
testId = { `${titleKey || descriptionKey}-dismiss` } />
</div>
</div>
);
};
return (<div className = { icon }>
<div className = { `ribbon ${appearance}` } />
<Icon
color = { iconColor }
size = { 20 }
src = { iconSrc } />
</div>);
}
}
export default translate(Notification);
export default Notification;