Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions src/components/notification/Notification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import * as React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import type { WrappedComponentProps } from 'react-intl';
import classNames from 'classnames';

import {
AlertCircle,
InformationCircle,
CheckmarkCircle,
AlertTriangle,
XMark,
} from '@box/blueprint-web-assets/icons/Medium';

import InfoBadge16 from '../../icon/line/InfoBadge16';
import CircleCheck16 from '../../icon/line/CircleCheck16';
import TriangleAlert16 from '../../icon/line/TriangleAlert16';

import XBadge16 from '../../icon/line/XBadge16';
import X16 from '../../icon/fill/X16';

import type { NotificationType } from '../../common/types/core';

import './Notification.scss';

// @NOTE: We can't import these constants from ./constant.js because `react-docgen`
// can't handle imported variables appear in propTypes
// see https://github.com/reactjs/react-docgen/issues/33
const DURATION_SHORT = 'short';
const DURATION_LONG = 'long';
const OVERFLOW_WRAP = 'wrap';
const TYPE_DEFAULT = 'default';
const TYPE_INFO = 'info';
const TYPE_WARN = 'warn';
const TYPE_ERROR = 'error';

const DURATION_TIMES = {
[DURATION_SHORT]: 5000,
[DURATION_LONG]: 10000,
};

const ICON_RENDERER: Record<NotificationType, (useV2Icons?: boolean) => React.ReactElement> = {
[TYPE_DEFAULT]: useV2Icons => (useV2Icons ? <InformationCircle /> : <InfoBadge16 />),
[TYPE_ERROR]: useV2Icons => (useV2Icons ? <AlertCircle /> : <XBadge16 />),
[TYPE_INFO]: useV2Icons => (useV2Icons ? <CheckmarkCircle /> : <CircleCheck16 />),
[TYPE_WARN]: useV2Icons => (useV2Icons ? <AlertTriangle /> : <TriangleAlert16 />),
};

const messages = defineMessages({
clearNotificationButtonText: {
defaultMessage: 'Clear Notification',
description: 'Button to clear notification',
id: 'boxui.notification.clearNotification',
},
});

export interface NotificationProps {
/**
* The contents of the `Notification`.
* - Notification text must be wrapped in a `<span />` tag.
* - Notification buttons must be the `<Button />` component.
*/
children: React.ReactNode;
/** Additional CSS class for the notification */
className?: string;
/**
* When set, dictates how long the notification will exist before calling `onClose`.
* If unset, the notification will not automatically call `onClose`.
* - `short`: 5s
* - `long`: 10s
*/
duration?: 'short' | 'long';
/** Function that gets executed when close button is clicked or when duration expires. */
onClose?: (event?: React.SyntheticEvent) => void;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/**
* Determines notification colors
* - `default`: black
* - `info`: green
* - `warn`: yellow
* - `error`: red
*/
type?: NotificationType;
/** How notification text overflow is handled */
overflow?: 'wrap' | 'ellipsis';
/** When true, render Blueprint v2 icons instead of the local icon set */
useV2Icons?: boolean;
}

class Notification extends React.Component<NotificationProps & WrappedComponentProps> {
static defaultProps: Pick<NotificationProps, 'overflow' | 'type'> = {
overflow: OVERFLOW_WRAP,
type: TYPE_DEFAULT,
};

componentDidMount() {
const { duration, onClose } = this.props;
this.timeout = duration && onClose ? setTimeout(onClose, DURATION_TIMES[duration]) : null;
}

componentWillUnmount() {
if (this.timeout) {
clearTimeout(this.timeout);
}
}

onClose = (event?: React.SyntheticEvent) => {
const { onClose } = this.props;
if (this.timeout) {
clearTimeout(this.timeout);
}

if (onClose) {
onClose(event);
}
};

getChildren() {
const { children } = this.props;
return typeof children === 'string' ? <span>{children}</span> : children;
}

timeout: ReturnType<typeof setTimeout> | null;

render() {
const contents = this.getChildren();
const { intl, type = TYPE_DEFAULT, overflow, className, useV2Icons } = this.props;
const { formatMessage } = intl;
const classes = classNames('notification', type, overflow, className);
const iconRenderer = ICON_RENDERER[type](useV2Icons);
const iconColor = useV2Icons ? '#222' : '#fff';

return (
<div className={classes}>
{React.cloneElement(iconRenderer, {
color: iconColor,
height: 20,
width: 20,
})}
{contents}
<button
aria-label={formatMessage(messages.clearNotificationButtonText)}
className="close-btn"
onClick={this.onClose}
type="button"
>
{useV2Icons ? <XMark height={32} width={32} /> : <X16 />}
</button>
</div>
);
}
}

export default injectIntl(Notification);
18 changes: 18 additions & 0 deletions src/components/notification/NotificationsWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as React from 'react';

import FocusTrap from '../focus-trap';
import Portal from '../portal';

export interface NotificationsWrapperProps {
/** Notification elements to render inside the live region */
children?: React.ReactNode;
}

const NotificationsWrapper = ({ children }: NotificationsWrapperProps) => (
// @ts-ignore Portal forwards children and extra HTML attributes at runtime
<Portal className="notifications-wrapper" aria-live="polite">
{children ? <FocusTrap className="notification-container">{children}</FocusTrap> : null}
</Portal>
);

export default NotificationsWrapper;
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { TYPE_DEFAULT, TYPE_INFO, TYPE_WARN, TYPE_ERROR } from '../constants';
import { Notification } from '..';

const sandbox = sinon.sandbox.create();
let clock;
let clock: ReturnType<typeof sinon.useFakeTimers>;

describe('components/notification/Notification', () => {
beforeEach(() => {
Expand All @@ -22,7 +22,7 @@ describe('components/notification/Notification', () => {
test('should render a notification when initialized', () => {
const wrapper = mount(<Notification>test</Notification>);

expect(wrapper.find('div.notification').length).toBe(1);
expect(wrapper.find('div.notification')).toHaveLength(1);
expect(wrapper.find('span').text()).toEqual('test');
});

Expand Down Expand Up @@ -53,13 +53,13 @@ describe('components/notification/Notification', () => {
const XBadge16Count = type === TYPE_ERROR ? 1 : 0;
const TriangleAlert16Count = type === TYPE_WARN ? 1 : 0;

expect(component.find('InfoBadge16').length).toBe(infoBadge16Count);
expect(component.find('XBadge16').length).toBe(XBadge16Count);
expect(component.find('CircleCheck16').length).toBe(CircleCheck16Count);
expect(component.find('TriangleAlert16').length).toBe(TriangleAlert16Count);
expect(component.find('InfoBadge16')).toHaveLength(infoBadge16Count);
expect(component.find('XBadge16')).toHaveLength(XBadge16Count);
expect(component.find('CircleCheck16')).toHaveLength(CircleCheck16Count);
expect(component.find('TriangleAlert16')).toHaveLength(TriangleAlert16Count);

// Does not render v2 icons
expect(component.find(`svg[role="img"]`).length).toBe(0);
expect(component.find(`svg[role="img"]`)).toHaveLength(0);
});

test('should render v2 icons when useV2Icons is true', () => {
Expand All @@ -70,30 +70,32 @@ describe('components/notification/Notification', () => {
);

// Type icon and Close button
expect(component.find(`svg[role="img"]`).length).toBe(2);
expect(component.find(`svg[role="img"]`)).toHaveLength(2);

// Does not render local icons
expect(component.find('InfoBadge16').length).toBe(0);
expect(component.find('XBadge16').length).toBe(0);
expect(component.find('CircleCheck16').length).toBe(0);
expect(component.find('TriangleAlert16').length).toBe(0);
expect(component.find('InfoBadge16')).toHaveLength(0);
expect(component.find('XBadge16')).toHaveLength(0);
expect(component.find('CircleCheck16')).toHaveLength(0);
expect(component.find('TriangleAlert16')).toHaveLength(0);
});
});

[
{
overflowOption: undefined,
expectedClass: 'wrap',
},
{
overflowOption: 'wrap',
expectedClass: 'wrap',
},
{
overflowOption: 'ellipsis',
expectedClass: 'ellipsis',
},
].forEach(({ overflowOption, expectedClass }) => {
(
[
{
overflowOption: undefined,
expectedClass: 'wrap',
},
{
overflowOption: 'wrap',
expectedClass: 'wrap',
},
{
overflowOption: 'ellipsis',
expectedClass: 'ellipsis',
},
] as const
).forEach(({ overflowOption, expectedClass }) => {
test(`should render a notification with ${expectedClass} styling when passed the ${overflowOption} overflow option`, () => {
const component = mount(<Notification overflow={overflowOption}>test</Notification>);

Expand Down Expand Up @@ -192,6 +194,21 @@ describe('components/notification/Notification', () => {
expect(closeMock.called).toBe(false);
});

test('should not call onClose after unmount when the duration later expires', () => {
const closeMock = sinon.spy();

const component = mount(
<Notification duration="short" onClose={closeMock}>
test
</Notification>,
);

component.unmount();
clock.tick(5000 + 10);

expect(closeMock.called).toBe(false);
});

test('should render buttons and text when multiple children are passed in', () => {
const wrapper = mount(
<Notification>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from 'react';
import { shallow } from 'enzyme';

import NotificationsWrapper from '../NotificationsWrapper';
import Notification from '../Notification';
Expand All @@ -8,7 +9,7 @@ describe('components/notification/NotificationsWrapper', () => {
const wrapper = shallow(<NotificationsWrapper />);
expect(wrapper.is('Portal')).toBeTruthy();
expect(wrapper.hasClass('notifications-wrapper')).toBeTruthy();
expect(wrapper.props('aria-live')).toBeTruthy();
expect(wrapper.prop('aria-live')).toBe('polite');
});

test('should render a focus trap', () => {
Expand All @@ -18,7 +19,7 @@ describe('components/notification/NotificationsWrapper', () => {
</NotificationsWrapper>,
);
const focusTrap = wrapper.find('FocusTrap');
expect(focusTrap.length).toEqual(1);
expect(focusTrap).toHaveLength(1);
});

test('should not render focusTrap if there are no children', () => {
Expand All @@ -34,6 +35,6 @@ describe('components/notification/NotificationsWrapper', () => {
</NotificationsWrapper>,
);

expect(wrapper.find('Notification').length).toEqual(2);
expect(wrapper.find('Notification')).toHaveLength(2);
});
});
13 changes: 13 additions & 0 deletions src/components/notification/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Duration constants
export const DURATION_SHORT = 'short';
export const DURATION_LONG = 'long';

// Type constants
export const TYPE_DEFAULT = 'default';
export const TYPE_INFO = 'info';
export const TYPE_WARN = 'warn';
export const TYPE_ERROR = 'error';

// Overflow constants
export const OVERFLOW_WRAP = 'wrap';
export const OVERFLOW_ELLIPSIS = 'ellipsis';
7 changes: 7 additions & 0 deletions src/components/notification/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as NotificationConstants from './constants';
import Notification from './Notification';
import NotificationsWrapper from './NotificationsWrapper';

export { Notification, NotificationConstants, NotificationsWrapper };
export type { NotificationProps } from './Notification';
export type { NotificationsWrapperProps } from './NotificationsWrapper';
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// @flow
import * as React from 'react';
import { IntlProvider } from 'react-intl';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,35 @@
// @flow
/* eslint-disable react-hooks/rules-of-hooks */
import * as React from 'react';

import Button from '../../button/Button';
import PrimaryButton from '../../primary-button/PrimaryButton';
import Notification from '../Notification';

import { DURATION_SHORT, DURATION_LONG, TYPE_INFO, TYPE_WARN } from '../../../components/notification/constants';
import NotificationsWrapper from '../NotificationsWrapper';
import notes from './NotificationsWrapper.stories.md';

export const example = () => {
const DATE = new Date('May 13, 2002 23:15:30').toTimeString();

const [notificationData, setNotificationData] = React.useState({
const [notificationData, setNotificationData] = React.useState<{
id: number;
notifications: Map<number, React.ReactNode>;
}>({
id: 0,
notifications: new Map(),
});

const closeNotification = id => {
const closeNotification = (id: number) => {
const notifications = new Map(notificationData.notifications);
notifications.delete(id);
setNotificationData({ ...notificationData, notifications });
};

const addNotification = (duration, type) => {
const addNotification = (
duration: typeof DURATION_SHORT | typeof DURATION_LONG,
type: typeof TYPE_INFO | typeof TYPE_WARN,
) => {
const { id } = notificationData;
const { notifications } = notificationData;
const notification = (
Expand All @@ -40,7 +46,7 @@ export const example = () => {

return (
<div>
<NotificationsWrapper>{[...notificationData.notifications.values()]}</NotificationsWrapper>
<NotificationsWrapper>{Array.from(notificationData.notifications.values())}</NotificationsWrapper>
Comment thread
bonchevskyi marked this conversation as resolved.
<Button onClick={() => addNotification('short', 'info')}>Display timed notification</Button>
<PrimaryButton onClick={() => addNotification(undefined, 'warn')}>
Display persistent notification
Expand Down
Loading