nopaque.App = class App { #promises; constructor() { this.data = { users: {} }; this.#promises = { getUser: {}, subscribeUser: {} }; this.socket = io({transports: ['websocket'], upgrade: false}); this.socket.on('patch_user', (patch) => {this.onPatch(patch);}); } getUser(userId) { if (userId in this.#promises.getUser) { return this.#promises.getUser[userId]; } this.#promises.getUser[userId] = new Promise((resolve, reject) => { this.socket.emit('users.get_user', userId, (response) => { if (response.status === 200) { this.data.users[userId] = response.body; resolve(this.data.users[userId]); } else { reject(`[${response.status}] ${response.statusText}`); } }); }); return this.#promises.getUser[userId]; } subscribeUser(userId) { if (userId in this.#promises.subscribeUser) { return this.#promises.subscribeUser[userId]; } this.#promises.subscribeUser[userId] = new Promise((resolve, reject) => { this.socket.emit('users.subscribe_user', userId, (response) => { if (response.status === 200) { resolve(response); } else { reject(response); } }); }); return this.#promises.subscribeUser[userId]; } flash(message, category) { let iconPrefix = ''; switch (category) { case 'corpus': { iconPrefix = 'book'; break; } case 'error': { iconPrefix = 'error'; break; } case 'job': { iconPrefix = 'J'; break; } case 'settings': { iconPrefix = 'settings'; break; } default: { iconPrefix = 'notifications'; break; } } let toast = M.toast( { html: ` ${iconPrefix}${message} `.trim() } ); let toastCloseActionElement = toast.el.querySelector('.action-button[data-action="close"]'); toastCloseActionElement.addEventListener('click', () => {toast.dismiss();}); } onPatch(patch) { // Filter Patch to only include operations on users that are initialized let regExp = new RegExp(`^/users/(${Object.keys(this.data.users).join('|')})`); let filteredPatch = patch.filter(operation => regExp.test(operation.path)); // Handle job status updates let subRegExp = new RegExp(`^/users/([A-Za-z0-9]*)/jobs/([A-Za-z0-9]*)/status$`); let subFilteredPatch = filteredPatch .filter((operation) => {return operation.op === 'replace';}) .filter((operation) => {return subRegExp.test(operation.path);}); for (let operation of subFilteredPatch) { let [match, userId, jobId] = operation.path.match(subRegExp); this.flash(`[${this.data.users[userId].jobs[jobId].title}] New status: `, 'job'); } // Apply Patch jsonpatch.applyPatch(this.data, filteredPatch); } init() { this.initUi(); } initUi() { /* Pre-Initialization fixes */ // #region // Flask-WTF sets the standard HTML maxlength Attribute on input/textarea // elements to specify their maximum length (in characters). Unfortunatly // Materialize won't recognize the maxlength Attribute, instead it uses // the data-length Attribute. It's conversion time :) for (let elem of document.querySelectorAll('input[maxlength], textarea[maxlength]')) { elem.dataset.length = elem.getAttribute('maxlength'); elem.removeAttribute('maxlength'); } // To work around some limitations with the Form setup of Flask-WTF. // HTML option elements with an empty value are considered as placeholder // elements. The user should not be able to actively select these options. // So they get the disabled attribute. for (let optionElement of document.querySelectorAll('option[value=""]')) { optionElement.disabled = true; } // TODO: Check why we are doing this. for (let optgroupElement of document.querySelectorAll('optgroup[label=""]')) { for (let c of optgroupElement.children) { optgroupElement.parentElement.insertAdjacentElement('afterbegin', c); } optgroupElement.remove(); } // #endregion /* Initialize Materialize Components */ // #region // Automatically initialize Materialize Components that do not require // additional configuration. M.AutoInit(); // CharacterCounters // Materialize didn't include the CharacterCounter plugin within the // AutoInit method (maybe they forgot it?). Anyway... We do it here. :) M.CharacterCounter.init(document.querySelectorAll('input[data-length]:not(.no-autoinit), textarea[data-length]:not(.no-autoinit)')); // Header navigation processes and services Dropdown. M.Dropdown.init( document.querySelector('#navbar-data-processing-and-analysis-dropdown-trigger'), { constrainWidth: false, container: document.querySelector('#dropdowns'), coverTrigger: false } ); // Header navigation account Dropdown. M.Dropdown.init( document.querySelector('#navbar-account-dropdown-trigger'), { alignment: 'right', constrainWidth: false, container: document.querySelector('#dropdowns'), coverTrigger: false } ); // Terms of use modal M.Modal.init( document.querySelector('#terms-of-use-modal'), { dismissible: false, onCloseEnd: (modalElement) => { nopaque.requests.users.entity.acceptTermsOfUse(); } } ); // #endregion /* Initialize nopaque Components */ // #region nopaque.resource_displays.AutoInit(); nopaque.resource_lists.AutoInit(); nopaque.forms.AutoInit(); // #endregion } };