From ea97be01c7083ffae5b1b9c5b500ac5bd3fd823b Mon Sep 17 00:00:00 2001 From: Patrick Riley Date: Mon, 14 Aug 2017 11:46:58 -0400 Subject: [PATCH] feat(toastNotification): adds toast notification component Closes #47 --- package.json | 2 +- src/Alert/Alert.js | 4 +- src/DropdownKebab/DropdownKebab.js | 18 +- .../TimedToastNotification.js | 98 ++++++++++ src/ToastNotification/ToastNotification.js | 73 ++++++++ .../ToastNotification.stories.js | 171 ++++++++++++++++++ .../ToastNotification.test.js | 44 +++++ .../ToastNotificationList.js | 70 +++++++ .../ToastNotification.test.js.snap | 55 ++++++ src/common/Timer.js | 23 +++ src/common/constants.js | 9 + src/common/helpers.js | 8 + src/index.js | 13 +- 13 files changed, 578 insertions(+), 10 deletions(-) create mode 100644 src/ToastNotification/TimedToastNotification.js create mode 100644 src/ToastNotification/ToastNotification.js create mode 100644 src/ToastNotification/ToastNotification.stories.js create mode 100644 src/ToastNotification/ToastNotification.test.js create mode 100644 src/ToastNotification/ToastNotificationList.js create mode 100644 src/ToastNotification/__snapshots__/ToastNotification.test.js.snap create mode 100644 src/common/Timer.js create mode 100644 src/common/constants.js create mode 100644 src/common/helpers.js diff --git a/package.json b/package.json index 64d99da45d6..75081372ade 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ }, "scripts": { "commit": "git-cz", - "build": "babel src --out-dir lib --ignore test.js,__mocks__ && less", + "build": "babel src --out-dir lib --ignore test.js,__mocks__ && npm run less", "less": "mkdir -p lib/styles/less && cp -r less/ lib/styles/less", "lint": "eslint --max-warnings 0 src storybook", "prettier": "prettier --write --single-quote --no-semi '{src,storybook}/**/*.js'", diff --git a/src/Alert/Alert.js b/src/Alert/Alert.js index e00afc82cf2..70712bba036 100644 --- a/src/Alert/Alert.js +++ b/src/Alert/Alert.js @@ -1,6 +1,7 @@ import ClassNames from 'classnames' import React from 'react' import PropTypes from 'prop-types' +import { ALERT_TYPES } from '../common/constants' /** * Alert Component for Patternfly React @@ -42,8 +43,7 @@ Alert.propTypes = { /** callback when alert is dismissed */ onDismiss: PropTypes.func, /** the type of alert */ - type: PropTypes.oneOf(['danger', 'error', 'warning', 'success', 'info']) - .isRequired, + type: PropTypes.oneOf(ALERT_TYPES).isRequired, /** children nodes */ children: PropTypes.node } diff --git a/src/DropdownKebab/DropdownKebab.js b/src/DropdownKebab/DropdownKebab.js index cd72c301511..6b804339a07 100644 --- a/src/DropdownKebab/DropdownKebab.js +++ b/src/DropdownKebab/DropdownKebab.js @@ -1,22 +1,30 @@ import { Dropdown } from 'react-bootstrap' +import ClassNames from 'classnames' import React from 'react' import PropTypes from 'prop-types' -const DropdownKebab = ({ children, id, pullRight }) => { +/** + * DropdownKebab Component for Patternfly React + */ +const DropdownKebab = ({ className, children, id, pullRight }) => { + const kebabClass = ClassNames('dropdown-kebab-pf', className) return ( - + - - {children} - + {children} ) } DropdownKebab.propTypes = { + /** additional kebab dropdown classes */ + className: PropTypes.string, + /** children nodes */ children: PropTypes.node, + /** kebab dropdown id */ id: PropTypes.string.isRequired, + /** menu right aligned */ pullRight: PropTypes.bool } export default DropdownKebab diff --git a/src/ToastNotification/TimedToastNotification.js b/src/ToastNotification/TimedToastNotification.js new file mode 100644 index 00000000000..c110dce4042 --- /dev/null +++ b/src/ToastNotification/TimedToastNotification.js @@ -0,0 +1,98 @@ +import React from 'react' +import PropTypes from 'prop-types' +import helpers from '../common/helpers' +import Timer from '../common/Timer' +import { TOAST_NOTIFICATION_TYPES } from '../common/constants' +import { ToastNotification } from '../index.js' + +/** + * TimedToastNotification Component for Patternfly React + */ +class TimedToastNotification extends React.Component { + constructor(props) { + super(props) + helpers.bindMethods(this, ['onMouseEnter', 'onMouseLeave']) + } + + componentDidMount() { + const { paused, persistent, onDismiss, timerdelay } = this.props + + if (!persistent) { + this.timer = new Timer(onDismiss, timerdelay) + this.timer.startTimer() + } + + /** if we are paused on mount, then clear the timer + * after having initialized with the correct delay */ + if (paused) { + this.timer && this.timer.clearTimer() + } + } + + componentWillReceiveProps(nextProps) { + /** + * If paused prop changes, update our timer + */ + if (nextProps.paused !== this.props.paused) { + if (nextProps.paused) { + this.timer && this.timer.clearTimer() + } else { + this.timer && this.timer.startTimer() + } + } + } + + componentWillUnmount() { + this.timer && this.timer.clearTimer() + } + + onMouseEnter() { + const { onMouseEnter } = this.props + onMouseEnter && onMouseEnter() + } + + onMouseLeave() { + const { onMouseLeave } = this.props + onMouseLeave && onMouseLeave() + } + render() { + const { children } = this.props + return ( + + {children} + + ) + } +} + +TimedToastNotification.propTypes = { + /** pauses notification from dismissing */ + paused: PropTypes.bool, + /** persistent keeps the notification up endlessly until closed */ + persistent: PropTypes.bool, + /** timer delay until dismiss */ + timerdelay: PropTypes.number, + /** additional notification classes */ + className: PropTypes.string, // eslint-disable-line react/no-unused-prop-types + /** callback when alert is dismissed */ + onDismiss: PropTypes.func, + /** onMouseEnter callback */ + onMouseEnter: PropTypes.func, + /** onMouseLeave callback */ + onMouseLeave: PropTypes.func, + /** the type of alert */ + type: PropTypes.oneOf(TOAST_NOTIFICATION_TYPES).isRequired, // eslint-disable-line react/no-unused-prop-types + /** children nodes */ + children: PropTypes.node +} +TimedToastNotification.defaultProps = { + paused: false, + type: 'error', + timerdelay: 8000 +} + +export default TimedToastNotification diff --git a/src/ToastNotification/ToastNotification.js b/src/ToastNotification/ToastNotification.js new file mode 100644 index 00000000000..6a06427e164 --- /dev/null +++ b/src/ToastNotification/ToastNotification.js @@ -0,0 +1,73 @@ +import ClassNames from 'classnames' +import React from 'react' +import PropTypes from 'prop-types' +import { Button } from '../index' +import { TOAST_NOTIFICATION_TYPES } from '../common/constants' + +/** + * ToastNotification Component for Patternfly React + */ +const ToastNotification = ({ + className, + onDismiss, + type, + onMouseEnter, + onMouseLeave, + children, + ...props +}) => { + const notificationClasses = ClassNames( + { + alert: true, + 'toast-pf': true, + 'alert-danger': type === 'danger' || type === 'error', + 'alert-warning': type === 'warning', + 'alert-success': type === 'success', + 'alert-info': type === 'info', + 'alert-dismissable': onDismiss + }, + className + ) + const iconClass = ClassNames({ + pficon: true, + 'pficon-error-circle-o': type === 'danger' || type === 'error', + 'pficon-warning-triangle-o': type === 'warning', + 'pficon-ok': type === 'success', + 'pficon-info': type === 'info' + }) + + return ( +
+ {onDismiss && + } + + {children} +
+ ) +} +ToastNotification.propTypes = { + /** additional notification classes */ + className: PropTypes.string, + /** callback when alert is dismissed */ + onDismiss: PropTypes.func, + /** the type of alert */ + type: PropTypes.oneOf(TOAST_NOTIFICATION_TYPES).isRequired, + /** onMouseEnter callback */ + onMouseEnter: PropTypes.func, + /** onMouseLeave callback */ + onMouseLeave: PropTypes.func, + /** children nodes */ + children: PropTypes.node +} +ToastNotification.defaultProps = { + type: 'error' +} + +export default ToastNotification diff --git a/src/ToastNotification/ToastNotification.stories.js b/src/ToastNotification/ToastNotification.stories.js new file mode 100644 index 00000000000..05cdbe40eb1 --- /dev/null +++ b/src/ToastNotification/ToastNotification.stories.js @@ -0,0 +1,171 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { action } from '@storybook/addon-actions' +import { withKnobs, text, select, boolean } from '@storybook/addon-knobs' +import { defaultTemplate } from '../../storybook/decorators/storyTemplates' +import { + Button, + DropdownKebab, + MenuItem, + ToastNotification, + TimedToastNotification, + ToastNotificationList +} from '../index.js' + +const stories = storiesOf('ToastNotification', module) +stories.addDecorator(withKnobs) +stories.addDecorator( + defaultTemplate({ + title: 'Toast Notification', + documentationLink: + 'http://www.patternfly.org/pattern-library/communication/toast-notifications/' + }) +) + +stories.addWithInfo( + 'Toast Notification', + `Toast Notifications pop onto the screen to notify the user of a system occurrence`, + () => { + const header = text('Header', 'Great job!') + const message = text('Message', 'This is really working out.') + const type = select( + 'Type', + ['success', 'danger', 'warning', 'info'], + 'success' + ) + const dismissEnabled = boolean('Dismiss', false) + const menuEnabled = boolean('Menu', true) + const actionEnabled = boolean('Action', true) + return ( +
+ + {menuEnabled && + + Action + Another Action + Something else here + + Separated link + {dismissEnabled && Close} + } + {actionEnabled && +
+ Start Server +
} + + {header}   + {message} + +
+
+ ) + } +) + +class ToastNotificationStoryWrapper extends React.Component { + constructor(props) { + super(props) + this.state = { + infoNotificationDismissed: false, + warningNotificationDismissed: false, + successNotificationDismissed: false + } + + this.infoNotificationDismissed = () => { + action('info notification: onDismiss fired')() + this.setState({ infoNotificationDismissed: true }) + } + + this.warningNotificationDismissed = () => { + action('warning notification: onDismiss fired')() + this.setState({ warningNotificationDismissed: true }) + } + + this.persistentNotificationDismissed = () => { + action('persistent notification: onDismiss fired')() + this.setState({ persistentNotificationDismissed: true }) + } + + this.resetStateClicked = () => { + this.setState({ + infoNotificationDismissed: false, + warningNotificationDismissed: false, + persistentNotificationDismissed: false + }) + } + } + + render() { + return ( +
+ + + {!this.state.infoNotificationDismissed && + + + {text( + 'Message', + "By default, a toast notification's timer expires after eight seconds." + )} + + } +
+ {!this.state.warningNotificationDismissed && + + + Additionally, if the user hovers any toast notification each + timer is reset. + + } + {!this.state.persistentNotificationDismissed && + + + A persistent notification will remain on the screen until + closed. + + } +
+
+ ) + } +} + +stories.addWithInfo( + 'Toast Notification List', + `This is the Toast Notification List with a custom timer delay supplied.`, + () => { + return + } +) diff --git a/src/ToastNotification/ToastNotification.test.js b/src/ToastNotification/ToastNotification.test.js new file mode 100644 index 00000000000..91d7f906461 --- /dev/null +++ b/src/ToastNotification/ToastNotification.test.js @@ -0,0 +1,44 @@ +/* eslint-env jest */ + +import React from 'react' +import renderer from 'react-test-renderer' + +import ToastNotification from './ToastNotification' +import TimedToastNotification from './TimedToastNotification' + +test('ToastNotification renders properly', () => { + const handleNotificationClose = jest.fn() + const handleOnMouseEnter = jest.fn() + const handleOnMouseLeave = jest.fn() + const component = renderer.create( + + Danger Will Robinson! + + ) + let tree = component.toJSON() + expect(tree).toMatchSnapshot() +}) + +test('TimedToastNotification renders properly', () => { + const handleNotificationClose = jest.fn() + const handleOnMouseEnter = jest.fn() + const handleOnMouseLeave = jest.fn() + const component = renderer.create( + + Danger Will Robinson! + + ) + let tree = component.toJSON() + expect(tree).toMatchSnapshot() +}) diff --git a/src/ToastNotification/ToastNotificationList.js b/src/ToastNotification/ToastNotificationList.js new file mode 100644 index 00000000000..3381ec48286 --- /dev/null +++ b/src/ToastNotification/ToastNotificationList.js @@ -0,0 +1,70 @@ +import React from 'react' +import PropTypes from 'prop-types' +import helpers from '../common/helpers' +import { TimedToastNotification } from '../index' + +/** + * ToastNotificationList Component for Patternfly React + */ +class ToastNotificationList extends React.Component { + constructor(props) { + super(props) + this.state = { paused: false } + helpers.bindMethods(this, ['onMouseEnter', 'onMouseLeave']) + } + onMouseEnter() { + this.setState({ paused: true }) + const { onMouseEnter } = this.props + onMouseEnter && onMouseEnter() + } + + onMouseLeave() { + this.setState({ paused: false }) + const { onMouseLeave } = this.props + onMouseLeave && onMouseLeave() + } + + renderChildren() { + const paused = this.state.paused + return React.Children.map(this.props.children, child => { + if (child && child.type === TimedToastNotification) { + /** + * If any of the notifications are hovered, pause + * all child notifications from dismissing + */ + return React.cloneElement(child, { + paused: paused + }) + } else { + return child + } + }) + } + render() { + const { className } = this.props + return ( +
+ {this.renderChildren()} +
+ ) + } +} +ToastNotificationList.propTypes = { + /** additional notification list classes */ + className: PropTypes.string, + /** onMouseEnter callback */ + onMouseEnter: PropTypes.func, + /** onMouseLeave callback */ + onMouseLeave: PropTypes.func, + /** children nodes */ + children: PropTypes.node +} +ToastNotificationList.defaultProps = { + className: 'toast-notifications-list-pf' +} + +export default ToastNotificationList diff --git a/src/ToastNotification/__snapshots__/ToastNotification.test.js.snap b/src/ToastNotification/__snapshots__/ToastNotification.test.js.snap new file mode 100644 index 00000000000..e99700690a2 --- /dev/null +++ b/src/ToastNotification/__snapshots__/ToastNotification.test.js.snap @@ -0,0 +1,55 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TimedToastNotification renders properly 1`] = ` +
+ + + + Danger Will Robinson! + +
+`; + +exports[`ToastNotification renders properly 1`] = ` +
+ + + + Danger Will Robinson! + +
+`; diff --git a/src/common/Timer.js b/src/common/Timer.js new file mode 100644 index 00000000000..08a8e7bdde9 --- /dev/null +++ b/src/common/Timer.js @@ -0,0 +1,23 @@ +/** + * Timer class implements a simple timeout mechanism + */ +class Timer { + constructor(func, delay) { + this.timer = null + this.delay = delay + this.execute = func + } + + startTimer() { + this.clearTimer() + + this.timer = setTimeout(this.execute, this.delay) + } + clearTimer() { + if (this.timer) { + clearTimeout(this.timer) + } + } +} + +export default Timer diff --git a/src/common/constants.js b/src/common/constants.js new file mode 100644 index 00000000000..0fe60a9978d --- /dev/null +++ b/src/common/constants.js @@ -0,0 +1,9 @@ +export const TOAST_NOTIFICATION_TYPES = [ + 'danger', + 'error', + 'warning', + 'success', + 'info' +] + +export const ALERT_TYPES = ['danger', 'error', 'warning', 'success', 'info'] diff --git a/src/common/helpers.js b/src/common/helpers.js new file mode 100644 index 00000000000..3e7e7a455a1 --- /dev/null +++ b/src/common/helpers.js @@ -0,0 +1,8 @@ +export default { + bindMethods: function(context, methods) { + methods.forEach(method => { + context[method] = context[method].bind(context) + }) + }, + noop: Function.prototype // empty function +} diff --git a/src/index.js b/src/index.js index b7ed36ba575..0d5d1003072 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,6 @@ export { default as Alert } from './Alert/Alert' -export { default as DropdownKebab } from './DropdownKebab/DropdownKebab' -export { default as MenuItem } from './MenuItem/MenuItem' export { default as Button } from './Button/Button' +export { default as DropdownKebab } from './DropdownKebab/DropdownKebab' export { ListView, ListGroupItem, @@ -22,3 +21,13 @@ export { } from './ListView' export { default as ListViewItem } from './ListView/ListViewItem' export { default as ListViewRow } from './ListView/ListViewRow' +export { default as MenuItem } from './MenuItem/MenuItem' +export { + default as ToastNotification +} from './ToastNotification/ToastNotification' +export { + default as TimedToastNotification +} from './ToastNotification/TimedToastNotification' +export { + default as ToastNotificationList +} from './ToastNotification/ToastNotificationList'