client/events: improve event dispatching

This commit introduces timer-less retry system:

1. Any change to URL is going to stop listening to any messages.
2. If a message is sent and there's no handler that could pick it up,
   the message gets enqueued.
3. The message is sent again to the first handler that attaches itself
   to given event type.

While in theory this is full of holes (no control over the first
handler), in practice, it works quite well.

Additionally, views.listenToMessages was attaching to completely wrong
DOM node; this commit fixes this as well.
This commit is contained in:
rr- 2016-05-11 21:29:57 +02:00
parent 09bc5f10f9
commit 2a4241641c
23 changed files with 133 additions and 100 deletions

View file

@ -1,5 +1,5 @@
<div class='pager'>
<div class='page-header-holder'></div>
<div class='pages-holder'></div>
<div class='messages'></div>
<div class='pages-holder'></div>
</div>

View file

@ -9,6 +9,7 @@ class PageController {
constructor() {
events.listen(events.SettingsChange, () => {
this.update();
return true;
});
this.update();
}

View file

@ -40,7 +40,7 @@ class TagsController {
Promise.all(promises).then(
() => {
events.notify(events.TagsChange);
events.notify(events.Success, 'Changes saved successfully');
events.notify(events.Success, 'Changes saved.');
},
response => {
events.notify(events.Error, response.description);

View file

@ -43,7 +43,9 @@ class TopNavController {
this.topNavView.activate(this.activeItem);
};
events.listen(events.Authentication, rerender);
events.listen(
events.Authentication,
() => { rerender(); return true; });
rerender();
}

View file

@ -195,25 +195,23 @@ class UsersController {
_delete(user) {
const isLoggedIn = api.isLoggedIn(user);
return new Promise((resolve, reject) => {
api.delete('/user/' + user.name)
.then(response => {
if (isLoggedIn) {
api.forget();
api.logout();
}
resolve();
if (api.hasPrivilege('users:list')) {
page('/users');
} else {
page('/');
}
events.notify(events.Success, 'Account deleted');
}, response => {
reject();
events.notify(events.Error, response.description);
});
});
return api.delete('/user/' + user.name)
.then(response => {
if (isLoggedIn) {
api.forget();
api.logout();
}
if (api.hasPrivilege('users:list')) {
page('/users');
} else {
page('/');
}
events.notify(events.Success, 'Account deleted.');
return Promise.resolve();
}, response => {
events.notify(events.Error, response.description);
return Promise.reject();
});
}
_show(user, section) {

View file

@ -1,34 +1,48 @@
'use strict';
let listeners = [];
let pendingMessages = new Map();
let listeners = new Map();
function unlisten(messageClass) {
listeners[messageClass] = [];
listeners.set(messageClass, []);
}
function listen(messageClass, handler) {
if (!(messageClass in listeners)) {
listeners[messageClass] = [];
if (pendingMessages.has(messageClass)) {
let newPendingMessages = [];
for (let message of pendingMessages.get(messageClass)) {
if (!handler(message)) {
newPendingMessages.push(message);
}
}
pendingMessages.set(messageClass, newPendingMessages);
}
listeners[messageClass].push(handler);
if (!listeners.has(messageClass)) {
listeners.set(messageClass, []);
}
listeners.get(messageClass).push(handler);
}
function notify(messageClass, message) {
if (!(messageClass in listeners)) {
if (!listeners.has(messageClass) || !listeners.get(messageClass).length) {
if (!pendingMessages.has(messageClass)) {
pendingMessages.set(messageClass, []);
}
pendingMessages.get(messageClass).push(message);
return;
}
for (let handler of listeners[messageClass]) {
for (let handler of listeners.get(messageClass)) {
handler(message);
}
}
module.exports = {
Success: 1,
Error: 2,
Info: 3,
Authentication: 4,
SettingsChange: 5,
TagsChange: 6,
Success: 'success',
Error: 'error',
Info: 'info',
Authentication: 'auth',
SettingsChange: 'settings-change',
TagsChange: 'tags-change',
notify: notify,
listen: listen,

View file

@ -29,10 +29,16 @@ controllers.push(require('./controllers/home_controller.js'));
const tags = require('./tags.js');
const events = require('./events.js');
const views = require('./util/views.js');
for (let controller of controllers) {
controller.registerRoutes();
}
page.exit((ctx, next) => {
views.unlistenToMessages();
next();
});
const api = require('./api.js');
Promise.all([tags.refreshExport(), api.loginFromCookies()])
.then(() => {

View file

@ -60,7 +60,9 @@ function getExport() {
return _export || {};
}
events.listen(events.TagsChange, refreshExport);
events.listen(
events.TagsChange,
() => { refreshExport(); return true; });
module.exports = {
getExport: getExport,

View file

@ -37,31 +37,31 @@ function makeThumbnail(url) {
function makeRadio(options) {
return makeVoidElement(
'input',
{
id: options.id,
name: options.name,
value: options.value,
type: 'radio',
checked: options.selectedValue === options.value,
required: options.required,
}) +
_makeLabel(options, {class: 'radio'});
'input',
{
id: options.id,
name: options.name,
value: options.value,
type: 'radio',
checked: options.selectedValue === options.value,
required: options.required,
}) +
_makeLabel(options, {class: 'radio'});
}
function makeCheckbox(options) {
return makeVoidElement(
'input',
{
id: options.id,
name: options.name,
value: options.value,
type: 'checkbox',
checked: options.checked !== undefined ?
options.checked : false,
required: options.required,
}) +
_makeLabel(options, {class: 'checkbox'});
'input',
{
id: options.id,
name: options.name,
value: options.value,
type: 'checkbox',
checked: options.checked !== undefined ?
options.checked : false,
required: options.required,
}) +
_makeLabel(options, {class: 'checkbox'});
}
function makeSelect(options) {
@ -143,26 +143,6 @@ function makeFlexboxAlign(options) {
.map(() => '<li class="flexbox-dummy"></li>').join('');
}
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);
const wrapper = document.createElement('div');
wrapper.classList.add('message-wrapper');
wrapper.appendChild(node);
messagesHolder.appendChild(wrapper);
}
function _serializeElement(name, attributes) {
return [name]
.concat(Object.keys(attributes).map(key => {
@ -186,16 +166,44 @@ function makeVoidElement(name, attributes) {
return '<{0}/>'.format(_serializeElement(name, attributes));
}
function listenToMessages(target) {
function _messageHandler(target, message, className) {
if (!message) {
message = 'Unknown message';
}
const messagesHolder = target.querySelector('.messages');
if (!messagesHolder) {
return false;
}
/* TODO: animate this */
const node = document.createElement('div');
node.innerHTML = message.replace(/\n/g, '<br/>');
node.classList.add('message');
node.classList.add(className);
const wrapper = document.createElement('div');
wrapper.classList.add('message-wrapper');
wrapper.appendChild(node);
messagesHolder.appendChild(wrapper);
return true;
}
function unlistenToMessages() {
events.unlisten(events.Success);
events.unlisten(events.Error);
events.unlisten(events.Info);
events.listen(
events.Success, msg => { _messageHandler(target, msg, 'success'); });
events.listen(
events.Error, msg => { _messageHandler(target, msg, 'error'); });
events.listen(
events.Info, msg => { _messageHandler(target, msg, 'info'); });
}
function listenToMessages(target) {
unlistenToMessages();
const listen = (eventType, className) => {
events.listen(
eventType,
msg => {
return _messageHandler(target, msg, className);
});
};
listen(events.Success, 'success');
listen(events.Error, 'error');
listen(events.Info, 'info');
}
function clearMessages(target) {
@ -314,6 +322,7 @@ module.exports = {
enableForm: enableForm,
disableForm: disableForm,
listenToMessages: listenToMessages,
unlistenToMessages: unlistenToMessages,
clearMessages: clearMessages,
decorateValidator: decorateValidator,
makeVoidElement: makeVoidElement,

View file

@ -10,7 +10,7 @@ class EmptyView {
render(ctx) {
const target = document.getElementById('content-holder');
const source = this.template;
views.listenToMessages(target);
views.listenToMessages(source);
views.showView(target, source);
}
}

View file

@ -15,7 +15,7 @@ class EndlessPageView {
const source = this.holderTemplate();
const pageHeaderHolder = source.querySelector('.page-header-holder');
const pagesHolder = source.querySelector('.pages-holder');
views.listenToMessages(target);
views.listenToMessages(source);
views.showView(target, source);
this.active = true;
this.working = 0;

View file

@ -61,7 +61,7 @@ class HelpView {
}
}
views.listenToMessages(target);
views.listenToMessages(source);
views.showView(target, source);
views.scrollToHash();

View file

@ -16,7 +16,7 @@ class HomeView {
buildDate: config.meta.buildDate,
});
views.listenToMessages(target);
views.listenToMessages(source);
views.showView(target, source);
}
}

View file

@ -36,7 +36,7 @@ class LoginView {
.always(() => { views.enableForm(form); });
});
views.listenToMessages(target);
views.listenToMessages(source);
views.showView(target, source);
}
}

View file

@ -99,13 +99,13 @@ class ManualPageView {
}));
}
views.listenToMessages(target);
views.listenToMessages(source);
views.showView(target, source);
if (response.total <= (currentPage - 1) * response.pageSize) {
events.notify(events.Info, 'No data to show');
}
}, response => {
views.listenToMessages(target);
views.listenToMessages(source);
views.showView(target, source);
events.notify(events.Error, response.description);
});

View file

@ -24,7 +24,7 @@ class PasswordResetView {
.catch(() => { views.enableForm(form); });
});
views.listenToMessages(target);
views.listenToMessages(source);
views.showView(target, source);
}
}

View file

@ -33,7 +33,7 @@ class RegistrationView {
.always(() => { views.enableForm(form); });
});
views.listenToMessages(target);
views.listenToMessages(source);
views.showView(target, source);
}
}

View file

@ -25,7 +25,7 @@ class SettingsView {
});
});
views.listenToMessages(target);
views.listenToMessages(source);
views.showView(target, source);
}
}

View file

@ -101,7 +101,7 @@ class TagListHeaderView {
this._saveButtonClickHandler(e, ctx, target);
});
views.listenToMessages(target);
views.listenToMessages(source);
views.showView(target, source);
}
}

View file

@ -19,10 +19,11 @@ class UserDeleteView {
e.preventDefault();
views.clearMessages(target);
views.disableForm(form);
ctx.delete();
ctx.delete()
.catch(() => { views.enableForm(form); });
});
views.listenToMessages(target);
views.listenToMessages(source);
views.showView(target, source);
}
}

View file

@ -54,7 +54,7 @@ class UserEditView {
.always(() => { views.enableForm(form); });
});
views.listenToMessages(target);
views.listenToMessages(source);
views.showView(target, source);
}
}

View file

@ -10,7 +10,7 @@ class UserSummaryView {
render(ctx) {
const target = ctx.target;
const source = this.template(ctx);
views.listenToMessages(target);
views.listenToMessages(source);
views.showView(target, source);
}
}

View file

@ -38,7 +38,7 @@ class UserView {
ctx.target = source.querySelector('#user-content-holder');
view.render(ctx);
views.listenToMessages(target);
views.listenToMessages(source);
views.showView(target, source);
}
}