UNPKG

nodegame-widgets

Version:

Collections of useful and reusable javascript / HTML snippets for nodeGame

942 lines (856 loc) 31.7 kB
/** * # Feedback * Copyright(c) 2019 Stefano Balietti * MIT Licensed * * Sends a feedback message to the server * * www.nodegame.org * * TODO: rename css class feedback-char-count * TODO: words and chars count without contraints, just show. * TODO: shows all constraints in gray before the textarea. */ (function(node) { "use strict"; node.widgets.register('Feedback', Feedback); // ## Meta-data Feedback.version = '1.6.0'; Feedback.description = 'Displays a configurable feedback form'; Feedback.title = 'Feedback'; Feedback.className = 'feedback'; Feedback.texts = { autoHint: function(w) { var res, res2; if (w.minChars && w.maxChars) { res = 'between ' + w.minChars + ' and ' + w.maxChars + ' characters'; } else if (w.minChars) { res = 'at least ' + w.minChars + ' character'; if (w.minChars > 1) res += 's'; } else if (w.maxChars) { res = 'at most ' + w.maxChars + ' character'; if (w.maxChars > 1) res += 's'; } if (w.minWords && w.maxWords) { res2 = 'beetween ' + w.minWords + ' and ' + w.maxWords + ' words'; } else if (w.minWords) { res2 = 'at least ' + w.minWords + ' word'; if (w.minWords > 1) res2 += 's'; } else if (w.maxWords) { res2 = 'at most ' + w.maxWords + ' word'; if (w.maxWords > 1) res2 += 's'; } if (res) { res = '(' + res; if (res2) res += ', and ' + res2; return res + ')'; } else if (res2) { return '(' + res2 + ')'; } return false; }, submit: 'Submit feedback', label: 'Any feedback? Let us know here:', sent: 'Sent!', counter: function(w, param) { var res; res = param.chars ? ' character' : ' word'; if (param.len !== 1) res += 's'; if (param.needed) res += ' needed'; else if (param.over) res += ' over'; else if (!param.justcount) res += ' remaining'; return res; } }; // Colors for missing, excess or ok. var colNeeded, colOver, colRemain; colNeeded = '#a32020'; // #f2dede'; colOver = '#a32020'; // #f2dede'; colRemain = '#78b360'; // '#dff0d8'; // ## Dependencies Feedback.dependencies = { JSUS: {} }; /** * ## Feedback constructor * * `Feedback` sends a feedback message to the server * * @param {object} options Optional. Configuration options */ function Feedback(options) { var tmp; if ('undefined' !== typeof options.maxLength) { console.log('***Feedback constructor: maxLength is deprecated, ' + 'use maxChars instead***'); options.maxChars = options.maxLength; } if ('undefined' !== typeof options.minLength) { console.log('***Feedback constructor: minLength is deprecated, ' + 'use minChars instead***'); options.minChars = options.minLength; } /** * ### Feedback.mainText * * The main text introducing the choices * * @see Feedback.spanMainText */ this.mainText = null; /** * ### Feedback.hint * * An additional text with information about how to select items * * If not specified, it may be auto-filled, e.g. '(pick 2)'. * * @see Feedback.texts.autoHint */ this.hint = null; /** * ### Feedback.spanMainText * * The span containing the main text */ this.spanMainText = null; /** * ### Feedback.maxChars * * The maximum character length for feedback to be submitted * * Default: 0 */ if ('undefined' === typeof options.maxChars) { this.maxChars = 0; } else { tmp = J.isInt(options.maxChars, 0); if (tmp !== false) { this.maxChars = tmp; } else { throw new TypeError('Feedback constructor: maxChars ' + 'must be an integer >= 0 or undefined. ' + 'Found: ' + options.maxChars); } } /** * ### Feedback.minChars * * The minimum character length for feedback to be submitted * * If minChars = 0, then there is no minimum length checked. * * Default: 0 */ if ('undefined' === typeof options.minChars) { this.minChars = 0; } else { tmp = J.isInt(options.minChars, 0, undefined, true); if (tmp !== false) { if (this.maxChars && tmp > this.maxChars) { throw new TypeError('Feedback constructor: minChars ' + 'cannot be greater than maxChars. ' + 'Found: ' + tmp + ' > ' + this.maxChars); } this.minChars = tmp; } else { throw new TypeError('Feedback constructor: minChars ' + 'must be an integer >= 0 or undefined. ' + 'Found: ' + options.minChars); } } /** * ### Feedback.maxWords * * The maximum number of words for feedback to be submitted * * Set to 0 for no checks. * * Default: 0 */ if ('undefined' === typeof options.maxWords) { this.maxWords = 0; } else { tmp = J.isInt(options.maxWords, 0, undefined, true); if (tmp !== false) { this.maxWords = options.maxWords; } else { throw new TypeError('Feedback constructor: maxWords ' + 'must be an integer >= 0 or undefined. ' + 'Found: ' + options.maxWords); } } /** * ### Feedback.minWords * * The minimum number of words for feedback to be submitted * * If minWords = 0, then there is no minimum checked. * * Default: 0 */ if ('undefined' === typeof options.minWords) { this.minWords = 0; } else { tmp = J.isInt(options.minWords, 0, undefined, true); if (tmp !== false) { this.minWords = options.minWords; // Checking if words and characters limit are compatible. if (this.maxChars) { tmp = (this.maxChars+1)/2; if (this.minWords > tmp) { throw new TypeError('Feedback constructor: minWords ' + 'cannot be larger than ' + '(maxChars+1)/2. Found: ' + this.minWords + ' > ' + tmp); } } } else { throw new TypeError('Feedback constructor: minWords ' + 'must be an integer >= 0 or undefined. ' + 'Found: ' + options.minWords); } } // Extra checks. if (this.maxWords) { if (this.maxChars && this.maxChars < this.maxWords) { throw new TypeError('Feedback constructor: maxChars ' + 'cannot be smaller than maxWords. ' + 'Found: ' + this.maxChars + ' > ' + this.maxWords); } if (this.minChars > this.maxWords) { throw new TypeError('Feedback constructor: minChars ' + 'cannot be greater than maxWords. ' + 'Found: ' + this.minChars + ' > ' + this.maxWords); } } /** * ### Feedback.rows * * The number of initial rows of the texarea * * Default: 3 */ if ('undefined' === typeof options.rows) { this.rows = 3; } else if (J.isInt(options.rows, 0) !== false) { this.rows = options.rows; } else { throw new TypeError('Feedback constructor: rows ' + 'must be an integer > 0 or undefined. ' + 'Found: ' + options.rows); } /** * ### Feedback.maxAttemptLength * * The maximum character length for an attempt to submit feedback * * Attempts are stored in the attempts array. You can store attempts * longer than valid feedbacks. * * Set to 0 for no limit. * * Default: 0 */ if ('undefined' === typeof options.maxAttemptLength) { this.maxAttemptLength = 0; } else { tmp = J.isNumber(options.maxAttemptLength, 0); if (tmp !== false) { this.maxAttemptLength = tmp; } else { throw new TypeError('Feedback constructor: ' + 'options.maxAttemptLength must be a number ' + '> 0 or undefined. Found: ' + options.maxAttemptLength); } } /** * ### Feedback.showSubmit * * If TRUE, the submit button is shown * * Default: true * * @see Feedback.submitButton */ this.showSubmit = 'undefined' === typeof options.showSubmit ? true : !!options.showSubmit; /** * ### Feedback.onsubmit * * Options passed to `getValues` when the submit button is pressed * * @see Feedback.getValues */ if (!options.onsubmit) { this.onsubmit = { feedbackOnly: true, send: true, updateUI: true }; } else if ('object' === typeof options.onsubmit) { this.onsubmit = options.onsubmit; } else { throw new TypeError('Feedback constructor: onsubmit ' + 'must be string or object. Found: ' + options.onsubmit); } /** * ### Feedback._feedback * * Internal storage of the value of the feedback * * This value is used when the form has not been created yet */ this._feedback = options.feedback || null; /** * ### Feedback.attempts * * Invalid feedbacks tried */ this.attempts = []; /** * ### Feedback.timeInputBegin * * Time when feedback was inserted (first character, last attempt) */ this.timeInputBegin = null; /** * ### Feedback.feedbackForm * * The HTML form element containing the textarea */ this.feedbackForm = null; /** * ### Feedback.textareaElement * * The HTML textarea element containing the feedback */ this.textareaElement = null; /** * ### Feedback.charCounter * * The HTML span element containing the characters count */ this.charCounter = null; /** * ### Feedback.wordCounter * * The HTML span element containing the words count */ this.wordCounter = null; /** * ### Feedback.submitButton * * The HTML submit button */ this.submitButton = null; /** * ### Feedback.setMsg * * If TRUE, a set message is sent instead of a data msg * * Default: FALSE */ this.setMsg = !!options.setMsg || false; } // ## Feedback methods // TODO: move all initialization here from constructor. Feedback.prototype.init = function(options) { // Set the mainText, if any. if ('string' === typeof options.mainText) { this.mainText = options.mainText; } else if ('undefined' === typeof options.mainText) { this.mainText = this.getText('label'); } else { throw new TypeError('Feedback.init: options.mainText must ' + 'be string or undefined. Found: ' + options.mainText); } // Set the hint, if any. if ('string' === typeof options.hint || false === options.hint) { this.hint = options.hint; } else if ('undefined' !== typeof options.hint) { throw new TypeError('Feedback.init: options.hint must ' + 'be a string, false, or undefined. Found: ' + options.hint); } else { // Returns undefined if there are no constraints. this.hint = this.getText('autoHint'); } }; /** * ### Feedback.verifyFeedback * * Verify feedback and optionally marks attempt and updates interface * * @param {boolean} markAttempt Optional. If TRUE, the current feedback * is added to the attempts array (if too long, may be truncateed). * Default: TRUE * @param {boolean} updateUI Optional. If TRUE, the interface is updated. * Default: FALSE * * @return {boolean} TRUE, if the feedback is valid * * @see Feedback.getValues * @see Feedback.maxAttemptLength * @see getFeedback */ Feedback.prototype.verifyFeedback = function(markAttempt, updateUI) { var feedback, length, res; var submitButton, charCounter, wordCounter, tmp; var updateCharCount, updateCharColor, updateWordCount, updateWordColor; feedback = getFeedback.call(this); length = feedback ? feedback.length : 0; submitButton = this.submitButton; charCounter = this.charCounter; wordCounter = this.wordCounter; res = true; if (length < this.minChars) { res = false; tmp = this.minChars - length; updateCharCount = tmp + this.getText('counter', { chars: true, needed: true, len: tmp }); updateCharColor = colNeeded; } else if (this.maxChars && length > this.maxChars) { res = false; tmp = length - this.maxChars; updateCharCount = tmp + this.getText('counter', { chars: true, over: true, len: tmp }); updateCharColor = colOver; } else { tmp = this.maxChars ? this.maxChars - length : length; updateCharCount = tmp + this.getText('counter', { chars: true, len: tmp, justcount: !this.maxChars }); updateCharColor = colRemain; } if (wordCounter) { // kudos: https://css-tricks.com/build-word-counter-app/ // word count using \w metacharacter - // replacing this with .* to match anything between word // boundaries since it was not taking 'a' as a word. // this is a masterstroke - to count words with any number // of hyphens as one word // [-?(\w+)?]+ looks for hyphen and a word (we make // both optional with ?). + at the end makes it a repeated pattern // \b is word boundary metacharacter tmp = feedback ? feedback.match(/\b[-?(\w+)?]+\b/gi) : 0; length = tmp ? tmp.length : 0; if (length < this.minWords) { res = false; tmp = tmp = this.minWords - length; updateWordCount = tmp + this.getText('counter', { needed: true, len: tmp }); updateWordColor = colNeeded; } else if (this.maxWords && length > this.maxWords) { res = false; tmp = length - this.maxWords; updateWordCount = tmp + this.getText('counter', { over: true, len: tmp }); updateWordColor = colOver; } else { tmp = this.maxWords ? this.maxWords - length : length; updateWordCount = tmp + this.getText('counter', { len: tmp, justcount: !this.maxWords }); updateWordColor = colRemain; } } if (updateUI) { if (submitButton) submitButton.disabled = !res; if (charCounter) { charCounter.style.backgroundColor = updateCharColor; charCounter.innerHTML = updateCharCount; } if (wordCounter) { wordCounter.style.backgroundColor = updateWordColor; wordCounter.innerHTML = updateWordCount; } } if (!res && ('undefined' === typeof markAttempt || markAttempt)) { if (this.maxAttemptLength && length > this.maxAttemptLength) { feedback = feedback.substr(0, this.maxAttemptLength); } this.attempts.push(feedback); } return res; }; /** * ### Feedback.append * * Appends widget to this.bodyDiv */ Feedback.prototype.append = function() { var that; that = this; // this.feedbackForm = W.get('div', { className: 'feedback' }); this.feedbackForm = W.append('form', this.bodyDiv, { className: 'feedback-form' }); // MainText. if (this.mainText) { this.spanMainText = W.append('span', this.feedbackForm, { className: 'feedback-maintext', innerHTML: this.mainText }); } // Hint. if (this.hint) { W.append('span', this.spanMainText || this.feedbackForm, { className: 'feedback-hint', innerHTML: this.hint }); } this.textareaElement = W.append('textarea', this.feedbackForm, { className: 'form-control feedback-textarea', type: 'text', rows: this.rows }); if (this.showSubmit) { this.submitButton = W.append('input', this.feedbackForm, { className: 'btn btn-lg btn-primary', type: 'submit', value: this.getText('submit') }); // Add listeners. J.addEvent(this.feedbackForm, 'submit', function(event) { event.preventDefault(); that.getValues(that.onsubmit); }); } this.showCounters(); J.addEvent(this.feedbackForm, 'input', function() { if (that.isHighlighted()) that.unhighlight(); that.verifyFeedback(false, true); }); J.addEvent(this.feedbackForm, 'click', function() { if (that.isHighlighted()) that.unhighlight(); }); // Check it once at the beginning to initialize counter. this.verifyFeedback(false, true); }; /** * ### Feedback.setValues * * Set the value of the feedback * * @param {object} options Conf options. Values: * * - feedback: a string containing the desired feedback. * If not set, a random string will be set. * - verify: if TRUE, the method verifyFeedback is called * afterwards, updating the UI. Default: TRUE * - markAttempt: if TRUE, the verify attempt is added. Default: TRUE */ Feedback.prototype.setValues = function(options) { var feedback, maxChars, minChars, nWords, i; options = options || {}; if (!options.feedback) { minChars = this.minChars || 0; if (this.maxChars) maxChars = this.maxChars; else if (this.maxWords) maxChars = this.maxWords * 4; else if (minChars) maxChars = minChars + 80; else maxChars = 80; feedback = J.randomString(J.randomInt(minChars, maxChars), 'aA_1'); if (this.minWords) { nWords = this.minWords - feedback.split(' ').length; if (nWords > 0) { for (i = 0; i < nWords ; i++) { feedback += ' ' + i; } } } } else { feedback = options.feedback; } if (!this.textareaElement) this._feedback = feedback; else this.textareaElement.value = feedback; this.timeInputBegin = J.now(); if (options.verify !== false) { this.verifyFeedback(options.markAttempt, true); } }; /** * ### Feedback.getValues * * Returns the feedback and paradata * * @param {object} opts Optional. Configures the return value. * Available optionts: * * - feedbackOnly:If TRUE, returns just the feedback (default: FALSE), * - keepBreaks: If TRUE, returns a value where all line breaks are * substituted with HTML <br /> tags (default: FALSE) * - verify: If TRUE, check if the feedback is valid (default: TRUE), * - reset: If TRUTHY and the feedback is valid, then it resets * the feedback value before returning (default: FALSE), * - markAttempt: If TRUE, getting the value counts as an attempt * (default: TRUE), * - updateUI: If TRUE, the UI (form, input, button) is updated. * Default: FALSE. * - highlight: If TRUE, if feedback is not the valid, widget is * is highlighted. Default: (updateUI || FALSE). * - send: If TRUE, and the email is valid, then it sends * a data or set msg. Default: FALSE. * - sendAnyway: If TRUE, it sends a data or set msg regardless of * the validity of the email. Default: FALSE. * - say: same as send, but deprecated. * - sayAnyway: same as sendAnyway, but deprecated * * @return {string|object} The feedback, and optional paradata * * @see Feedback.sendValues * @see Feedback.verifyFeedback * @see getFeedback */ Feedback.prototype.getValues = function(opts) { var feedback, res; opts = opts || {}; if ('undefined' !== typeof opts.say) { console.log('***EmailForm.getValues: option say is deprecated, ' + ' use send.***'); opts.send = opts.say; } if ('undefined' !== typeof opts.sayAnyway) { console.log('***EmailForm.getValues: option sayAnyway is ' + 'deprecated, use sendAnyway.***'); opts.sendAnyway = opts.sayAnyway; } if ('undefined' === typeof opts.markAttempt) opts.markAttempt = true; if ('undefined' === typeof opts.highlight) opts.highlight = true; feedback = getFeedback.call(this); if (opts.keepBreaks) feedback = feedback.replace(/\n\r?/g, '<br />'); if (opts.verify !== false) res = this.verifyFeedback(opts.markAttempt, opts.updateUI); if (res === false && (opts.updateUI || opts.highlight)) this.highlight(); // Only value. if (!opts.feedbackOnly) { feedback = { timeBegin: this.timeInputBegin, feedback: feedback, attempts: this.attempts, valid: res }; if (opts.markAttempt) feedback.isCorrect = res; } // Send the message. if (feedback !== '' && ((opts.send && res) || opts.sendAnyway)) { this.sendValues({ values: feedback }); if (opts.updateUI) { this.submitButton.setAttribute('value', this.getText('sent')); this.submitButton.disabled = true; this.textareaElement.disabled = true; } } if (opts.reset) this.reset(); return feedback; }; /** * ### Feedback.sendValues * * Sends a DATA message with label 'feedback' with feedback and paradata * * @param {object} opts Optional. Options to pass to the `getValues` * method. Additional options: * * - values: actual values to send, instead of the return * value of `getValues` * - to: recipient of the message. Default: 'SERVER' * * @return {string|object} The feedback, and optional paradata * * @see Feedback.getValues */ Feedback.prototype.sendValues = function(opts) { var values; opts = opts || { feedbackOnly: true }; values = opts.values || this.getValues(opts); if (this.setMsg) { if ('string' === typeof values) values = { feedback: values }; node.set(values, opts.to || 'SERVER'); } else { node.say('feedback', opts.to || 'SERVER', values); } return values; }; /** * ### Feedback.highlight * * Highlights the feedback form * * @param {string} The style for the form border. Default: '1px solid red' * * @see Feedback.highlighted */ Feedback.prototype.highlight = function(border) { if (border && 'string' !== typeof border) { throw new TypeError('Feedback.highlight: border must be ' + 'string or undefined. Found: ' + border); } if (!this.isAppended() || this.highlighted === true) return; this.textareaElement.style.border = border || '3px solid red'; this.highlighted = true; this.emit('highlighted', border); }; /** * ### Feedback.unhighlight * * Removes highlight from the form * * @see Feedback.highlighted */ Feedback.prototype.unhighlight = function() { if (!this.isAppended() || this.highlighted !== true) return; this.textareaElement.style.border = ''; this.highlighted = false; this.emit('unhighlighted'); }; /** * ### Feedback.reset * * Resets feedback and collected paradata */ Feedback.prototype.reset = function() { this.attempts = []; this.timeInputBegin = null; this._feedback = null; if (this.textareaElement) this.textareaElement.value = ''; if (this.isHighlighted()) this.unhighlight(); }; /** * ### Feedback.disable * * Disables texarea and submit button (if present) */ Feedback.prototype.disable = function() { // TODO: This gets off when WaitScreen locks all inputs. // if (this.disabled === true) return; if (!this.textareaElement || this.textareaElement.disabled) return; this.disabled = true; if (this.submitElement) this.submitElement.disabled = true; this.textareaElement.disabled = true; this.emit('disabled'); }; /** * ### Feedback.enable * * Enables texarea and submit button (if present) * */ Feedback.prototype.enable = function() { // TODO: This gets off when WaitScreen locks all inputs. // if (this.disabled === false || !this.textareaElement) return; if (!this.textareaElement || !this.textareaElement.disabled) return; this.disabled = false; if (this.submitElement) this.submitElement.disabled = false; this.textareaElement.disabled = false; this.emit('enabled'); }; /** * ### Feedback.showCounters * * Shows the character counter * * If not existing before, it creates it. * * @see Feedback.charCounter */ Feedback.prototype.showCounters = function() { if (!this.charCounter) { if (this.minChars || this.maxChars) { this.charCounter = W.append('span', this.feedbackForm, { className: 'feedback-char-count badge', innerHTML: this.maxChars }); } } else { this.charCounter.style.display = ''; } if (!this.wordCounter) { if (this.minWords || this.maxWords) { this.wordCounter = W.append('span', this.feedbackForm, { className: 'feedback-char-count badge', innerHTML: this.maxWords }); if (this.charCounter) { this.wordCounter.style['margin-left'] = '10px'; } } } else { this.wordCounter.style.display = ''; } }; /** * ### Feedback.hideCounters * * Hides the character counter */ Feedback.prototype.hideCounters = function() { if (this.charCounter) this.charCounter.style.display = 'none'; if (this.wordCounter) this.wordCounter.style.display = 'none'; }; // ## Helper functions. /** * ### getFeedback * * Returns the value of the feedback textarea or in `_feedback` * * Must be invoked with right context * * @return {string|null} The value of the feedback, if any */ function getFeedback() { var out; out = this.textareaElement ? this.textareaElement.value : this._feedback; return out ? out.trim() : out; } })(node);