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