client/views: replace inheritance with composition

This commit is contained in:
rr- 2016-04-09 18:54:23 +02:00
parent 4821f2ac6c
commit 2e1823b708
15 changed files with 214 additions and 198 deletions

View file

@ -69,3 +69,6 @@
#user-delete form {
width: 100%;
}
#user-delete form label {
padding: 0;
}

View file

@ -5,6 +5,7 @@ const api = require('../api.js');
const config = require('../config.js');
const events = require('../events.js');
const misc = require('../util/misc.js');
const views = require('../util/views.js');
const topNavController = require('../controllers/top_nav_controller.js');
const RegistrationView = require('../views/registration_view.js');
const UserView = require('../views/user_view.js');
@ -58,7 +59,7 @@ class UsersController {
this.user = response.user;
next();
}).catch(response => {
this.userView.emptyView(this.userView.contentHolder);
views.emptyView(document.getElementById('content-holder'));
events.notify(events.Error, response.description);
});
}

View file

@ -2,6 +2,10 @@
let listeners = [];
function unlisten(messageClass) {
listeners[messageClass] = [];
}
function listen(messageClass, handler) {
if (!(messageClass in listeners)) {
listeners[messageClass] = [];
@ -25,4 +29,5 @@ module.exports = {
notify: notify,
listen: listen,
unlisten: unlisten,
};

125
client/js/util/views.js Normal file
View file

@ -0,0 +1,125 @@
'use strict';
require('../util/polyfill.js');
const handlebars = require('handlebars');
const events = require('../events.js');
const domParser = new DOMParser();
function _messageHandler(target, message, className) {
if (!message) {
message = 'Unknown message';
}
const messagesHolder = target.querySelector('.messages');
if (!messagesHolder) {
alert(message);
return;
}
/* TODO: animate this */
const node = document.createElement('div');
node.innerHTML = message.replace(/\n/g, '<br/>');
node.classList.add('message');
node.classList.add(className);
messagesHolder.appendChild(node);
}
function listenToMessages(target) {
events.unlisten(events.Success);
events.unlisten(events.Error);
events.listen(
events.Success, msg => { _messageHandler(target, msg, 'success'); });
events.listen(
events.Error, msg => { _messageHandler(target, msg, 'error'); });
}
function clearMessages(target) {
const messagesHolder = target.querySelector('.messages');
/* TODO: animate that */
while (messagesHolder.lastChild) {
messagesHolder.removeChild(messagesHolder.lastChild);
}
}
function htmlToDom(html) {
const parsed = domParser.parseFromString(html, 'text/html').body;
return parsed.childNodes.length > 1 ?
parsed.childNodes :
parsed.firstChild;
}
function getTemplate(templatePath) {
const templateElement = document.getElementById(templatePath + '-template');
if (!templateElement) {
console.error('Missing template: ' + templatePath);
return null;
}
const templateText = templateElement.innerHTML.trim();
const templateFactory = handlebars.compile(templateText);
return (...args) => {
return htmlToDom(templateFactory(...args));
};
}
function decorateValidator(form) {
// postpone showing form fields validity until user actually tries
// to submit it (seeing red/green form w/o doing anything breaks POLA)
const submitButton = form.querySelector('.buttons input');
submitButton.addEventListener('click', e => {
form.classList.add('show-validation');
});
form.addEventListener('submit', e => {
form.classList.remove('show-validation');
});
}
function disableForm(form) {
for (let input of form.querySelectorAll('input')) {
input.disabled = true;
}
}
function enableForm(form) {
for (let input of form.querySelectorAll('input')) {
input.disabled = false;
}
}
function emptyView(target) {
const ret = showView(target, htmlToDom('<div class="messages"></div>'));
listenToMessages(target);
return ret;
}
function showView(target, source) {
return new Promise((resolve, reject) => {
let observer = new MutationObserver(mutations => {
resolve();
observer.disconnect();
});
observer.observe(target, {childList: true});
while (target.lastChild) {
target.removeChild(target.lastChild);
}
if (source instanceof NodeList) {
for (let child of source) {
target.appendChild(child);
}
} else if (source instanceof Node) {
target.appendChild(source);
} else {
console.error('Invalid view source', source);
}
});
}
module.exports = {
htmlToDom: htmlToDom,
getTemplate: getTemplate,
showView: showView,
emptyView: emptyView,
enableForm: enableForm,
disableForm: disableForm,
listenToMessages: listenToMessages,
clearMessages: clearMessages,
decorateValidator: decorateValidator,
};

View file

@ -1,115 +0,0 @@
'use strict';
const handlebars = require('handlebars');
const events = require('../events.js');
const contentHolder = document.getElementById('content-holder');
require('../util/polyfill.js');
function messageHandler(message, className) {
if (!message) {
message = 'Unknown message';
}
const messagesHolder = contentHolder.querySelector('.messages');
if (!messagesHolder) {
alert(message);
return;
}
/* TODO: animate this */
const node = document.createElement('div');
node.innerHTML = message.replace(/\n/g, '<br/>');
node.classList.add('message');
node.classList.add(className);
messagesHolder.appendChild(node);
}
events.listen(events.Success, msg => { messageHandler(msg, 'success'); });
events.listen(events.Error, msg => { messageHandler(msg, 'error'); });
class BaseView {
constructor() {
this.contentHolder = contentHolder;
this.domParser = new DOMParser();
}
htmlToDom(html) {
const parsed = this.domParser.parseFromString(html, 'text/html').body;
return parsed.childNodes.length > 1 ?
parsed.childNodes :
parsed.firstChild;
}
getTemplate(templatePath) {
const templateElement = document.getElementById(templatePath);
if (!templateElement) {
console.error('Missing template: ' + templatePath);
return null;
}
const templateText = templateElement.innerHTML.trim();
const templateFactory = handlebars.compile(templateText);
return (...args) => {
return this.htmlToDom(templateFactory(...args));
};
}
clearMessages() {
const messagesHolder = this.contentHolder.querySelector('.messages');
/* TODO: animate that */
while (messagesHolder.lastChild) {
messagesHolder.removeChild(messagesHolder.lastChild);
}
}
decorateValidator(form) {
// postpone showing form fields validity until user actually tries
// to submit it (seeing red/green form w/o doing anything breaks POLA)
const submitButton = form.querySelector('.buttons input');
submitButton.addEventListener('click', e => {
form.classList.add('show-validation');
});
form.addEventListener('submit', e => {
form.classList.remove('show-validation');
});
}
disableForm(form) {
for (let input of form.querySelectorAll('input')) {
input.disabled = true;
}
}
enableForm(form) {
for (let input of form.querySelectorAll('input')) {
input.disabled = false;
}
}
emptyView(target) {
return this.showView(
target,
this.htmlToDom('<div class="messages"></div>'));
}
showView(target, source) {
return new Promise((resolve, reject) => {
let observer = new MutationObserver(mutations => {
resolve();
observer.disconnect();
});
observer.observe(target, {childList: true});
while (target.lastChild) {
target.removeChild(target.lastChild);
}
if (source instanceof NodeList) {
for (let child of source) {
target.appendChild(child);
}
} else if (source instanceof Node) {
target.appendChild(source);
} else {
console.error('Invalid view source', source);
}
});
}
}
module.exports = BaseView;

View file

@ -1,31 +1,30 @@
'use strict';
const config = require('../config.js');
const BaseView = require('./base_view.js');
const views = require('../util/views.js');
class HelpView extends BaseView {
class HelpView {
constructor() {
super();
this.template = this.getTemplate('help-template');
this.template = views.getTemplate('help');
this.sectionTemplates = {};
const sectionKeys = ['about', 'keyboard', 'search', 'comments', 'tos'];
for (let section of sectionKeys) {
const templateName = 'help-' + section + '-template';
this.sectionTemplates[section] = this.getTemplate(templateName);
const templateName = 'help-' + section;
this.sectionTemplates[section] = views.getTemplate(templateName);
}
}
render(ctx) {
const target = this.contentHolder;
const target = document.getElementById('content-holder');
const source = this.template();
ctx.section = ctx.section || 'about';
if (!(ctx.section in this.sectionTemplates)) {
this.emptyView(this.contentHolder);
views.emptyView(target);
return;
}
this.showView(
views.showView(
source.querySelector('.content'),
this.sectionTemplates[ctx.section]({
name: config.name,
@ -40,7 +39,8 @@ class HelpView extends BaseView {
}
}
this.showView(target, source);
views.listenToMessages(target);
views.showView(target, source);
}
}

View file

@ -1,22 +1,23 @@
'use strict';
const config = require('../config.js');
const BaseView = require('./base_view.js');
const views = require('../util/views.js');
class HomeView extends BaseView {
class HomeView {
constructor() {
super();
this.template = this.getTemplate('home-template');
this.template = views.getTemplate('home');
}
render(ctx) {
const target = this.contentHolder;
const target = document.getElementById('content-holder');
const source = this.template({
name: config.name,
version: config.meta.version,
buildDate: config.meta.buildDate,
});
this.showView(target, source);
views.listenToMessages(target);
views.showView(target, source);
}
}

View file

@ -1,17 +1,15 @@
'use strict';
const config = require('../config.js');
const events = require('../events.js');
const BaseView = require('./base_view.js');
const views = require('../util/views.js');
class LoginView extends BaseView {
class LoginView {
constructor() {
super();
this.template = this.getTemplate('login-template');
this.template = views.getTemplate('login');
}
render(ctx) {
const target = this.contentHolder;
const target = document.getElementById('content-holder');
const source = this.template({canSendMails: config.canSendMails});
const form = source.querySelector('form');
@ -19,22 +17,23 @@ class LoginView extends BaseView {
const passwordField = source.querySelector('#user-password');
const rememberUserField = source.querySelector('#remember-user');
this.decorateValidator(form);
views.decorateValidator(form);
userNameField.setAttribute('pattern', config.userNameRegex);
passwordField.setAttribute('pattern', config.passwordRegex);
form.addEventListener('submit', e => {
e.preventDefault();
this.clearMessages();
this.disableForm(form);
views.clearMessages(target);
views.disableForm(form);
ctx.login(
userNameField.value,
passwordField.value,
rememberUserField.checked)
.always(() => { this.enableForm(form); });
.always(() => { views.enableForm(form); });
});
this.showView(target, source);
views.listenToMessages(target);
views.showView(target, source);
}
}

View file

@ -1,32 +1,31 @@
'use strict';
const events = require('../events.js');
const BaseView = require('./base_view.js');
const views = require('../util/views.js');
class PasswordResetView extends BaseView {
class PasswordResetView {
constructor() {
super();
this.template = this.getTemplate('password-reset-template');
this.template = views.getTemplate('password-reset');
}
render(ctx) {
const target = this.contentHolder;
const target = document.getElementById('content-holder');
const source = this.template();
const form = source.querySelector('form');
const userNameOrEmailField = source.querySelector('#user-name');
this.decorateValidator(form);
views.decorateValidator(form);
form.addEventListener('submit', e => {
e.preventDefault();
this.clearMessages();
this.disableForm(form);
views.clearMessages(target);
views.disableForm(form);
ctx.proceed(userNameOrEmailField.value)
.catch(() => { this.enableForm(form); });
.catch(() => { views.enableForm(form); });
});
this.showView(target, source);
views.listenToMessages(target);
views.showView(target, source);
}
}

View file

@ -1,17 +1,15 @@
'use strict';
const config = require('../config.js');
const events = require('../events.js');
const BaseView = require('./base_view.js');
const views = require('../util/views.js');
class RegistrationView extends BaseView {
class RegistrationView {
constructor() {
super();
this.template = this.getTemplate('user-registration-template');
this.template = views.getTemplate('user-registration');
}
render(ctx) {
const target = this.contentHolder;
const target = document.getElementById('content-holder');
const source = this.template();
const form = source.querySelector('form');
@ -19,22 +17,23 @@ class RegistrationView extends BaseView {
const passwordField = source.querySelector('#user-password');
const emailField = source.querySelector('#user-email');
this.decorateValidator(form);
views.decorateValidator(form);
userNameField.setAttribute('pattern', config.userNameRegex);
passwordField.setAttribute('pattern', config.passwordRegex);
form.addEventListener('submit', e => {
e.preventDefault();
this.clearMessages();
this.disableForm(form);
views.clearMessages(target);
views.disableForm(form);
ctx.register(
userNameField.value,
passwordField.value,
emailField.value)
.always(() => { this.enableForm(form); });
.always(() => { views.enableForm(form); });
});
this.showView(target, source);
views.listenToMessages(target);
views.showView(target, source);
}
}

View file

@ -1,11 +1,10 @@
'use strict';
const BaseView = require('./base_view.js');
const views = require('../util/views.js');
class TopNavView extends BaseView {
class TopNavView {
constructor() {
super();
this.template = this.getTemplate('top-nav-template');
this.template = views.getTemplate('top-nav');
this.navHolder = document.getElementById('top-nav-holder');
}
@ -21,7 +20,7 @@ class TopNavView extends BaseView {
'<span class="access-key" data-accesskey="$1">$1</span>');
}
this.showView(this.navHolder, source);
views.showView(this.navHolder, source);
}
activate(itemName) {

View file

@ -1,11 +1,10 @@
'use strict';
const BaseView = require('./base_view.js');
const views = require('../util/views.js');
class UserDeletionView extends BaseView {
class UserDeletionView {
constructor() {
super();
this.template = this.getTemplate('user-deletion-template');
this.template = views.getTemplate('user-deletion');
}
render(ctx) {
@ -14,16 +13,17 @@ class UserDeletionView extends BaseView {
const form = source.querySelector('form');
this.decorateValidator(form);
views.decorateValidator(form);
form.addEventListener('submit', e => {
e.preventDefault();
this.clearMessages();
this.disableForm(form);
views.clearMessages(target);
views.disableForm(form);
ctx.delete();
});
this.showView(target, source);
views.listenToMessages(target);
views.showView(target, source);
}
}

View file

@ -1,12 +1,11 @@
'use strict';
const config = require('../config.js');
const BaseView = require('./base_view.js');
const views = require('../util/views.js');
class UserEditView extends BaseView {
class UserEditView {
constructor() {
super();
this.template = this.getTemplate('user-edit-template');
this.template = views.getTemplate('user-edit');
}
render(ctx) {
@ -19,7 +18,7 @@ class UserEditView extends BaseView {
const userNameField = source.querySelector('#user-name');
const passwordField = source.querySelector('#user-password');
this.decorateValidator(form);
views.decorateValidator(form);
if (userNameField) {
userNameField.setAttribute(
@ -41,17 +40,18 @@ class UserEditView extends BaseView {
form.addEventListener('submit', e => {
e.preventDefault();
this.clearMessages();
this.disableForm(form);
views.clearMessages(target);
views.disableForm(form);
ctx.edit(
userNameField.value,
passwordField.value,
emailField.value,
rankField.value)
.always(() => { this.enableForm(form); });
.always(() => { views.enableForm(form); });
});
this.showView(target, source);
views.listenToMessages(target);
views.showView(target, source);
}
}

View file

@ -1,17 +1,17 @@
'use strict';
const BaseView = require('./base_view.js');
const views = require('../util/views.js');
class UserSummaryView extends BaseView {
class UserSummaryView {
constructor() {
super();
this.template = this.getTemplate('user-summary-template');
this.template = views.getTemplate('user-summary');
}
render(ctx) {
const target = ctx.target;
const source = this.template(ctx);
this.showView(target, source);
views.listenToMessages(target);
views.showView(target, source);
}
}

View file

@ -1,21 +1,20 @@
'use strict';
const BaseView = require('./base_view.js');
const views = require('../util/views.js');
const UserDeletionView = require('./user_deletion_view.js');
const UserSummaryView = require('./user_summary_view.js');
const UserEditView = require('./user_edit_view.js');
class UserView extends BaseView {
class UserView {
constructor() {
super();
this.template = this.getTemplate('user-template');
this.template = views.getTemplate('user');
this.deletionView = new UserDeletionView();
this.summaryView = new UserSummaryView();
this.editView = new UserEditView();
}
render(ctx) {
const target = this.contentHolder;
const target = document.getElementById('content-holder');
const source = this.template(ctx);
ctx.section = ctx.section || 'summary';
@ -39,7 +38,8 @@ class UserView extends BaseView {
ctx.target = source.querySelector('#user-content-holder');
view.render(ctx);
this.showView(target, source);
views.listenToMessages(target);
views.showView(target, source);
}
}