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);
+};