From 1b5b935a28f6aa43f42b5206d2b9e02a36a7fca4 Mon Sep 17 00:00:00 2001 From: Patrick Jentsch Date: Tue, 15 Dec 2020 14:38:52 +0100 Subject: [PATCH] Rework list handling --- web/app/events.py | 40 ++-- web/app/static/css/nopaque.css | 4 + web/app/static/js/nopaque.js | 239 ++++++++++++---------- web/app/static/js/nopaque.lists.js | 248 +++++++++++++++++------ web/app/templates/admin/user.html.j2 | 21 +- web/app/templates/main/dashboard.html.j2 | 13 +- web/app/templates/nopaque.html.j2 | 21 +- 7 files changed, 354 insertions(+), 232 deletions(-) diff --git a/web/app/events.py b/web/app/events.py index df716d81..a0f76b3b 100644 --- a/web/app/events.py +++ b/web/app/events.py @@ -33,38 +33,24 @@ def disconnect(): connected_sessions.remove(request.sid) -@socketio.on('user_data_stream_init') +@socketio.on('start_user_session') @socketio_login_required -def user_data_stream_init(): - socketio.start_background_task(user_data_stream, +def start_user_session(user_id): + if not (current_user.id == user_id or current_user.is_administrator): + return + socketio.start_background_task(user_session, current_app._get_current_object(), - current_user.id, request.sid) + user_id, request.sid) -@socketio.on('foreign_user_data_stream_init') -@socketio_login_required -@socketio_admin_required -def foreign_user_data_stream_init(user_id): - socketio.start_background_task(user_data_stream, - current_app._get_current_object(), - user_id, request.sid, foreign=True) - - -def user_data_stream(app, user_id, session_id, foreign=False): +def user_session(app, user_id, session_id): ''' - ' Sends initial corpus and job lists to the client. Afterwards it checks - ' every 3 seconds if changes to the initial values appeared. If changes are - ' detected, a RFC 6902 compliant JSON patch gets send. - ' - ' NOTE: The initial values are send as a init events. - ' The JSON patches are send as update events. + ' Sends initial user data to the client. Afterwards it checks every 3s if + ' changes to the initial values appeared. If changes are detected, a + ' RFC 6902 compliant JSON patch gets send. ''' - if foreign: - init_event = 'foreign_user_data_stream_init' - update_event = 'foreign_user_data_stream_update' - else: - init_event = 'user_data_stream_init' - update_event = 'user_data_stream_update' + init_event = 'user_{}_init'.format(user_id) + patch_event = 'user_{}_patch'.format(user_id) with app.app_context(): # Gather current values from database. user = User.query.get(user_id) @@ -80,7 +66,7 @@ def user_data_stream(app, user_id, session_id, foreign=False): new_user_dict) # In case there are patches, send them to the client. if user_patch: - socketio.emit(update_event, user_patch.to_string(), + socketio.emit(patch_event, user_patch.to_string(), room=session_id) # Set new values as references for the next iteration. user_dict = new_user_dict diff --git a/web/app/static/css/nopaque.css b/web/app/static/css/nopaque.css index 3ad3a914..597701aa 100644 --- a/web/app/static/css/nopaque.css +++ b/web/app/static/css/nopaque.css @@ -8,6 +8,10 @@ main { margin-top: 48px; } +table.ressource-list tr { + cursor: pointer; +} + .parallax-container .parallax { z-index: auto; } diff --git a/web/app/static/js/nopaque.js b/web/app/static/js/nopaque.js index 5e3ad92a..bd5e3f4e 100644 --- a/web/app/static/js/nopaque.js +++ b/web/app/static/js/nopaque.js @@ -1,96 +1,138 @@ +class AppClient { + constructor(currentUserId) { + this.socket = io({transports: ['websocket']}); + this.users = {}; + this.users.self = this.loadUser(currentUserId); + } + + loadUser(userId) { + let user = new User(); + this.users[userId] = user; + this.socket.on(`user_${userId}_init`, msg => user.init(JSON.parse(msg))); + this.socket.on(`user_${userId}_patch`, msg => user.patch(JSON.parse(msg))); + this.socket.emit('start_user_session', userId); + return user; + } +} + + +class User { + constructor() { + this.data = undefined; + this.eventListeners = { + corporaInit: [], + corporaPatch: [], + jobsInit: [], + jobsPatch: [], + queryResultsInit: [], + queryResultsPatch: [] + }; + } + + init(data) { + this.data = data; + + let listener; + for (listener of this.eventListeners.corporaInit) { + listener(this.data.corpora); + } + for (listener of this.eventListeners.jobsInit) { + listener(this.data.jobs); + } + for (listener of this.eventListeners.queryResultsInit) { + listener(this.data.query_results); + } + } + + patch(patch) { + this.data = jsonpatch.apply_patch(this.data, patch); + + let corporaPatch = patch.filter(operation => operation.path.startsWith("/corpora")); + let jobsPatch = patch.filter(operation => operation.path.startsWith("/jobs")); + let queryResultsPatch = patch.filter(operation => operation.path.startsWith("/query_results")); + + for (let listener of this.eventListeners.corporaPatch) { + if (corporaPatch.length > 0) {listener(corporaPatch);} + } + for (let listener of this.eventListeners.jobsPatch) { + if (jobsPatch.length > 0) {listener(jobsPatch);} + } + for (let listener of this.eventListeners.queryResultsPatch) { + if (queryResultsPatch.length > 0) {listener(queryResultsPatch);} + } + + for (let operation of jobsPatch) { + if (operation.op !== 'replace') {continue;} + // Matches the only path that should be handled here: /jobs/{jobId}/status + if (/^\/jobs\/(\d+)\/status$/.test(operation.path)) { + let [match, jobId] = operation.path.match(/^\/jobs\/(\d+)\/status$/); + if (this.data.settings.job_status_site_notifications === "end" && !["complete", "failed"].includes(operation.value)) {continue;} + nopaque.flash(`[${this.data.jobs[jobId].title}] New status: ${operation.value}`, "job"); + } + } + } + + addEventListener(type, listener) { + switch (type) { + case 'corporaInit': + this.eventListeners.corporaInit.push(listener); + if (this.data !== undefined) {listener(this.data.corpora);} + break; + case 'corporaPatch': + this.eventListeners.corporaPatch.push(listener); + break; + case 'jobsInit': + this.eventListeners.jobsInit.push(listener); + if (this.data !== undefined) {listener(this.data.jobs);} + break; + case 'jobsPatch': + this.eventListeners.jobsPatch.push(listener); + break; + case 'queryResultsInit': + this.eventListeners.queryResultsInit.push(listener); + if (this.data !== undefined) {listener(this.data.query_results);} + break; + case 'queryResultsPatch': + this.eventListeners.queryResultsPatch.push(listener); + break; + default: + console.error(`Unknown event type: ${type}`); + } + } +} + + /* * The nopaque object is used as a namespace for nopaque specific functions and * variables. */ var nopaque = {}; -// User data -nopaque.user = {}; -nopaque.user.settings = {}; -nopaque.user.settings.darkMode = undefined; -nopaque.corporaSubscribers = []; -nopaque.jobsSubscribers = []; -nopaque.queryResultsSubscribers = []; +nopaque.flash = function(message, category) { + let toast; + let toastActionElement; -// Foreign user (user inspected with admin credentials) data -nopaque.foreignUser = {}; -nopaque.foreignUser.isAuthenticated = undefined; -nopaque.foreignUser.settings = {}; -nopaque.foreignUser.settings.darkMode = undefined; -nopaque.foreignCorporaSubscribers = []; -nopaque.foreignJobsSubscribers = []; -nopaque.foreignQueryResultsSubscribers = []; + switch (category) { + case "corpus": + message = `book${message}`; + break; + case "error": + message = `error${message}`; + break; + case "job": + message = `work${message}`; + break; + default: + message = `notifications${message}`; + } -// nopaque functions -nopaque.socket = io({transports: ['websocket']}); -// Add event handlers -nopaque.socket.on("user_data_stream_init", function(msg) { - nopaque.user = JSON.parse(msg); - for (let subscriber of nopaque.corporaSubscribers) { - subscriber.init(nopaque.user.corpora); - } - for (let subscriber of nopaque.jobsSubscribers) { - subscriber.init(nopaque.user.jobs); - } - for (let subscriber of nopaque.queryResultsSubscribers) { - subscriber.init(nopaque.user.query_results); - } -}); - -nopaque.socket.on("user_data_stream_update", function(msg) { - var patch; - - patch = JSON.parse(msg); - nopaque.user = jsonpatch.apply_patch(nopaque.user, patch); - corpora_patch = patch.filter(operation => operation.path.startsWith("/corpora")); - jobs_patch = patch.filter(operation => operation.path.startsWith("/jobs")); - query_results_patch = patch.filter(operation => operation.path.startsWith("/query_results")); - for (let subscriber of nopaque.corporaSubscribers) { - subscriber.update(corpora_patch); - } - for (let subscriber of nopaque.jobsSubscribers) { - subscriber.update(jobs_patch); - } - for (let subscriber of nopaque.queryResultsSubscribers) { - subscriber.update(query_results_patch); - } - if (["all", "end"].includes(nopaque.user.settings.job_status_site_notifications)) { - for (operation of jobs_patch) { - /* "/jobs/{jobId}/..." -> ["{jobId}", ...] */ - pathArray = operation.path.split("/").slice(2); - if (operation.op === "replace" && pathArray[1] === "status") { - if (nopaque.user.settings.job_status_site_notifications === "end" && !["complete", "failed"].includes(operation.value)) {continue;} - nopaque.flash(`[${nopaque.user.jobs[pathArray[0]].title}] New status: ${operation.value}`, "job"); - } - } - } -}); - -nopaque.socket.on("foreign_user_data_stream_init", function(msg) { - nopaque.foreignUser = JSON.parse(msg); - for (let subscriber of nopaque.foreignCorporaSubscribers) { - subscriber.init(nopaque.foreignUser.corpora); - } - for (let subscriber of nopaque.foreignJobsSubscribers) { - subscriber.init(nopaque.foreignUser.jobs); - } - for (let subscriber of nopaque.foreignQueryResultsSubscribers) { - subscriber.init(nopaque.foreignUser.query_results); - } -}); - -nopaque.socket.on("foreign_user_data_stream_update", function(msg) { - var patch; - - patch = JSON.parse(msg); - nopaque.foreignUser = jsonpatch.apply_patch(nopaque.foreignUser, patch); - corpora_patch = patch.filter(operation => operation.path.startsWith("/corpora")); - jobs_patch = patch.filter(operation => operation.path.startsWith("/jobs")); - query_results_patch = patch.filter(operation => operation.path.startsWith("/query_results")); - for (let subscriber of nopaque.foreignCorporaSubscribers) {subscriber.update(corpora_patch);} - for (let subscriber of nopaque.foreignJobsSubscribers) {subscriber.update(jobs_patch);} - for (let subscriber of nopaque.foreignQueryResultsSubscribers) {subscriber.update(query_results_patch);} -}); + toast = M.toast({html: `${message} + `}); + toastActionElement = toast.el.querySelector('.toast-action[data-action="close"]'); + toastActionElement.addEventListener('click', () => {toast.dismiss();}); +}; nopaque.Forms = {}; nopaque.Forms.init = function() { @@ -163,30 +205,3 @@ nopaque.Forms.init = function() { } } } - - -nopaque.flash = function(message, category) { - let toast; - let toastActionElement; - - switch (category) { - case "corpus": - message = `book${message}`; - break; - case "error": - message = `error${message}`; - break; - case "job": - message = `work${message}`; - break; - default: - message = `notifications${message}`; - } - - toast = M.toast({html: `${message} - `}); - toastActionElement = toast.el.querySelector('.toast-action[data-action="close"]'); - toastActionElement.addEventListener('click', () => {toast.dismiss();}); -} diff --git a/web/app/static/js/nopaque.lists.js b/web/app/static/js/nopaque.lists.js index 0ce961ed..36ea8b48 100644 --- a/web/app/static/js/nopaque.lists.js +++ b/web/app/static/js/nopaque.lists.js @@ -1,6 +1,33 @@ class RessourceList { - constructor(idOrElement, options = {}) { - this.list = new List(idOrElement, {...RessourceList.options, ...options}); + /* A wrapper class for the list.js list. + * This class is not meant to be used directly, instead it should be used as + * a template for concrete ressource list implementations. + */ + constructor(listElement, options = {}) { + if (listElement.dataset.userId) { + if (listElement.dataset.userId in nopaque.appClient.users) { + this.user = nopaque.appClient.users[listElement.dataset.userId]; + } else { + console.error(`User not found: ${listElement.dataset.userId}`); + return; + } + } else { + this.user = nopaque.appClient.users.self; + } + this.list = new List(listElement, {...RessourceList.options, ...options}); + this.valueNames = ['id']; + for (let element of this.list.valueNames) { + switch (typeof element) { + case 'object': + if (element.hasOwnProperty('name')) {this.valueNames.push(element.name);} + break; + case 'string': + this.valueNames.push(element); + break; + default: + console.error(`Unknown value name definition: ${element}`); + } + } } init(ressources) { @@ -9,38 +36,27 @@ class RessourceList { this.list.sort('id', {order: 'desc'}); } - - update(patch) { - let item, pathArray; - - for (let operation of patch) { - /* - * '/{ressourceName}/{ressourceId}/{valueName}' -> ['{ressourceId}', {valueName}] - * Example: '/jobs/1/status' -> ['1', 'status'] - */ - let [id, valueName] = operation.path.split("/").slice(2); - switch(operation.op) { - case 'add': - this.add(operation.value); - break; - case 'remove': - this.remove(id); - break; - case 'replace': - this.replace(id, valueName, operation.value); - break; - default: - break; - } - } + patch(patch) { + /* + * It's not possible to generalize a patch Handler for all type of + * ressources. So this method is meant to be an interface. + */ + console.error('patch method not implemented!'); } add(values) { - /* WORKAROUND: Set a callback function ('() => {return;}') to force List.js - perform the add method asynchronous. - * https://listjs.com/api/#add - */ - this.list.add(values, () => {return;}); + let ressources = Array.isArray(values) ? values : [values]; + // Discard ressource values, that are not defined to be used in the list. + ressources = ressources.map(ressource => { + let cleanedRessource = {}; + for (let [valueName, value] of Object.entries(ressource)) { + if (this.valueNames.includes(valueName)) {cleanedRessource[valueName] = value;} + } + return cleanedRessource; + }); + // Set a callback function ('() => {return;}') to force List.js perform the + // add method asynchronous: https://listjs.com/api/#add + this.list.add(ressources, () => {return;}); } remove(id) { @@ -48,35 +64,84 @@ class RessourceList { } replace(id, valueName, newValue) { - if (!this.list.valuesNames.includes(valueName)) {return;} - let item = this.list.get('id', id); - item.values({[valueName]: newValue}); + if (this.valueNames.includes(valueName)) { + let item = this.list.get('id', id)[0]; + item.values({[valueName]: newValue}); + } } } - - RessourceList.options = {page: 5, pagination: [{innerWindow: 4, outerWindow: 1}]}; class CorpusList extends RessourceList { - constructor(listElementId, options = {}) { - let listElement = document.querySelector(`#${listElementId}`); + constructor(listElement, options = {}) { super(listElement, {...CorpusList.options, ...options}); - listElement.addEventListener('click', (event) => { - let actionButtonElement = event.target.closest('.action-button'); - if (actionButtonElement === null) {return;} - let corpusId = event.target.closest('tr').dataset.id; - let action = actionButtonElement.dataset.action; - switch (action) { - case 'analyse': - window.location.href = nopaque.user.corpora[corpusId].analysis_url; + this.user.addEventListener('corporaInit', corpora => this.init(corpora)); + this.user.addEventListener('corporaPatch', patch => this.patch(patch)); + listElement.addEventListener('click', (event) => {this.onclick(event)}); + } + + onclick(event) { + let corpusId = event.target.closest('tr').dataset.id; + let actionButtonElement = event.target.closest('.action-button'); + let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action; + switch (action) { + case 'analyse': + window.location.href = nopaque.user.corpora[corpusId].analysis_url; + case 'delete': + let deleteModalHTML = ``; + let deleteModalParentElement = document.querySelector('main'); + deleteModalParentElement.insertAdjacentHTML('beforeend', deleteModalHTML); + let deleteModalElement = deleteModalParentElement.lastChild; + let deleteModal = M.Modal.init(deleteModalElement, {onCloseEnd: () => {deleteModal.destroy(); deleteModalElement.remove();}}); + deleteModal.open(); + break; + case 'view': + // TODO: handle unprepared corpora + window.location.href = nopaque.user.corpora[corpusId].url; + break; + default: + console.error(`Unknown action: ${action}`); + break; + } + } + + patch(patch) { + for (let operation of patch) { + switch(operation.op) { + case 'add': + // Matches the only paths that should be handled here: /corpora/{corpusId} + if (/^\/corpora\/(\d+)$/.test(operation.path)) {this.add(operation.value);} + break; + case 'remove': + // See case 'add' ;) + if (/^\/corpora\/(\d+)$/.test(operation.path)) { + let [match, id] = operation.path.match(/^\/corpora\/(\d+)$/); + this.remove(corpusId); + } + break; + case 'replace': + // Matches the only paths that should be handled here: /corpora/{corpusId}/{status || description || title} + if (/^\/corpora\/(\d+)\/(status|description|title)$/.test(operation.path)) { + let [match, id, valueName] = operation.path.match(/^\/corpora\/(\d+)\/(status|description|title)$/); + this.replace(id, valueName, operation.value); + } + break; + default: + break; } - }); - nopaque.corporaSubscribers.push(this); + } } } - - CorpusList.options = { item: ` book @@ -84,23 +149,80 @@ CorpusList.options = { delete - edit search + send `, - valueNames: [{data: ['id']}, {name: "status", attr: "data-status"}, 'description', 'title'] + valueNames: [{data: ['id']}, {name: 'status', attr: 'data-status'}, 'description', 'title'] }; class JobList extends RessourceList { - constructor(listElementId, options = {}) { - let listElement = document.querySelector(`#${listElementId}`); + constructor(listElement, options = {}) { super(listElement, {...JobList.options, ...options}); - nopaque.jobsSubscribers.push(this); + this.user.addEventListener('jobsInit', jobs => this.init(jobs)); + this.user.addEventListener('jobsPatch', patch => this.patch(patch)); + listElement.addEventListener('click', (event) => {this.onclick(event)}); + } + + onclick(event) { + let jobId = event.target.closest('tr').dataset.id; + let actionButtonElement = event.target.closest('.action-button'); + let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action; + switch (action) { + case 'delete': + let deleteModalHTML = ``; + let deleteModalParentElement = document.querySelector('main'); + deleteModalParentElement.insertAdjacentHTML('beforeend', deleteModalHTML); + let deleteModalElement = deleteModalParentElement.lastChild; + let deleteModal = M.Modal.init(deleteModalElement, {onCloseEnd: () => {deleteModal.destroy(); deleteModalElement.remove();}}); + deleteModal.open(); + break; + case 'view': + window.location.href = this.user.data.jobs[jobId].url; + break; + default: + console.error(`Unknown action: "${action}"`); + break; + } + } + + patch(patch) { + for (let operation of patch) { + switch(operation.op) { + case 'add': + // Matches the only paths that should be handled here: /jobs/{jobId} + if (/^\/jobs\/(\d+)$/.test(operation.path)) {this.add(operation.value);} + break; + case 'remove': + // See case add ;) + if (/^\/jobs\/(\d+)$/.test(operation.path)) { + let [match, id] = operation.path.match(/^\/jobs\/(\d+)$/); + this.remove(jobId); + } + break; + case 'replace': + // Matches the only paths that should be handled here: /jobs/{jobId}/{service || status || description || title} + if (/^\/jobs\/(\d+)\/(service|status|description|title)$/.test(operation.path)) { + let [match, id, valueName] = operation.path.match(/^\/jobs\/(\d+)\/(service|status|description|title)$/); + this.replace(id, valueName, operation.value); + } + break; + default: + break; + } + } } } - - JobList.options = { item: ` @@ -111,19 +233,17 @@ JobList.options = { send `, - valueNames: [{data: ['id']}, {name: 'service', attr: 'data-service'}, {name: "status", attr: "data-status"}, 'description', 'title'] + valueNames: [{data: ['id']}, {name: 'service', attr: 'data-service'}, {name: 'status', attr: 'data-status'}, 'description', 'title'] }; class QueryResultList extends RessourceList { - constructor(listElementId, options = {}) { - let listElement = document.querySelector(`#${listElementId}`); + constructor(listElement, options = {}) { super(listElement, {...QueryResultList.options, ...options}); - nopaque.queryResultsSubscribers.push(this); + this.user.addEventListener('queryResultsInit', queryResults => this.init(queryResults)); + this.user.addEventListener('queryResultsPatch', patch => this.init(patch)); } } - - QueryResultList.options = { item: `

@@ -136,5 +256,3 @@ QueryResultList.options = { `, valueNames: [{data: ['id']}, 'corpus_title', 'description', 'query', 'title'] }; - -export { CorpusList, JobList, QueryResultList }; diff --git a/web/app/templates/admin/user.html.j2 b/web/app/templates/admin/user.html.j2 index 97e3137a..78351735 100644 --- a/web/app/templates/admin/user.html.j2 +++ b/web/app/templates/admin/user.html.j2 @@ -36,16 +36,16 @@ -
+

Corpora

-
+
search
- +
@@ -64,16 +64,16 @@ -
+

Jobs

-
+
search
-
+
@@ -109,10 +109,9 @@ {% block scripts %} {{ super() }} - {% endblock scripts %} diff --git a/web/app/templates/main/dashboard.html.j2 b/web/app/templates/main/dashboard.html.j2 index e838f72a..8326654a 100644 --- a/web/app/templates/main/dashboard.html.j2 +++ b/web/app/templates/main/dashboard.html.j2 @@ -29,7 +29,7 @@ -
Service
+
@@ -102,7 +102,7 @@ -
+
@@ -175,10 +175,9 @@ {% block scripts %} {{ super() }} - {% endblock scripts %} diff --git a/web/app/templates/nopaque.html.j2 b/web/app/templates/nopaque.html.j2 index a54fda33..804e8776 100644 --- a/web/app/templates/nopaque.html.j2 +++ b/web/app/templates/nopaque.html.j2 @@ -244,28 +244,29 @@ {% block scripts %} {{ super() }} +{% if current_user.setting_dark_mode %} + +{% endif %} + + {% endblock scripts %}
Service