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/ConnectionStatusActions.js b/src/browser/actions/ConnectionStatusActions.js
new file mode 100644
index 00000000..c9fbccba
--- /dev/null
+++ b/src/browser/actions/ConnectionStatusActions.js
@@ -0,0 +1,13 @@
+import Reflux from 'reflux';
+
+
+const ConnectionStatusActions = Reflux.createActions([
+ 'connecting',
+ 'connected',
+ 'disconnected',
+ 'delaying',
+ 'failed'
+]);
+
+
+export default ConnectionStatusActions;
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/ConnectionStatus.jsx b/src/browser/components/ConnectionStatus.jsx
new file mode 100644
index 00000000..7d1b92e4
--- /dev/null
+++ b/src/browser/components/ConnectionStatus.jsx
@@ -0,0 +1,72 @@
+import React, { Component, PropTypes } from 'react';
+import reactMixin from 'react-mixin';
+import { ListenerMixin } from 'reflux';
+import ConnectionStatusStore, {
+ CONNECTION_STATUS_CONNECTING,
+ CONNECTION_STATUS_CONNECTED,
+ CONNECTION_STATUS_DISCONNECTED,
+ CONNECTION_STATUS_DELAYING,
+ CONNECTION_STATUS_FAILED
+} from '../stores/ConnectionStatusStore';
+
+
+class ConnectionStatus extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = ConnectionStatusStore.getState();
+ }
+
+ componentWillMount() {
+ this.listenTo(ConnectionStatusStore, this.onStatusUpdate);
+ }
+
+ onStatusUpdate({ countdown, status, retry }) {
+ this.setState({ countdown, status, retry });
+ }
+
+ render() {
+ const { countdown, status, retry } = this.state;
+
+ let message;
+ let iconClass;
+ if (status === CONNECTION_STATUS_CONNECTING) {
+ message = 'connecting';
+ iconClass = 'fa fa-question';
+ } else if (status === CONNECTION_STATUS_CONNECTED) {
+ message = 'connected';
+ iconClass = 'fa fa-check';
+ } else if (status === CONNECTION_STATUS_DISCONNECTED || status === CONNECTION_STATUS_DELAYING) {
+ message = 'disconnected';
+ iconClass = 'fa fa-warning';
+
+ if (status === CONNECTION_STATUS_DELAYING) {
+ message = (
+
+ disconnected
+ next attempt in {countdown}s
+
+ );
+ }
+ } else if (status === CONNECTION_STATUS_FAILED) {
+ iconClass = 'fa fa-frown-o';
+ message = `unable to restore websockets after ${retry} attemps,
+ please make sure Mozaïk server is running and that
+ you can reach the internet if running on a remote server.`;
+ }
+
+ return (
+
+
+ {message}
+
+ );
+ }
+}
+
+ConnectionStatus.displayName = 'ConnectionStatus';
+
+reactMixin(ConnectionStatus.prototype, ListenerMixin);
+
+
+export default ConnectionStatus;
diff --git a/src/browser/components/Mozaik.jsx b/src/browser/components/Mozaik.jsx
index 5f4a355f..170412b4 100644
--- a/src/browser/components/Mozaik.jsx
+++ b/src/browser/components/Mozaik.jsx
@@ -2,7 +2,8 @@ import React, { Component, PropTypes } from 'react';
import reactMixin from 'react-mixin';
import { ListenerMixin } from 'reflux';
import Dashboard from './Dashboard.jsx';
-import ConfigStore from './../stores/ConfigStore';
+import Notifications from './Notifications.jsx';
+import ConfigStore from '../stores/ConfigStore';
class Mozaik extends Component {
@@ -35,6 +36,7 @@ class Mozaik extends Component {
return (
{dashboardNodes}
+
);
}
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/ApiStore.js b/src/browser/stores/ApiStore.js
index 722c40a7..bd1f138d 100644
--- a/src/browser/stores/ApiStore.js
+++ b/src/browser/stores/ApiStore.js
@@ -1,9 +1,114 @@
-import Reflux from 'reflux';
-import ApiActions from './../actions/ApiActions';
-import ConfigStore from './ConfigStore';
+import Reflux from 'reflux';
+import ConfigStore from './ConfigStore';
+import ApiActions from '../actions/ApiActions';
+import ConfigActions from '../actions/ConfigActions';
+import ConnectionStatusActions from '../actions/ConnectionStatusActions';
+import NotificationsActions from '../actions/NotificationsActions';
+import ConnectionStatus from '../components/ConnectionStatus.jsx';
+import {
+ NOTIFICATION_STATUS_SUCCESS,
+ NOTIFICATION_STATUS_WARNING,
+ NOTIFICATION_STATUS_ERROR
+} from './NotificationsStore';
+
+const NOTIFICATION_ID = 'connection.status';
+
+const CONNECTION_RETRY_DELAY_SECONDS = 15;
+const CONNECTION_MAX_RETRIES = 10;
+let retryCount = 0;
+
+let reconnections = 0;
-const buffer = [];
let ws = null;
+let retryTimer;
+let history = [];
+let buffer = [];
+
+
+const clearRetryTimer = () => {
+ if (retryTimer) {
+ clearTimeout(retryTimer);
+ retryTimer = null;
+ }
+};
+
+
+const connectWS = (config, store) => {
+ ConnectionStatusActions.connecting();
+ NotificationsActions.update(NOTIFICATION_ID, { status: NOTIFICATION_STATUS_WARNING });
+
+ let proto = 'ws';
+ if (config.useWssConnection === true) {
+ proto = 'wss';
+ }
+
+ let port = window.document.location.port;
+ if (config.wsPort !== undefined) {
+ port = config.wsPort;
+ }
+
+ let wsUrl = `${proto}://${window.document.location.hostname}`;
+ if (port && port !== '') {
+ wsUrl = `${wsUrl}:${port}`;
+ }
+
+ ws = new WebSocket(wsUrl);
+
+ ws.onopen = event => {
+ clearRetryTimer();
+
+ retryCount = 0;
+
+ ConnectionStatusActions.connected();
+ NotificationsActions.update(NOTIFICATION_ID, { status: NOTIFICATION_STATUS_SUCCESS });
+ NotificationsActions.close(NOTIFICATION_ID, 2000);
+
+ if (reconnections > 0) {
+ ConfigActions.loadConfig();
+ history.forEach(request => { ws.send(JSON.stringify(request)); });
+ } else {
+ buffer.forEach(request => { ws.send(JSON.stringify(request)); });
+ buffer = [];
+ }
+
+ reconnections++;
+ };
+
+ ws.onmessage = event => {
+ if (event.data !== '') {
+ store.trigger(JSON.parse(event.data));
+ }
+ };
+
+ ws.onclose = event => {
+ ws = null;
+
+ clearRetryTimer();
+
+ if (retryCount === 0) {
+ NotificationsActions.notify({
+ id: NOTIFICATION_ID,
+ component: ConnectionStatus,
+ status: NOTIFICATION_STATUS_WARNING,
+ ttl: -1
+ });
+ } else if (retryCount === CONNECTION_MAX_RETRIES) {
+ ConnectionStatusActions.failed(retryCount);
+ NotificationsActions.update(NOTIFICATION_ID, { status: NOTIFICATION_STATUS_ERROR });
+ return;
+ }
+
+ ConnectionStatusActions.delaying(retryCount, CONNECTION_RETRY_DELAY_SECONDS);
+ NotificationsActions.update(NOTIFICATION_ID, { status: NOTIFICATION_STATUS_WARNING });
+
+ retryTimer = setTimeout(() => {
+ connectWS(config, store);
+ }, CONNECTION_RETRY_DELAY_SECONDS * 1000);
+
+ retryCount++;
+ };
+};
+
const ApiStore = Reflux.createStore({
init() {
@@ -11,43 +116,24 @@ const ApiStore = Reflux.createStore({
},
initWs(config) {
- let proto = 'ws';
- if (config.useWssConnection === true) {
- proto = 'wss';
+ // only connect ws if it's not already connected, when connection is lost and we succeed in re-establishing it
+ // we reload configuration, so without this check we'll end in an infinite loop.
+ if (ws === null) {
+ connectWS(config, this);
}
- let port = window.document.location.port;
- if (config.wsPort !== undefined) {
- port = config.wsPort;
- }
+ this.listenTo(ApiActions.get, this.fetch);
+ },
- let wsUrl = `${proto}://${window.document.location.hostname}`;
- if (port && port !== '') {
- wsUrl = `${wsUrl}:${port}`;
- }
+ fetch(id, params = {}) {
+ const request = { id, params };
- ws = new WebSocket(wsUrl);
- ws.onmessage = event => {
- if (event.data !== '') {
- ApiStore.trigger(JSON.parse(event.data));
- }
- };
+ // keep track to use when re-connecting
+ history.push(request);
- ws.onopen = () => {
- buffer.forEach(request => {
- ws.send(JSON.stringify(request));
- });
- };
- this.listenTo(ApiActions.get, this.get);
- },
-
- get(id, params) {
+ // if websockets not ready, add request to buffer
if (ws === null || ws.readyState !== WebSocket.OPEN) {
- buffer.push({
- id: id,
- params: params || {}
- });
-
+ buffer.push(request);
return;
}
@@ -55,6 +141,26 @@ const ApiStore = Reflux.createStore({
id: id,
params: params || {}
}));
+ },
+
+ getHistory() {
+ return history;
+ },
+
+ getBuffer() {
+ return buffer;
+ },
+
+ reset() {
+ clearRetryTimer();
+
+ history = [];
+ buffer = [];
+
+ if (ws !== null) {
+ ws.close();
+ ws = null;
+ }
}
});
diff --git a/src/browser/stores/ConnectionStatusStore.js b/src/browser/stores/ConnectionStatusStore.js
new file mode 100644
index 00000000..1eb9ba82
--- /dev/null
+++ b/src/browser/stores/ConnectionStatusStore.js
@@ -0,0 +1,89 @@
+import Reflux from 'reflux';
+import ConnectionStatusActions from '../actions/ConnectionStatusActions';
+
+
+export const CONNECTION_STATUS_CONNECTING = 'connecting';
+export const CONNECTION_STATUS_CONNECTED = 'connected';
+export const CONNECTION_STATUS_DISCONNECTED = 'disconnected';
+export const CONNECTION_STATUS_DELAYING = 'delaying';
+export const CONNECTION_STATUS_FAILED = 'failed';
+
+
+// current store state
+let status = CONNECTION_STATUS_DISCONNECTED;
+let retry = 0;
+let countdown = 0;
+let countdownTimer;
+
+
+const clearCountdown = () => {
+ if (countdownTimer) {
+ clearInterval(countdownTimer);
+ countdownTimer = null;
+ }
+ countdown = 0;
+};
+
+
+const ConnectionStatusStore = Reflux.createStore({
+ listenables: ConnectionStatusActions,
+
+ getState() {
+ return { status, retry, countrdown };
+ },
+
+ setStatus(newStatus) {
+ clearCountdown();
+
+ status = newStatus;
+
+ this.trigger({ status, retry, countdown });
+ },
+
+ connecting() {
+ this.setStatus(CONNECTION_STATUS_CONNECTING);
+ },
+
+ connected() {
+ this.setStatus(CONNECTION_STATUS_CONNECTED);
+ },
+
+ disconnected() {
+ this.setStatus(CONNECTION_STATUS_DISCONNECTED);
+ },
+
+ delaying(retryCount, seconds) {
+ clearCountdown();
+
+ status = CONNECTION_STATUS_DELAYING;
+ retry = retryCount;
+ countdown = seconds;
+
+ if (seconds > 0) {
+ countdownTimer = setInterval(() => {
+ if (countdown > 0) {
+ countdown--;
+ }
+
+ this.trigger({ status, retry, countdown });
+ }, 1000);
+ }
+
+ this.trigger({ status, retry, countdown });
+ },
+
+ failed(retryCount) {
+ retry = retryCount;
+
+ this.setStatus(CONNECTION_STATUS_FAILED);
+ },
+
+ reset() {
+ status = CONNECTION_STATUS_DISCONNECTED;
+ retry = 0;
+ clearCountdown();
+ }
+});
+
+
+export default ConnectionStatusStore;
diff --git a/src/browser/stores/NotificationsStore.js b/src/browser/stores/NotificationsStore.js
new file mode 100644
index 00000000..2726d11f
--- /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 = [];
+
+export const NOTIFICATION_STATUS_SUCCESS = 'success';
+export const NOTIFICATION_STATUS_WARNING = 'warning';
+export const NOTIFICATION_STATUS_ERROR = 'error';
+
+const NOTIFICATION_DEFAULT_TTL = 5000;
+
+const timers = {};
+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/connection-status.styl b/src/styl/components/connection-status.styl
new file mode 100644
index 00000000..9ab03cf2
--- /dev/null
+++ b/src/styl/components/connection-status.styl
@@ -0,0 +1,9 @@
+.connection-status
+ .fa
+ margin-right 1vmin
+
+ &-warning
+ color $warning-color
+
+ &-check
+ color $success-color
diff --git a/src/styl/components/label.styl b/src/styl/components/label.styl
index 88786e28..ee9bd3a8 100644
--- a/src/styl/components/label.styl
+++ b/src/styl/components/label.styl
@@ -8,12 +8,16 @@
border $label-border
&__group
- display flex
+ display inline-flex
border $label-border
+ border-radius $label-border-radius
+ align-items stretch
+ align-content stretch
.label
border 0
border-radius 0
+ flex-grow 1
*
border-left $label-sep-border
@@ -25,14 +29,6 @@
*:last-child
border-radius 0 $label-border-radius $label-border-radius 0
- &--full
- display flex
- align-items stretch
- align-content stretch
-
- .label
- flex-grow 1
-
&__addon
padding $label-padding
white-space pre
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/src/styl/mozaik.styl b/src/styl/mozaik.styl
index 0275174d..8c92b3ab 100644
--- a/src/styl/mozaik.styl
+++ b/src/styl/mozaik.styl
@@ -1,8 +1,9 @@
-@require '__vars'
+@require '_mixins'
@require $theme + '/_vars'
-@require '_mixins'
+@require '__vars'
+
@require '_main'
@require 'components/dashboard'
@require 'components/widget'
@@ -14,6 +15,8 @@
@require 'components/pie'
@require 'components/bar-chart'
@require 'components/inspector'
+@require 'components/notifications'
+@require 'components/connection-status'
// IMPORT EXTENSIONS STYLES
@require '../ext/collected'
diff --git a/src/themes/bordeau/_vars.styl b/src/themes/bordeau/_vars.styl
index 27f4a844..6a372408 100644
--- a/src/themes/bordeau/_vars.styl
+++ b/src/themes/bordeau/_vars.styl
@@ -5,6 +5,7 @@ $main-bg-color = rgb(40, 18, 18)
$main-txt-color = hsl(6, 26%, 67%)
$main-margin = 4vmin
$main-font = normal normal 400 unquote("2vmin/3vmin") "Open sans", sans-serif
+$card-bg-color = rgb(69, 23, 23)
// DASHBOARD
$dashboard-header-height = 6vmin
@@ -13,56 +14,42 @@ $dashboard-header-font = normal normal 300 unquote("2.6vmin/6vmin") "Ro
// WIDGET
$widget-spacing = 0.4vmin
-$widget-bg-color = rgb(69, 23, 23)
-$widget-border-radius = 0
// WIDGET — header
-$widget-header-bg-color = transparent
$widget-header-txt-color = hsl(10, 60%, 90%)
$widget-header-icon-color = hsl(0, 52%, 60%)
-$widget-header-shadow = none
$widget-header-border-bottom = 1px solid $main-bg-color
-$widget-header-border-radius = 0
$widget-header-font = normal normal 100 2.5vmin "Roboto Slab", sans-serif
-// WIDGET — header count
-$widget-header-count-bg-color = $main-bg-color
-$widget-header-count-txt-color = hsl(0, 52%, 60%)
-
-// WIDGET — body
-$widget-body-border-radius = 0
-$widget-body-bg-color = transparent
-
-
// COUNT
-$count-bg-color = lighten($widget-bg-color, 4)
+$count-bg-color = lighten($card-bg-color, 4)
$count-txt-color = hsl(0, 52%, 60%)
$count-border-radius = 2px
+// WIDGET — header count
+$widget-header-count-bg-color = $main-bg-color
// LABEL
-$label-bg-color = lighten($widget-bg-color, 5)
+$label-bg-color = lighten($card-bg-color, 5)
$label-txt-color = $widget-header-txt-color
$label-addon-bg-color = $main-bg-color
$label-addon-txt-color = $widget-header-icon-color
$label-border-radius = 2px
+$notifications-bg-color = lighten($card-bg-color, 7)
// TABLE
$table-border-h = 1px solid $main-bg-color
-
// Meaningful colors
$unknown-color = #7e706d;
$success-color = #50a3b2;
$failure-color = #a31c12;
-
// CHARTS
-$histogram-bar-bg-color = lighten($widget-bg-color, 4);
-$chart-axis-txt-color = $main-txt-color;
-
+$histogram-bar-bg-color = lighten($card-bg-color, 4)
+$chart-axis-txt-color = $main-txt-color
// PROPS
-$prop-key-txt-color = $main-txt-color;
-$prop-value-txt-color = lighten($main-txt-color, 13);
\ No newline at end of file
+$prop-key-txt-color = $main-txt-color
+$prop-value-txt-color = lighten($main-txt-color, 13)
diff --git a/src/themes/light-grey/_vars.styl b/src/themes/light-grey/_vars.styl
index de9f9032..191a9429 100644
--- a/src/themes/light-grey/_vars.styl
+++ b/src/themes/light-grey/_vars.styl
@@ -17,14 +17,18 @@ $widget-shadow = none
// WIDGET — header
$widget-header-height = 7vmin
$widget-header-bg-color = transparent
-$widget-header-txt-color = #999999
+$widget-header-txt-color = #999
$widget-header-icon-color = #84c2f6
+// COUNT
+$count-padding = 3px 7px
+$count-bg-color = $main-bg-color
+$count-txt-color = #5b89b5
+$count-border-radius = 2px
+
// WIDGET — header count
$widget-header-count-bg-color = #fefefe
$widget-header-count-txt-color = #888
-$widget-header-count-shadow = none
-$widget-header-count-txt-shadow = none
$widget-header-font = normal normal 300 3.4vmin "Lato", sans-serif
// WIDGET — body
@@ -32,33 +36,20 @@ $widget-body-border-radius = 2px
$widget-body-bg-color = #fff
$widget-body-shadow = 0 1px 2px rgba(0, 0, 0, 0.2)
-
-// COUNT
-$count-padding = 3px 7px
-$count-bg-color = $main-bg-color
-$count-txt-color = #5b89b5
-$count-border-radius = 2px
-$count-border = none
-
-
// LABEL
$label-bg-color = #fff
-$label-txt-color = $main-txt-color
$label-addon-bg-color = $main-bg-color
$label-addon-txt-color = #84c2f6
$label-border = 1px solid darken($main-bg-color, 5)
-
// TABLE
$table-border-h = 1px solid #ddd
-
// Meaningful colors
$unknown-color = #cccccc
$success-color = #84c2f6
$failure-color = #e89643
-
// CHARTS
$histogram-bar-bg-color = #eee
$chart-axis-txt-color = #999
\ No newline at end of file
diff --git a/src/themes/light-yellow/_vars.styl b/src/themes/light-yellow/_vars.styl
index 4b165ab1..ff623092 100644
--- a/src/themes/light-yellow/_vars.styl
+++ b/src/themes/light-yellow/_vars.styl
@@ -8,47 +8,36 @@ $main-font = normal normal 400 unquote("2.2vmin/3.2vmin") "
// DASHBOARD
$dashboard-header-height = 6vmin
-$dashboard-header-txt-color = #050505
$dashboard-header-font = normal normal 300 unquote("4vmin/6vmin") "Lato", sans-serif
// WIDGET
$widget-spacing = 2vmin
$widget-bg-color = #f1e2b9
-$widget-border-radius = 0
$widget-border = 2px solid #050505
// WIDGET — header
$widget-header-height = 6vmin
$widget-header-bg-color = #ccc0a1
-$widget-header-txt-color = #050505
-$widget-header-icon-color = #050505
$widget-header-icon-size = 24px
$widget-header-border-bottom = 2px solid #050505
-$widget-header-border-radius = 0
$widget-header-font = normal normal 300 3vmin "Lato", sans-serif
+// COUNT
+$count-padding = 3px 7px
+$count-bg-color = lighten($main-bg-color, 3)
+$count-border-radius = 2px
+$count-border = 1px solid #000
+
// WIDGET — header count
$widget-header-count-border = 1px solid #050505
$widget-header-count-bg-color = transparent
-$widget-header-count-txt-color = #050505
// WIDGET — body
-$widget-body-border-radius = 0
$widget-body-bg-color = transparent
-
// TABLE
$table-border-h = 1px solid #050505
-
-// COUNT
-$count-padding = 3px 7px
-$count-bg-color = lighten($main-bg-color, 3)
-$count-txt-color = $main-txt-color
-$count-border-radius = 2px
-$count-border = 1px solid #000
-
-
// LABEL
$label-bg-color = transparent
$label-txt-color = $main-txt-color
@@ -56,14 +45,12 @@ $label-addon-bg-color = $widget-header-bg-color
$label-addon-txt-color = $widget-header-txt-color
$label-border = $count-border
-
// Meaningful colors
$unknown-color = #9d937a
$success-color = #41508b
$warning-color = #d1be65
$failure-color = #f9703c
-
// CHARTS
$histogram-bar-bg-color = #9d937a
$chart-axis-txt-color = #050505
\ No newline at end of file
diff --git a/src/themes/night-blue/_vars.styl b/src/themes/night-blue/_vars.styl
index 83195344..146d518b 100644
--- a/src/themes/night-blue/_vars.styl
+++ b/src/themes/night-blue/_vars.styl
@@ -1,4 +1,4 @@
-@import url("https://fonts.googleapis.com/css?family=Raleway:400,200,800|Montserrat:400,700")
+@import url("https://fonts.googleapis.com/css?family=Raleway:200,400,600,800|Montserrat:400,700")
// GENERIC
$main-bg-color = #1e2430
@@ -23,7 +23,6 @@ $widget-header-txt-color = #eedba5
$widget-header-icon-color = #e0c671
$widget-header-shadow = 0 1px 0 #495b71 inset
$widget-header-border-bottom = 1px solid #253246
-$widget-header-border-radius = 2px 2px 0 0
$widget-header-font = normal normal 400 1.6vmin "Montserrat", sans-serif
// WIDGET — header count
@@ -35,15 +34,12 @@ $widget-header-count-border = none
// WIDGET — body
$widget-body-border = none
-$widget-body-border-radius = 0 0 2px 2px
$widget-body-bg-color = transparent
$widget-body-shadow = none
-
// TABLE
$table-border-h = 1px solid #253246
-
// COUNT
$count-padding = 3px 7px
$count-bg-color = #1e2836
@@ -51,7 +47,6 @@ $count-txt-color = $main-txt-color
$count-border-radius = 2px
$count-border = none
-
// LABEL
$label-bg-color = #212e41
$label-txt-color = $widget-header-txt-color
@@ -59,6 +54,10 @@ $label-addon-bg-color = #1e2836
$label-addon-txt-color = $widget-header-txt-color
$label-border-radius = 2px
+// NOTIFICATIONS
+$notifications-bg-color = lighten($widget-header-bg-color, 5)
+$notifications-txt-color = $widget-header-txt-color
+$notifications-shadow = 0 1px 1px rgba(0, 0, 0, 0.85)
// Meaningful color
$unknown-color = #495b71
@@ -66,11 +65,9 @@ $success-color = #4ec2b4
$warning-color = #d1be65
$failure-color = #de5029
-
// CHART
+$chart-elements-color = lighten($widget-bg-color, 40)
$histogram-bar-bg-color = lighten($widget-bg-color, 7)
-$chart-axis-txt-color = lighten($widget-bg-color, 40)
-
// PROPS
$prop-key-txt-color = $main-txt-color
diff --git a/src/themes/night-blue/_dashboard.styl b/src/themes/night-blue/dashboard.styl
similarity index 100%
rename from src/themes/night-blue/_dashboard.styl
rename to src/themes/night-blue/dashboard.styl
diff --git a/src/themes/night-blue/index.styl b/src/themes/night-blue/index.styl
index 96c5b699..604e6649 100644
--- a/src/themes/night-blue/index.styl
+++ b/src/themes/night-blue/index.styl
@@ -1,4 +1,4 @@
-@require '_dashboard'
-@require '_widget'
-@require '_list'
-@require 'time/_clock'
\ No newline at end of file
+@require 'dashboard'
+@require 'widget'
+@require 'list'
+@require 'time/clock'
diff --git a/src/themes/night-blue/_list.styl b/src/themes/night-blue/list.styl
similarity index 100%
rename from src/themes/night-blue/_list.styl
rename to src/themes/night-blue/list.styl
diff --git a/src/themes/night-blue/time/_clock.styl b/src/themes/night-blue/time/clock.styl
similarity index 100%
rename from src/themes/night-blue/time/_clock.styl
rename to src/themes/night-blue/time/clock.styl
diff --git a/src/themes/night-blue/_widget.styl b/src/themes/night-blue/widget.styl
similarity index 100%
rename from src/themes/night-blue/_widget.styl
rename to src/themes/night-blue/widget.styl
diff --git a/src/themes/snow/_vars.styl b/src/themes/snow/_vars.styl
index 58f136dd..538dd368 100644
--- a/src/themes/snow/_vars.styl
+++ b/src/themes/snow/_vars.styl
@@ -6,61 +6,39 @@ $main-bg-color = #ebf0f1
$main-txt-color = #333
$main-margin = 2vmin
$main-font = normal normal 400 unquote("1.6vmin/3vmin") "Open Sans", sans-serif
+$card-bg-color = #fff
// DASHBOARD
$dashboard-header-height = 6vmin
-$dashboard-header-txt-color = #333
$dashboard-header-font = normal normal 400 unquote("2.6vmin/6vmin") "Montserrat", sans-serif
// WIDGET
$widget-spacing = 2vmin
-$widget-bg-color = #fff
$widget-shadow = 0 1px 1px rgba(0, 0, 0, 0.15)
$widget-border-radius = 2px
-$widget-border = none
// WIDGET — header
$widget-header-height = 5vmin
-$widget-header-border = none
$widget-header-bg-color = #fafafa
-$widget-header-txt-color = #333
$widget-header-icon-color = #bbb
$widget-header-icon-size = 2vmin
-$widget-header-shadow = none
-$widget-header-border-bottom = none
-$widget-header-border-radius = 2px 2px 0 0
$widget-header-font = normal normal 400 2vmin "Montserrat", sans-serif
-// WIDGET — header count
-$widget-header-count-bg-color = transparent
-$widget-header-count-txt-color = $main-txt-color
-$widget-header-count-shadow = none
-$widget-header-count-txt-shadow = none
-$widget-header-count-border = 1px solid #ddd
-$widget-header-count-border-radius = 2vmin
-$widget-header-count-padding = 0.6vmin 1.6vmin
+// COUNT
+$count-padding = 0.4vmin 1.2vmin
+$count-font-size = 1.8vmin
+$count-border-radius = 2vmin
+$count-border = 1px solid #ddd
+// WIDGET — header count
+$widget-header-count-padding = 0.6vmin 1.6vmin
// WIDGET — body
$widget-body-border = 3px solid #fff
-$widget-body-border-radius = 0 0 2px 2px
-$widget-body-bg-color = transparent
-$widget-body-shadow = none
-
// TABLE
$table-border-h = 1px solid #253246
-
-// COUNT
-$count-padding = 0.4vmin 1.2vmin
-$count-font-size = 1.8vmin
-$count-bg-color = transparent
-$count-txt-color = $main-txt-color
-$count-border-radius = 2vmin
-$count-border = 1px solid #ddd
-
-
// LABEL
$label-bg-color = #fff
$label-txt-color = $main-txt-color
@@ -69,19 +47,16 @@ $label-addon-txt-color = #000
$label-border-radius = 2px
$label-border = 1px solid #ddd
-
// Meaningful color
$unknown-color = #d3dfe8
$success-color = #8ddb8d
$warning-color = #d1be65
$failure-color = #e37856
-
// CHART
$histogram-bar-bg-color = #fafafa
$chart-axis-txt-color = #999
-
// PROPS
$prop-key-txt-color = $main-txt-color
$prop-value-txt-color = lighten($main-txt-color, 10)
diff --git a/src/themes/yellow/_vars.styl b/src/themes/yellow/_vars.styl
index 56ce1173..92c59cf5 100644
--- a/src/themes/yellow/_vars.styl
+++ b/src/themes/yellow/_vars.styl
@@ -21,29 +21,23 @@ $widget-header-bg-color = #e6d280
$widget-header-txt-color = #735e39
$widget-header-icon-color = #ce6c51
$widget-header-font = normal normal 700 2.5vmin "Montserrat", sans-serif
-$widget-header-border-radius = 2px 2px 0 0
// WIDGET — header count
$widget-header-count-bg-color = #e5dabe
$widget-header-count-txt-color = #ff9176
// WIDGET — body
-$widget-body-shadow = none
-$widget-body-border-radius = 0 0 2px 2px
$widget-body-bg-color = transparent
-
// TABLE
$table-border-h = 1px solid #e6d280
-
// Meaningful colors
$unknown-color = #c0ab7f
$success-color = #4eb6a3
$warning-color = #d1be65
$failure-color = #ff9176
-
// COUNT
$count-padding = 3px 6px
$count-bg-color = darken($widget-bg-color, 3)
@@ -51,7 +45,6 @@ $count-txt-color = $widget-header-count-txt-color
$count-border-radius = 3px
$count-border = none
-
// LABEL
$label-bg-color = #f7ecd0
$label-txt-color = $main-txt-color
@@ -59,14 +52,10 @@ $label-addon-bg-color = #e6d280
$label-addon-txt-color = $main-txt-color
$label-border-radius = 2px
-
// CHARTS
$histogram-bar-bg-color = #dcd1b5
$chart-axis-txt-color = #806b3f
-
-
-
// PROPS
$prop-key-txt-color = $main-txt-color
-$prop-value-txt-color = darken($widget-header-icon-color, 10)
\ No newline at end of file
+$prop-value-txt-color = darken($widget-header-icon-color, 10)
diff --git a/test/frontend/stores/ApiStore.test.js b/test/frontend/stores/ApiStore.test.js
new file mode 100644
index 00000000..6ac55c9e
--- /dev/null
+++ b/test/frontend/stores/ApiStore.test.js
@@ -0,0 +1,164 @@
+/* global describe it */
+import _ from 'lodash';
+import expect from 'expect';
+import sinon from 'sinon';
+import { expectTriggers } from '../../helpers/storeHelper';
+import { getFakeTimerCount } from '../../helpers/timersHelper';
+
+
+let clock;
+let triggerSpy;
+let ApiStore;
+let wsStub;
+let wsStubInstance;
+
+
+describe('Mozaïk | ApiStore', () => {
+ beforeEach(() => {
+ clock = sinon.useFakeTimers();
+
+ ApiStore = require('../../../src/browser/stores/ApiStore').default;
+
+ triggerSpy = sinon.spy();
+ ApiStore.trigger = triggerSpy;
+
+ global.window = {
+ document: {
+ location: {
+ port: '',
+ hostname: 'test.com'
+ }
+ }
+ };
+
+ wsStub = sinon.stub();
+ wsStubInstance = {
+ close() {},
+ send: sinon.spy()
+ };
+ wsStub.returns(wsStubInstance);
+ global.WebSocket = wsStub;
+
+ ApiStore.reset();
+ });
+
+ afterEach(() => {
+ clock.restore();
+ delete global.window;
+ delete global.WebSocket;
+ });
+
+ describe('initWs()', () => {
+ it('should create a new ws connection', () => {
+ ApiStore.initWs({});
+
+ expect(wsStub.calledOnce).toEqual(true);
+ expect(wsStub.getCall(0).args[0]).toEqual('ws://test.com');
+ });
+
+ it(`should create a new wss connection if 'useWssConnection' is true`, () => {
+ ApiStore.initWs({ useWssConnection: true });
+
+ expect(wsStub.calledOnce).toEqual(true);
+ expect(wsStub.getCall(0).args[0]).toEqual('wss://test.com');
+ });
+
+ it(`should create a new ws on custom port if 'wsPort' defined`, () => {
+ ApiStore.initWs({ wsPort: 2000 });
+
+ expect(wsStub.calledOnce).toEqual(true);
+ expect(wsStub.getCall(0).args[0]).toEqual('ws://test.com:2000');
+ });
+
+ it (`should not create a new ws if there's already one created`, () => {
+ ApiStore.initWs({});
+ ApiStore.initWs({});
+
+ expect(wsStub.calledOnce).toEqual(true);
+ });
+ });
+
+ describe('on ws message', () => {
+ it('should trigger received data', () => {
+ ApiStore.initWs({});
+
+ const data = { foo: 'bar' };
+
+ wsStubInstance.onmessage({ data: JSON.stringify(data) });
+
+ expectTriggers(triggerSpy, [data]);
+ });
+
+ it('should not trigger if data is an empty string', () => {
+ ApiStore.initWs({});
+
+ wsStubInstance.onmessage({ data: '' });
+
+ expect(triggerSpy.called).toEqual(false);
+ });
+ });
+
+ describe('fetch', () => {
+ it('should send request', () => {
+ ApiStore.initWs({});
+ ApiStore.fetch('foo');
+
+ expect(wsStubInstance.send.calledOnce).toEqual(true);
+ expect(wsStubInstance.send.getCall(0).args[0]).toEqual(JSON.stringify({
+ id: 'foo',
+ params: {}
+ }));
+ });
+
+ it('should add request to history', () => {
+ ApiStore.initWs({});
+ ApiStore.fetch('foo');
+
+ expect(ApiStore.getHistory()).toEqual([{
+ id: 'foo',
+ params: {}
+ }]);
+ });
+
+ it('should add request to buffer if ws is null', () => {
+ ApiStore.fetch('foo');
+
+ expect(ApiStore.getBuffer()).toEqual([{
+ id: 'foo',
+ params: {}
+ }]);
+ });
+
+ it('should add request to buffer if ws is not ready', () => {
+ ApiStore.initWs({});
+ wsStubInstance.readyState = 'not_ready';
+ ApiStore.fetch('foo');
+
+ expect(ApiStore.getBuffer()).toEqual([{
+ id: 'foo',
+ params: {}
+ }]);
+ });
+
+ it('should not add request to buffer if ws not null and ready', () => {
+ ApiStore.initWs({});
+ ApiStore.fetch('foo');
+
+ expect(ApiStore.getBuffer()).toEqual([]);
+ });
+ });
+
+ describe('on ws close', () => {
+ it('should try to reconnect 10 times each 15 seconds', () => {
+ ApiStore.initWs({});
+
+ for (let i = 0; i < 12; i++) {
+ clock.tick(15000);
+ wsStubInstance.onclose();
+ }
+
+ // 12th call ignored
+ expect(wsStub.callCount).toEqual(11);
+ });
+ });
+});
diff --git a/test/frontend/stores/ConnectionStatusStore.test.js b/test/frontend/stores/ConnectionStatusStore.test.js
new file mode 100644
index 00000000..8d8e2a3a
--- /dev/null
+++ b/test/frontend/stores/ConnectionStatusStore.test.js
@@ -0,0 +1,152 @@
+/* global describe it */
+import _ from 'lodash';
+import expect from 'expect';
+import sinon from 'sinon';
+import { expectTriggers } from '../../helpers/storeHelper';
+import { getFakeTimerCount } from '../../helpers/timersHelper';
+
+
+let clock;
+let triggerSpy;
+let ConnectionStatusStore;
+
+
+describe('Mozaïk | ConnectionStatusStore', () => {
+ beforeEach(() => {
+ clock = sinon.useFakeTimers();
+ ConnectionStatusStore = require('../../../src/browser/stores/ConnectionStatusStore').default;
+ triggerSpy = sinon.spy();
+ ConnectionStatusStore.trigger = triggerSpy;
+ ConnectionStatusStore.reset();
+ });
+
+ afterEach(() => {
+ clock.restore();
+ });
+
+ describe('setStatus()', () => {
+ it('should trigger with given status', () => {
+ ConnectionStatusStore.setStatus('foo');
+
+ expectTriggers(triggerSpy, [
+ ({ status }) => {
+ expect(status).toEqual('foo');
+ }
+ ]);
+ });
+
+ it('should clear existing countdown', () => {
+ ConnectionStatusStore.delaying(0, 5);
+
+ expect(getFakeTimerCount(clock)).toEqual(1);
+
+ ConnectionStatusStore.setStatus('bar');
+
+ expect(getFakeTimerCount(clock)).toEqual(0);
+ });
+ });
+
+ describe('connecting()', () => {
+ it(`should trigger with a 'connecting' status`, () => {
+ ConnectionStatusStore.connecting();
+
+ expectTriggers(triggerSpy, [
+ ({ status }) => {
+ expect(status).toEqual('connecting');
+ }
+ ]);
+ });
+ });
+
+ describe('connected()', () => {
+ it(`should trigger with a 'connected' status`, () => {
+ ConnectionStatusStore.connected();
+
+ expectTriggers(triggerSpy, [
+ ({ status }) => {
+ expect(status).toEqual('connected');
+ }
+ ]);
+ });
+ });
+
+ describe('disconnected()', () => {
+ it(`should trigger with a 'disconnected' status`, () => {
+ ConnectionStatusStore.disconnected();
+
+ expectTriggers(triggerSpy, [
+ ({ status }) => {
+ expect(status).toEqual('disconnected');
+ }
+ ]);
+ });
+ });
+
+ describe('delaying()', () => {
+ it(`should trigger with a 'delaying' status`, () => {
+ ConnectionStatusStore.delaying();
+
+ expectTriggers(triggerSpy, [
+ ({ status }) => {
+ expect(status).toEqual('delaying');
+ }
+ ]);
+ });
+
+ it(`should trigger with a 'retry' and 'countdown'`, () => {
+ ConnectionStatusStore.delaying(2, 3);
+
+ expectTriggers(triggerSpy, [
+ ({ retry, countdown }) => {
+ expect(retry).toEqual(2);
+ expect(countdown).toEqual(3);
+ }
+ ]);
+ });
+
+ it(`should create a 'countdown' interval`, () => {
+ ConnectionStatusStore.delaying(2, 3);
+
+ expectTriggers(triggerSpy, ['skip']);
+ expect(getFakeTimerCount(clock)).toEqual(1);
+
+ clock.tick(1000);
+
+ expectTriggers(triggerSpy, [
+ 'skip',
+ ({ countdown }) => {
+ expect(countdown).toEqual(2);
+ }
+ ]);
+ });
+
+ it(`should not create a 'countdown' interval if delay is 0`, () => {
+ ConnectionStatusStore.delaying(2, 0);
+
+ expectTriggers(triggerSpy, ['skip']);
+ expect(getFakeTimerCount(clock)).toEqual(0);
+ });
+ });
+
+ describe('failed()', () => {
+ it(`should trigger with a 'failed' status`, () => {
+ ConnectionStatusStore.failed();
+
+ expectTriggers(triggerSpy, [
+ ({ status }) => {
+ expect(status).toEqual('failed');
+ }
+ ]);
+ });
+
+ it('should trigger with the number of connection attempts', () => {
+ ConnectionStatusStore.failed(3);
+
+ expectTriggers(triggerSpy, [
+ ({ retry }) => {
+ expect(retry).toEqual(3);
+ }
+ ]);
+ });
+ });
+});
\ 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..8fbb02aa
--- /dev/null
+++ b/test/frontend/stores/NotificationStore.test.js
@@ -0,0 +1,138 @@
+/* global describe it */
+import _ from 'lodash';
+import expect from 'expect';
+import sinon from 'sinon';
+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);
+};