Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Wiring the views with the server. r=samgiles #14

Merged
merged 1 commit into from
Jul 4, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/css/views/microphone.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.microphone {
position: absolute;
position: fixed;
z-index: 1;
top: 8rem;
right: 9rem;
Expand Down
2 changes: 1 addition & 1 deletion app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self' ; connect-src * ; object-src 'none' ; img-src 'self' ; referrer no-referrer ;">
<meta http-equiv="Content-Security-Policy" content="default-src 'self' ; connect-src 'self' https://calendar.knilxof.org ; object-src 'none' ; img-src 'self' ; referrer no-referrer ;">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<meta name="theme-color" content="#0ca8a4">
<link rel="stylesheet" href="css/app.css">
Expand Down
21 changes: 16 additions & 5 deletions app/js/controllers/main.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import BaseController from './base';
import UsersController from './users';
import RemindersController from './reminders';

import SpeechController from '../lib/speech-controller';
import Server from '../lib/server/index';

import React from 'components/react';
import ReactDOM from 'components/react-dom';
import Microphone from '../views/microphone';

const p = Object.freeze({
controllers: Symbol('controllers'),
onHashChanged: Symbol('onHashChanged'),
speechController: Symbol('speechController'),
server: Symbol('server'),

onHashChanged: Symbol('onHashChanged'),
});

export default class MainController extends BaseController {
Expand All @@ -19,7 +23,8 @@ export default class MainController extends BaseController {

const mountNode = document.querySelector('.app-view-container');
const speechController = new SpeechController();
const options = { mountNode, speechController };
const server = new Server();
const options = { mountNode, speechController, server };

const usersController = new UsersController(options);
const remindersController = new RemindersController(options);
Expand All @@ -31,6 +36,7 @@ export default class MainController extends BaseController {
};

this[p.speechController] = speechController;
this[p.server] = server;

window.addEventListener('hashchange', this[p.onHashChanged].bind(this));
}
Expand All @@ -49,14 +55,19 @@ export default class MainController extends BaseController {
});

location.hash = '';

setTimeout(() => {
//location.hash = 'users/login';
location.hash = 'reminders';
}, 16);
if (this[p.server].isLoggedIn) {
location.hash = 'reminders';
} else {
location.hash = 'users/login';
}
});

ReactDOM.render(
React.createElement(Microphone, {
speechController: this[p.speechController],
server: this[p.server],
}), document.querySelector('.microphone')
);
}
Expand Down
1 change: 1 addition & 0 deletions app/js/controllers/reminders.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default class RemindersController extends BaseController {
ReactDOM.render(
React.createElement(Reminders, {
speechController: this.speechController,
server: this.server,
}), this.mountNode
);
}
Expand Down
9 changes: 6 additions & 3 deletions app/js/controllers/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,15 @@ export default class UsersController extends BaseController {

login() {
ReactDOM.render(
React.createElement(UserLogin, {}), this.mountNode
React.createElement(UserLogin, { server: this.server }), this.mountNode
);
}

logout() {
// Once logged out, we redirect to the login page.
location.hash = '#users/login';
this.server.logout()
.then(() => {
// Once logged out, we redirect to the login page.
location.hash = 'users/login';
});
}
}
159 changes: 159 additions & 0 deletions app/js/lib/server/api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
'use strict';

const p = Object.freeze({
settings: Symbol('settings'),
net: Symbol('net'),

// Private methods.
getURL: Symbol('getURL'),
onceOnline: Symbol('onceOnline'),
onceReady: Symbol('onceReady'),
getChannelValues: Symbol('getChannelValues'),
updateChannelValue: Symbol('updateChannelValue'),
});

/**
* Instance of the API class is intended to abstract consumer from the API
* specific details (e.g. API base URL and version). It also tracks
* availability of the network, API host and whether correct user session is
* established. If any of this conditions is not met all API requests are
* blocked until it's possible to perform them, so consumer doesn't have to
* care about these additional checks.
*/
export default class API {
constructor(net, settings) {
this[p.net] = net;
this[p.settings] = settings;

Object.freeze(this);
}

/**
* Performs HTTP 'GET' API request and accepts JSON as response.
*
* @param {string} path Specific API resource path to be used in conjunction
* with the base API path.
* @return {Promise}
*/
get(path) {
return this[p.onceReady]()
.then(() => this[p.net].fetchJSON(this[p.getURL](path)));
}

/**
* Performs HTTP 'POST' API request and accepts JSON as response.
*
* @param {string} path Specific API resource path to be used in conjunction
* with the base API path.
* @param {Object=} body Optional object that will be serialized to JSON
* string and sent as 'POST' body.
* @return {Promise}
*/
post(path, body) {
console.log(path, body);

return this[p.onceReady]()
.then(() => this[p.net].fetchJSON(this[p.getURL](path), 'POST', body));
}

/**
* Performs HTTP 'PUT' API request and accepts JSON as response.
*
* @param {string} path Specific API resource path to be used in conjunction
* with the base API path.
* @param {Object=} body Optional object that will be serialized to JSON
* string and sent as 'PUT' body.
* @return {Promise}
*/
put(path, body) {
return this[p.onceReady]()
.then(() => this[p.net].fetchJSON(this[p.getURL](path), 'PUT', body));
}

/**
* Performs HTTP 'DELETE' API request and accepts JSON as response.
*
* @param {string} path Specific API resource path to be used in conjunction
* with the base API path.
* @param {Object=} body Optional object that will be serialized to JSON
* string and sent as 'DELETE' body.
* @return {Promise}
*/
delete(path, body) {
return this[p.onceReady]()
.then(() => this[p.net].fetchJSON(this[p.getURL](path), 'DELETE', body));
}

/**
* Performs either HTTP 'GET' or 'PUT' (if body parameter is specified) API
* request and accepts Blob as response.
*
* @param {string} path Specific API resource path to be used in conjunction
* with the base API path.
* @param {Object=} body Optional object that will be serialized to JSON
* string and sent as 'PUT' body.
* @param {string=} accept Mime type of the Blob we expect as a response
* (default is image/jpeg).
* @return {Promise}
*/
blob(path, body, accept = 'image/jpeg') {
return this[p.onceReady]()
.then(() => {
if (body) {
return this[p.net].fetchBlob(
this[p.getURL](path), accept, 'PUT', body
);
}

return this[p.net].fetchBlob(this[p.getURL](path), accept);
});
}

/**
* Creates a fully qualified API URL based on predefined base origin, API
* version and specified resource path.
*
* @param {string} path Specific API resource path to be used in conjunction
* with the base API path and version.
* @return {string}
* @private
*/
[p.getURL](path) {
if (!path || typeof path !== 'string') {
throw new Error('Path should be a valid non-empty string.');
}

return `${this[p.net].origin}/api/v${this[p.settings].apiVersion}/${path}`;
}

/**
* Returns a promise that is resolved once API is ready to use (API host is
* online).
* In the future we can add more checks like:
* * User is authenticated
* * Document is visible
*
* @returns {Promise}
* @private
*/
[p.onceReady]() {
return Promise.all([
this[p.onceOnline](),
]);
}

/**
* Returns a promise that is resolved once API host is discovered and online.
*
* @returns {Promise}
* @private
*/
[p.onceOnline]() {
const net = this[p.net];
if (net.online) {
return Promise.resolve();
}

return new Promise((resolve) => net.once('online', () => resolve()));
}
}
105 changes: 105 additions & 0 deletions app/js/lib/server/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/* global URLSearchParams */

'use strict';

import EventDispatcher from '../common/event-dispatcher';

import Settings from './settings';
import Network from './network';
import WebPush from './webpush';
import API from './api';
import Reminders from './reminders';

// Private members.
const p = Object.freeze({
// Private properties.
settings: Symbol('settings'),
net: Symbol('net'),
webPush: Symbol('webPush'),
api: Symbol('api'),
});

export default class Server extends EventDispatcher {
constructor({ settings, net } = {}) {
Copy link
Contributor

@samgiles samgiles Jul 4, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this pattern intended? settings and net will be undefined by default. On reflection it kind of makes sense :)

Ooh, TIL: in ES6 you can do this:

constructor({settings, net} = { settings: new Settings(), })

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is mainly used for testing purposes to inject mocks into the constructor.

super(['online']);

// Private properties.
this[p.settings] = settings || new Settings();
this[p.net] = net || new Network(this[p.settings]);
this[p.api] = new API(this[p.net], this[p.settings]);
this[p.webPush] = new WebPush(this[p.api], this[p.settings]);

// Init
this.reminders = new Reminders(this[p.api], this[p.settings]);

this[p.net].on('online', (online) => this.emit('online', online));
this[p.webPush].on('message', (msg) => this.emit('push-message', msg));

window.server = this;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add things to window using the global symbol registry: Symbol.for('cueServer')?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is for hand debugging too. It should be stripped out on production builds.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can still use Symbol.for in debugging mode - it's just a bit more cumbersome I guess (and there probably isn't any danger of a browser implementing a server property on window).

We don't currently differentiate between production/dev builds yet do we?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do, we replace some components code by their minified counterpart in production (React in particular does many unnecessary checks useful for dev but not in prod) and the code is not minified in dev. See in gulpfile.js the build-dev and build-production tasks.


Object.seal(this);
}

/**
* Clear all data/settings stored on the browser. Use with caution.
*
* @param {boolean} ignoreServiceWorker
* @return {Promise}
*/
clear(ignoreServiceWorker = true) {
const promises = [this[p.settings].clear()];

if (!navigator.serviceWorker && !ignoreServiceWorker) {
promises.push(navigator.serviceWorker.ready
.then((registration) => registration.unregister()));
}

return Promise.all(promises);
}

get online() {
return this[p.net].online;
}

get isLoggedIn() {
return !!this[p.settings].session;
}

/**
* Authenticate a user.
*
* @param {string} user
* @param {string} password
* @return {Promise}
*/
login(user, password) {
return this[p.api].post('login', { user, password })
.then((res) => {
this[p.settings].session = res.token;
});
}

/**
* Log out the user.
*
* @return {Promise}
*/
logout() {
this[p.settings].session = null;
return Promise.resolve();
}

/**
* Ask the user to accept push notifications from the server.
* This method will be called each time that we log in, but will stop the
* execution if we already have the push subscription information.
*
* @param {boolean} resubscribe Parameter used for testing purposes, and
* follow the whole subscription process even if we already have a push
* subscription information.
* @return {Promise}
*/
subscribeToNotifications(resubscribe = false) {
return this[p.webPush].subscribeToNotifications(resubscribe);
}
}
Loading