Refactor notification for better display

- bring in CSS for patternfly
- better style/alignment
- notification self control
- more than one notification at a time
- persistent for form errors

Change-Id: I0c173c80f5482f0813b673067b7bb297f2a610cc
This commit is contained in:
Jason E. Rist 2016-05-04 08:55:47 -06:00
parent f066e0c1cb
commit c320348bc5
9 changed files with 96 additions and 344 deletions

View File

@ -1,11 +1,45 @@
import React from 'react';
import ClassNames from 'classnames';
import Timer from '../utils/Timer';
export default class Notification extends React.Component {
constructor() {
super();
this._notificationTimer = null;
}
componentDidMount() {
//create a timer for the notification if it's not persistent
if (!this.props.persistent) {
this._notificationTimer = new Timer(() => {
this._hideNotification();
}, 8000);
}
}
//hide the notification as long as it's not persistent
_hideNotification() {
if (this._notificationTimer && !this.props.persistent) {
this._notificationTimer.clear();
this.props.removeNotification();
}
}
// handles the mouse hovering over a notification unless it's a persistent notification
_handleMouseEnter() {
!this.props.persistent ? this._notificationTimer.pause() : null;
}
// handles the mouse leaving the hover over a notification unless it's a persistent notification
_handleMouseLeave() {
!this.props.persistent ? this._notificationTimer.resume() : null;
}
render() {
let classes = ClassNames({
'toast-pf': true,
'alert': true,
'pull-right': !this.props.persistent,
'alert-danger': this.props.type === 'error',
'alert-warning': this.props.type === 'warning',
'alert-success': this.props.type === 'success',
@ -23,32 +57,36 @@ export default class Notification extends React.Component {
return (
<div className={classes}
role="alert"
onMouseEnter={this.props.onMouseEnter}
onMouseLeave={this.props.onMouseLeave}>
onMouseEnter={this._handleMouseEnter.bind(this)}
onMouseLeave={this._handleMouseLeave.bind(this)}>
<span className={iconClass} aria-hidden="true"></span>
{this.props.dismissable ?
<button type="button"
className="close"
aria-label="Close"
onClick={this.props.removeNotification.bind(this)}>
onClick={this._hideNotification.bind(this)}>
<span className="pficon pficon-close" aria-hidden="true"></span>
</button> : false}
<strong>{this.props.title}</strong> {this.props.message}
<strong>{this.props.title}</strong>
<p>{this.props.message}</p>
</div>
);
}
}
Notification.propTypes = {
dismissable: React.PropTypes.bool,
dismissable: React.PropTypes.bool.isRequired,
message: React.PropTypes.string.isRequired,
onMouseEnter: React.PropTypes.func,
onMouseLeave: React.PropTypes.func,
persistent: React.PropTypes.bool,
removeNotification: React.PropTypes.func,
title: React.PropTypes.string,
type: React.PropTypes.string
};
Notification.defaultProps = {
dismissable: false,
title: '',
type: 'error'
type: 'error',
persistent: false
};

View File

@ -1,67 +0,0 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ClassNames from 'classnames';
import BlankSlate from '../ui/BlankSlate';
import NotificationListItem from './NotificationListItem';
export default class NotificationList extends React.Component {
componentWillReceiveProps(nextProps) {
if (this.props.active && !nextProps.active) {
this.props.notificationsViewed();
}
}
getNotificationsContent(notifications) {
let notificationListItems = notifications.toList().map(notification => {
return (
<NotificationListItem
key={notification.id}
title={notification.title}
message={notification.message}
type={notification.type}
timestamp={notification.timestamp}
viewed={notification.viewed}
removeNotification={this.props.removeNotification.bind(this, notification.id)}/>
);
});
if (notifications && notifications.size > 0) {
return (
<table className="table table-striped table-hover">
<tbody>
{notificationListItems.reverse()}
</tbody>
</table>
);
}
else {
return (
<BlankSlate iconClass="fa fa-bullhorn"
title="No Notifications"
message="There are no notifications at this time." />
);
}
}
render () {
let classes = ClassNames({
'col-sm-12': true,
'notifications-container': true,
'collapsed': !this.props.active
});
return (
<div className={classes}>
{this.getNotificationsContent(this.props.notifications)}
</div>
);
}
}
NotificationList.propTypes = {
active: React.PropTypes.bool,
notifications: ImmutablePropTypes.map.isRequired,
notificationsViewed: React.PropTypes.func.isRequired,
removeNotification: React.PropTypes.func.isRequired
};

View File

@ -1,55 +0,0 @@
import React from 'react';
import ClassNames from 'classnames';
import {FormattedDate} from 'react-intl';
export default class NotificationListItem extends React.Component {
render() {
let iconClass = ClassNames({
'pficon': true,
'pficon-ok': this.props.type === 'success',
'pficon-info': this.props.type === 'info',
'pficon-warning-triangle-o': this.props.type === 'warning',
'pficon-error-circle-o': this.props.type === 'error' || !this.props.type
});
let notificationDate = new Date();
notificationDate.setTime(this.props.timestamp);
return (
<tr className={this.props.viewed ? '' : 'unviewed'} role="alert">
<td>
<span className={iconClass} aria-hidden="true"/>
<strong>{this.props.title}</strong>
</td>
<td>
<span>{this.props.message}</span>
</td>
<td>
<FormattedDate value={notificationDate}
month="numeric" day="numeric" year="numeric"
hour="numeric" minute="numeric" second="numeric" />
</td>
<td>
<button type="button"
className="btn btn-primary btn-xs pull-right"
onClick={this.props.removeNotification}>
Dismiss
</button>
</td>
</tr>
);
}
}
NotificationListItem.propTypes = {
message: React.PropTypes.string.isRequired,
removeNotification: React.PropTypes.func.isRequired,
timestamp: React.PropTypes.number.isRequired,
title: React.PropTypes.string.isRequired,
type: React.PropTypes.string.isRequired,
viewed: React.PropTypes.bool.isRequired
};
NotificationListItem.defaultProps = {
title: '',
type: 'error'
};

View File

@ -1,66 +0,0 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import React from 'react';
export default class NotificationsIndicator extends React.Component {
getStatusInfo() {
// Generate an object of status counts {'status': statusCount...}
let stateCounts = this.props.notifications.countBy(n => n.type);
let unreadCount = this.props.notifications.countBy(notification => {
return notification.viewed ? 'read' : 'unread';
}).get('unread') || 0;
return {
total: this.props.notifications.size,
unread: unreadCount,
error: (stateCounts.error || 0) + (stateCounts.undefined || 0),
warning: stateCounts.warning || 0
};
}
getStatusBadge (statusInfo) {
if (statusInfo.error > 0 || statusInfo.warning > 0) {
let badgeClasses = 'badge';
let statusText = '';
let statusCount = 0;
if (statusInfo.error > 0) {
badgeClasses += ' error';
statusText = 'Errors';
statusCount = statusInfo.error;
}
else {
badgeClasses += ' warning';
statusText = 'Warnings';
statusCount = statusInfo.warning;
}
return (
<div className="pull-right">
<span className="indicator-category">{statusText}</span>
<span className={badgeClasses}>{statusCount}</span>
</div>
);
}
else {
return false;
}
}
render() {
let statusInfo = this.getStatusInfo();
return (
<a onClick={this.props.onClick} className="indicator">
<span className="indicator-title">Notifications:</span>
<span className="indicator-category">Unread</span>
<span className="badge unread">{statusInfo.unread}</span>
{this.getStatusBadge(statusInfo)}
</a>
);
}
}
NotificationsIndicator.propTypes = {
notifications: ImmutablePropTypes.map.isRequired,
onClick: React.PropTypes.func
};

View File

@ -3,117 +3,53 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import React from 'react';
import Notification from './Notification';
import NotificationActions from '../../actions/NotificationActions';
export default class NotificationsToaster extends React.Component {
constructor() {
super();
this.state = {
toasterNotification: null,
nextNotification: null,
timestamp: Date.now(),
isHovering: false,
timeout: false
};
}
componentWillReceiveProps(nextProps) {
this._onNotificationsChange(nextProps);
}
componentWillUpdate(nextProps, nextState) {
if (nextState.toasterNotification) {
if (nextState.toasterNotification != this.state.toasterNotification) {
this.startTimer();
}
}
if (this.state.isHovering && !nextState.isHovering && !this.state.timeout) {
this.showNextNotification();
}
}
componentWillUnmount() {
this.clearTimer();
}
_onNotificationsChange(nextProps) {
let now = new Date();
// Determine which notifications are new
let newNotifications = nextProps.notifications
.filter(notification => notification.timestamp > this.state.timestamp);
if (!newNotifications.isEmpty()) {
let nextNotification = newNotifications.first();
if (!this.state.toasterNotification || !this.state.isHovering) {
this.setState({toasterNotification: nextNotification, timestamp: now.getTime()});
}
else {
this.setState({nextNotification: nextNotification, timestamp: now.getTime()});
}
}
}
showNextNotification() {
this.setState({
toasterNotification: this.state.nextNotification,
nextNotification: null
renderNotifications(){
return this.props.notifications.toList().map(notification => {
return (
<Notification
key={notification.id}
title={notification.title}
message={notification.message}
type={notification.type}
dismissable={notification.dismissable}
removeNotification={this.props.removeNotification.bind(this, notification.id)}/>
);
});
}
toasterTimeout() {
this.setState({timeout: false});
if (!this.state.isHovering) {
this.showNextNotification();
}
}
startTimer() {
// Clear any previous timers
this.clearTimer();
this.setState({timeout: setTimeout(this.toasterTimeout.bind(this), 8000)});
}
clearTimer() {
if (this.state.timeout) {
clearTimeout(this.state.timeout);
this.setState({timeout: false});
}
}
closeNotification() {
this.setState({isHovering: false});
this.clearTimer();
this.showNextNotification();
}
notificationHover(isHover) {
this.setState({isHovering: isHover});
}
render() {
return this.state.toasterNotification ? (
<div className="notification-toaster col-lg-5 col-md-6 col-sm-8 col-xs-12">
<Notification
title={this.state.toasterNotification.title}
message={this.state.toasterNotification.message}
type={this.state.toasterNotification.type}
dismissable
removeNotification={this.closeNotification.bind(this)}
onMouseEnter={this.notificationHover.bind(this, true)}
onMouseLeave={this.notificationHover.bind(this, false)}/>
return (
<div className="toast-pf-max-width toast-pf-top-right">
{this.renderNotifications()}
</div>
) : null;
);
}
}
NotificationsToaster.propTypes = {
notifications: ImmutablePropTypes.map.isRequired
notifications: ImmutablePropTypes.map.isRequired,
removeNotification: React.PropTypes.func
};
function mapStateToProps(state) {
return {
notifications: state.notifications.get('all').sortBy(n => n.timestamp)
//TODO: write a selector for visible notifications
//notifications: getVisibileNotifications(state).sortBy(n => n.timestamp)
};
}
function mapDispatchToProps(dispatch) {
return {
removeNotification: notificationId =>
dispatch(NotificationActions.removeNotification(notificationId))
};
}
export default connect(mapStateToProps)(NotificationsToaster);
export default connect(mapStateToProps, mapDispatchToProps)(NotificationsToaster);

View File

@ -11,6 +11,7 @@ export default class FormErrorList extends React.Component {
title={error.title}
message={error.message}
dismissable={false}
persistent
type={error.type}/>
);
});

View File

@ -0,0 +1,22 @@
export default function Timer (callback, delay) {
var timerId;
var start;
var remaining = delay;
this.pause = function() {
clearTimeout(timerId);
remaining -= new Date() - start;
};
this.resume = function() {
start = new Date();
clearTimeout(timerId);
timerId = setTimeout(callback, remaining);
};
this.clear = function() {
clearTimeout(timerId);
};
this.resume();
}

View File

@ -94,7 +94,6 @@
@import "components/DeploymentPlan";
@import "components/NodeStack";
@import "components/NodePicker";
@import "components/NotificationList";
@import "components/Plans";
@import "components/Validations";

View File

@ -1,56 +0,0 @@
.notification-toaster {
position: fixed;
right:0;
top: 0px;
margin-top: 10px;
margin-left: 10px;
z-index: 1050;
&.on-login {
top: 200px;
}
}
.notifications-container {
padding: 0;
overflow-x: hidden;
overflow-y: auto;
&.collapsed {
display: none;
}
.table > tbody > tr {
&.unviewed {
td {
background-color: #fbeabc;
border-color: #f8d885;
}
&:hover {
td {
background-color: #f9d67a;
border-color: #f5c12e;
}
}
}
.pficon {
font-size: 18px;
line-height: 22px;
vertical-align: middle;
margin: 0 5px;
}
.btn {
visibility: hidden;
margin-right: 5px;
margin-top: 2px;
}
&:hover {
.btn {
visibility: visible;
}
}
}
}