diff --git a/package.json b/package.json index bc7c549f..417ebeae 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "gulp-stylus": "^2.0.0", "gulp-uglify": "^1.0.2", "gulp-util": "^3.0.3", - "lodash": "^3.2.0", + "lodash": "4.9.0", "memory-cache": "0.0.5", "react-mixin": "3.0.4", "reflux": "0.4.0", diff --git a/src/browser/actions/NotificationsActions.js b/src/browser/actions/NotificationsActions.js new file mode 100644 index 00000000..6d7dff06 --- /dev/null +++ b/src/browser/actions/NotificationsActions.js @@ -0,0 +1,11 @@ +import Reflux from 'reflux'; + + +const NotificationsActions = Reflux.createActions([ + 'notify', + 'update', + 'close' +]); + + +export default NotificationsActions; diff --git a/src/browser/components/Notifications.jsx b/src/browser/components/Notifications.jsx new file mode 100644 index 00000000..54ac3620 --- /dev/null +++ b/src/browser/components/Notifications.jsx @@ -0,0 +1,47 @@ +import React, { Component, PropTypes } from 'react'; +import _ from 'lodash'; +import reactMixin from 'react-mixin'; +import { ListenerMixin } from 'reflux'; +import NotificationsStore from '../stores/NotificationsStore'; +import NotificationsItem from './NotificationsItem.jsx'; + + +class Notifications extends Component { + constructor(props) { + super(props); + + this.state = { notifications: [] }; + } + + componentWillMount() { + this.listenTo(NotificationsStore, this.onNotificationsUpdate); + } + + onNotificationsUpdate(notifications) { + this.setState({ notifications }); + } + + render() { + const { notifications } = this.state; + + return ( +
+ {notifications.map(notification => ( + + ))} +
+ ); + } +} + +Notifications.displayName = 'Notifications'; + +Notifications.propTypes = {}; + +reactMixin(Notifications.prototype, ListenerMixin); + + +export default Notifications; diff --git a/src/browser/components/NotificationsItem.jsx b/src/browser/components/NotificationsItem.jsx new file mode 100644 index 00000000..90dc6c4b --- /dev/null +++ b/src/browser/components/NotificationsItem.jsx @@ -0,0 +1,33 @@ +import React, { Component, PropTypes } from 'react'; +import _ from 'lodash'; + + +class NotificationsItem extends Component { + render() { + const { notification } = this.props; + + let content; + if (notification.component) { + content = React.createElement(notification.component, _.assign({}, notification.props, { + notificationId: notification.id + })); + } else { + content = notification.message; + } + + return ( +
+ {content} +
+ ); + } +} + +NotificationsItem.displayName = 'NotificationsItem'; + +NotificationsItem.propTypes = { + notification: PropTypes.object.isRequired +}; + + +export default NotificationsItem; diff --git a/src/browser/stores/NotificationsStore.js b/src/browser/stores/NotificationsStore.js new file mode 100644 index 00000000..11d006ad --- /dev/null +++ b/src/browser/stores/NotificationsStore.js @@ -0,0 +1,91 @@ +import Reflux from 'reflux'; +import _ from 'lodash'; +import NotificationsActions from './../actions/NotificationsActions'; + +let currentId = 0; +let notifications = []; +const timers = {}; + +export const NOTIFICATION_STATUS_SUCCESS = 'success'; +export const NOTIFICATION_STATUS_WARNING = 'warning'; +export const NOTIFICATION_STATUS_ERROR = 'error'; + +const NOTIFICATION_DEFAULT_TTL = 5000; + + +const clearTimer = (id) => { + if (timers[id]) { + clearTimeout(timers[id]); + delete timers[id]; + } +}; + + +const NotificationsStore = Reflux.createStore({ + listenables: NotificationsActions, + + notify(notification) { + if (!_.has(notification, 'id')) { + notification.id = currentId; + currentId++; + } + + if (!_.has(notification, 'ttl')) { + notification.ttl = NOTIFICATION_DEFAULT_TTL; + } + + const existingNotification = _.find(notifications, { id: notification.id }); + if (existingNotification) { + const notificationIndex = _.indexOf(notifications, existingNotification); + notifications = notifications.slice(); + notifications.splice(notificationIndex, 1, notification); + } else { + notifications.push(notification); + } + + if (notification.ttl >= 0) { + this.close(notification.id, notification.ttl); + } + + this.trigger(notifications); + }, + + update(id, changeSet) { + const notification = _.find(notifications, { id }); + if (notification) { + const notificationIndex = _.indexOf(notifications, notification); + notifications = notifications.slice(); + notifications.splice(notificationIndex, 1, _.assign({}, notification, changeSet)); + + this.trigger(notifications); + } + }, + + close(id, delay = 0) { + if (delay > 0) { + clearTimer(id); + timers[id] = setTimeout(() => { this.close(id); }, delay); + return; + } + + const notification = _.find(notifications, { id }); + if (notification) { + const notificationIndex = _.indexOf(notifications, notification); + notifications = notifications.slice(); + notifications.splice(notificationIndex, 1); + + this.trigger(notifications); + } + }, + + reset() { + notifications = []; + currentId = 0; + _.forOwn(timers, (timer, id) => { + clearTimer(id); + }); + } +}); + + +export default NotificationsStore; diff --git a/src/styl/__vars.styl b/src/styl/__vars.styl index 1933d9e5..dde5b325 100644 --- a/src/styl/__vars.styl +++ b/src/styl/__vars.styl @@ -1,97 +1,100 @@ // GENERIC -$main-bg-color = #fff -$main-txt-color = #555 -$main-margin = 1vmin -$main-font = normal normal 400 unquote("2.4vmin/3.6vmin") "Open sans", sans-serif +$main-bg-color = default('$main-bg-color', #fff) +$main-txt-color = default('$main-txt-color', #555) +$main-margin = default('$main-margin', 1vmin) +$main-font = default('$main-font', unquote("normal normal 400 2.4vmin/3.6vmin 'Open sans', sans-serif")) +$card-bg-color = default('$card-bg-color', $main-bg-color) // DASHBOARD -$dashboard-header-height = 8vmin -$dashboard-header-txt-color = #eee -$dashboard-header-font = normal normal 400 unquote("4vmin/8vmin") "Open sans", sans-serif +$dashboard-header-height = default('$dashboard-header-height', 8vmin) +$dashboard-header-txt-color = default('$dashboard-header-txt-color', $main-txt-color) +$dashboard-header-font = default('$dashboard-header-font', $main-font) // WIDGET -$widget-spacing = 1.6vmin -$widget-bg-color = #fff -$widget-shadow = none -$widget-border = none -$widget-inner-spacing = 2vmin +$widget-spacing = default('$widget-spacing', 1.6vmin) +$widget-bg-color = default('$widget-bg-color', $card-bg-color) +$widget-shadow = default('$widget-shadow', none) +$widget-border = default('$widget-border', 0) +$widget-border-radius = default('$widget-border-radius', 0) +$widget-inner-spacing = default('$widget-inner-spacing', 2vmin) // WIDGET — header -$widget-header-height = 6vmin -$widget-header-border = none -$widget-header-bg-color = $main-bg-color -$widget-header-txt-color = $main-txt-color -$widget-header-icon-color = $main-txt-color -$widget-header-icon-size = 3vmin -$widget-header-shadow = none -$widget-header-border-bottom = none -$widget-header-border-radius = 0 -$widget-header-font = normal normal 400 unquote('15px/42px') sans-serif +$widget-header-height = default('$widget-header-height', 6vmin) +$widget-header-border = default('$widget-header-border', 0) +$widget-header-bg-color = default('$widget-header-bg-color', $card-bg-color) +$widget-header-txt-color = default('$widget-header-txt-color', $main-txt-color) +$widget-header-icon-color = default('$widget-header-icon-color', $widget-header-txt-color) +$widget-header-icon-size = default('$widget-header-icon-size', 3vmin) +$widget-header-shadow = default('$widget-header-shadow', none) +$widget-header-border-bottom = default('$widget-header-border-bottom', 0) +$widget-header-border-radius = default('$widget-header-border-radius', $widget-border-radius $widget-border-radius 0 0) +$widget-header-font = default('$widget-header-font', $main-font) + +// COUNT +$count-bg-color = default('$count-bg-color', transparent) +$count-txt-color = default('$count-txt-color', $main-txt-color) +$count-font-size = default('$count-font-size', 2.4vmin) +$count-border = default('$count-border', 0) +$count-border-radius = default('$count-border-radius', 0) +$count-padding = default('$count-padding', 0.4vmin 1.4vmin) // WIDGET — header count -$widget-header-count-bg-color = $main-bg-color -$widget-header-count-txt-color = $main-txt-color -$widget-header-count-shadow = none -$widget-header-count-txt-shadow = none -$widget-header-count-border = none -$widget-header-count-border-radius = 2px -$widget-header-count-padding = 0.8vmin 1vmin +$widget-header-count-bg-color = default('$widget-header-count-bg-color', $count-bg-color) +$widget-header-count-txt-color = default('$widget-header-count-txt-color', $count-txt-color) +$widget-header-count-shadow = default('$widget-header-count-shadow', none) +$widget-header-count-txt-shadow = default('$widget-header-count-txt-shadow', none) +$widget-header-count-border = default('$widget-header-count-border', $count-border) +$widget-header-count-border-radius = default('$widget-header-count-border-radius', $count-border-radius) +$widget-header-count-padding = default('$widget-header-count-padding', $count-padding) // WIDGET — body -$widget-body-border = none -$widget-body-border-radius = 0 -$widget-body-bg-color = transparent -$widget-body-shadow = none - +$widget-body-border = default('$widget-body-border', 0) +$widget-body-border-radius = default('$widget-body-border-radius', 0 0 $widget-border-radius $widget-border-radius) +$widget-body-bg-color = default('$widget-body-bg-color', $widget-bg-color) +$widget-body-shadow = default('$widget-body-shadow', none) // LIST -$list_item_padding = 1.5vmin 2vmin -$list_item_with_status_padding = 1.5vmin 2vmin 1.5vmin 4.5vmin -$list_item_status_icon_top = 2.3vmin -$list_item_status_icon_left = 2vmin -$list_item_status_icon_size = 1.5vmin - +$list_item_padding = default('$list_item_padding', 1.5vmin 2vmin) +$list_item_with_status_padding = default('$list_item_with_status_padding', 1.5vmin 2vmin 1.5vmin 4.5vmin) +$list_item_status_icon_top = default('$list_item_status_icon_top', 2.3vmin) +$list_item_status_icon_left = default('$list_item_status_icon_left', 2vmin) +$list_item_status_icon_size = default('$list_item_status_icon_size', 1.5vmin) // TABLE -$table-cell-padding = 1.5vmin 2vmin -$table-border-h = 1px solid #000 - - -// COUNT -$count-padding = 0.4vmin 1.4vmin -$count-font-size = 2.4vmin -$count-bg-color = $main-bg-color -$count-txt-color = $main-txt-color -$count-border-radius = 0 -$count-border = none - +$table-cell-padding = default('$table-cell-padding', 1.5vmin 2vmin) +$table-border-h = default('$table-border-h', 1px solid #000) // LABEL -$label-padding = 0.4vmin 1.4vmin -$label-font-size = 1.8vmin -$label-bg-color = $main-bg-color -$label-txt-color = $main-txt-color -$label-addon-bg-color = $label-bg-color -$label-addon-txt-color = $label-txt-color -$label-border-radius = 0 -$label-border = 0 - +$label-padding = default('$label-padding', 0.4vmin 1.4vmin) +$label-font-size = default('$label-font-size', 1.8vmin) +$label-bg-color = default('$label-bg-color', transparent) +$label-txt-color = default('$label-txt-color', $main-txt-color) +$label-addon-bg-color = default('$label-addon-bg-color', $label-bg-color) +$label-addon-txt-color = default('$label-addon-txt-color', $label-txt-color) +$label-border-radius = default('$label-border-radius', 0) +$label-border = default('$label-border', 0) + +// NOTIFICATIONS +$notifications-padding = default('$notifications-padding', 1.4vmin 2vmin 1.4vmin 2.8vmin) +$notifications-bg-color = default('$notifications-bg-color', $card-bg-color) +$notifications-txt-color = default('$notifications-txt-color', $main-txt-color) +$notifications-shadow = default('$notifications-shadow', 0 1px 1px rgba(0, 0, 0, 0.35)) +$notifications-marker-width = default('$notifications-marker-width', 0.8vmin) // Meaningful colors -$unknown-color = #495b71 -$success-color = #2ac256 -$warning-color = #d1be65 -$failure-color = #de1500 - +$unknown-color = default('$unknown-color', #495b71) +$success-color = default('$success-color', #30b366) +$warning-color = default('$warning-color', #d1be65) +$failure-color = default('$failure-color', #d53721) // CHARTS -$histogram-bar-bg-color = #ddd -$chart-axis-txt-color = $main-txt-color -$chart-tick-txt-size = 1.2vmin -$chart-axis-tick-color = $main-txt-color -$chart-grid-line-color = $main-txt-color - +$chart-elements-color = default('$chart-elements-color', $main-txt-color) +$histogram-bar-bg-color = default('$histogram-bar-bg-color', $chart-elements-color) +$chart-axis-txt-color = default('$chart-axis-txt-color', $chart-elements-color) +$chart-tick-txt-size = default('$chart-tick-txt-size', 1.2vmin) +$chart-axis-tick-color = default('$chart-axis-tick-color', $chart-elements-color) +$chart-grid-line-color = default('$chart-grid-line-color', $chart-elements-color) // PROPS -$prop-key-txt-color = $main-txt-color -$prop-value-txt-color = $main-txt-color +$prop-key-txt-color = default('$prop-key-txt-color', $main-txt-color) +$prop-value-txt-color = default('$prop-value-txt-color', $main-txt-color) diff --git a/src/styl/_mixins.styl b/src/styl/_mixins.styl index 7081ac10..d2f499dd 100644 --- a/src/styl/_mixins.styl +++ b/src/styl/_mixins.styl @@ -3,4 +3,10 @@ transition($value) -moz-transition $value; /* FF4+ */ -ms-transition $value; -o-transition $value; /* Opera 10.5+ */ - transition $value; \ No newline at end of file + transition $value; + +default($key, $value) + if lookup($key) is null + return $value + + return lookup($key) diff --git a/src/styl/components/notifications.styl b/src/styl/components/notifications.styl new file mode 100644 index 00000000..6c142397 --- /dev/null +++ b/src/styl/components/notifications.styl @@ -0,0 +1,31 @@ +.notifications + position absolute + top ($main-margin + $widget-spacing * 2) + right ($main-margin + $widget-spacing * 2) + z-index 10000 + width 25% + + &__item + position relative + margin-bottom 1.4vmin + padding $notifications-padding + background $notifications-bg-color + color $notifications-txt-color + box-shadow $notifications-shadow + + &:before + position absolute + content ' ' + top 0 + left 0 + bottom 0 + width $notifications-marker-width + + &--success:before + background $success-color + + &--warning:before + background $warning-color + + &--error:before + background $failure-color \ No newline at end of file diff --git a/test/frontend/stores/NotificationStore.test.js b/test/frontend/stores/NotificationStore.test.js new file mode 100644 index 00000000..ecd01292 --- /dev/null +++ b/test/frontend/stores/NotificationStore.test.js @@ -0,0 +1,139 @@ +/* global describe it */ +import _ from 'lodash'; +import expect from 'expect'; +import sinon from 'sinon'; +import mockery from 'mockery'; +import { expectTriggers } from '../../helpers/storeHelper'; +import { getFakeTimerCount } from '../../helpers/timersHelper'; + + +let clock; +let triggerSpy; +let NotificationsStore; + + +describe('Mozaïk | NotificationsStore', () => { + beforeEach(() => { + clock = sinon.useFakeTimers(); + NotificationsStore = require('../../../src/browser/stores/NotificationsStore').default; + triggerSpy = sinon.spy(); + NotificationsStore.trigger = triggerSpy; + NotificationsStore.reset(); + }); + + afterEach(() => { + clock.restore(); + }); + + describe('notify()', () => { + it('should add a notification to the current list of notifications', () => { + NotificationsStore.notify({}); + + expectTriggers(triggerSpy, [ + (notifications) => { + expect(notifications.length).toEqual(1); + } + ]); + }); + + it('should generate a unique notification id if none given', () => { + NotificationsStore.notify({}); + NotificationsStore.notify({}); + + expectTriggers(triggerSpy, [ + 'skip', + (notifications) => { + expect(notifications.length).toEqual(2); + expect(_.uniqBy(notifications, 'id').length).toEqual(2); + } + ]); + }); + + it(`should set a default 'ttl' if none given`, () => { + NotificationsStore.notify({}); + + expectTriggers(triggerSpy, [[{ id: 0, ttl: 5000 }]]); + }); + + it(`should allow to define a custom 'ttl'`, () => { + NotificationsStore.notify({ ttl: 1000 }); + + expectTriggers(triggerSpy, [[{ id: 0, ttl: 1000 }]]); + }); + + it(`should remove notification after given 'ttl'`, () => { + NotificationsStore.notify({}); + + expectTriggers(triggerSpy, [[{ id: 0, ttl: 5000 }]]); + + clock.tick(5000); + + expectTriggers(triggerSpy, ['skip', []]); + }); + + it(`should not remove notification if 'ttl' is -1`, () => { + NotificationsStore.notify({ ttl: -1 }); + clock.tick(5000); + + expectTriggers(triggerSpy, [[{ id: 0, ttl: -1 }]]); + }); + + it(`should replace existing notification if there's already one having given id`, () => { + NotificationsStore.notify({ id: 1, message: 'foo' }); + NotificationsStore.notify({ id: 1, message: 'bar' }); + + expectTriggers(triggerSpy, [ + [{ id: 1, message: 'foo', ttl: 5000 }], + [{ id: 1, message: 'bar', ttl: 5000 }] + ]); + }); + + it(`should clear existing timer if there's already a notification having given id`, () => { + NotificationsStore.notify({ id: 1, message: 'foo' }); + NotificationsStore.notify({ id: 1, message: 'bar' }); + + expect(getFakeTimerCount(clock)).toEqual(1); + }); + }); + + describe('update()', () => { + it('should update status for notification matching given id', () => { + NotificationsStore.notify({ id: 1, status: 'warning' }); + NotificationsStore.update(1, { status: 'error' }); + + expectTriggers(triggerSpy, [ + [{ id: 1, status: 'warning', ttl: 5000 }], + [{ id: 1, status: 'error', ttl: 5000 }] + ]); + }); + }); + + describe('close()', () => { + it('should remove notification matching given id', () => { + NotificationsStore.notify({ id: 1 }); + NotificationsStore.close(1); + + expectTriggers(triggerSpy, [[{ id: 1, ttl: 5000 }], []]); + }); + + it(`should delay notification removal if 'delay' is not 0`, () => { + NotificationsStore.notify({ id: 1 }); + NotificationsStore.close(1, 1000); + + expectTriggers(triggerSpy, [[{ id: 1, ttl: 5000 }]]); + + triggerSpy.reset(); + clock.tick(1000); + + expectTriggers(triggerSpy, [[]]); + }); + + it(`should clear previous timer if called twice for the same notification id`, () => { + NotificationsStore.notify({ id: 1 }); + NotificationsStore.close(1, 1000); + NotificationsStore.close(1, 1000); + + expect(getFakeTimerCount(clock)).toEqual(1); + }); + }); +}); diff --git a/test/helpers/storeHelper.js b/test/helpers/storeHelper.js new file mode 100644 index 00000000..7cbe6436 --- /dev/null +++ b/test/helpers/storeHelper.js @@ -0,0 +1,19 @@ +import _ from 'lodash'; +import expect from 'expect'; + + +export const expectTriggers = (spy, expectedCalls) => { + expect(spy.callCount).toEqual(expectedCalls.length); + + expectedCalls.forEach((expectedCall, i) => { + if (expectedCall !== 'skip') { + const state = spy.getCall(i).args[0]; + + if (_.isFunction(expectedCall)) { + expectedCall(state); + } else { + expect(state).toEqual(expectedCall); + } + } + }); +}; diff --git a/test/helpers/timersHelper.js b/test/helpers/timersHelper.js new file mode 100644 index 00000000..7718c89f --- /dev/null +++ b/test/helpers/timersHelper.js @@ -0,0 +1,12 @@ +import _ from 'lodash'; +import expect from 'expect'; + + +export const getFakeTimerCount = (clock) => { + return _.reduce(clock.timers, (count, timer) => { + if (timer !== undefined) { + count++; + } + return count; + }, 0); +};