UNPKG

nodegame-widgets

Version:

Collections of useful and reusable javascript / HTML snippets for nodeGame

697 lines (628 loc) 21.6 kB
/** * # ChoiceManager * Copyright(c) 2021 Stefano Balietti * MIT Licensed * * Creates and manages a set of selectable choices forms (e.g., ChoiceTable). * * www.nodegame.org */ (function(node) { "use strict"; node.widgets.register('ChoiceManager', ChoiceManager); // ## Meta-data ChoiceManager.version = '1.4.1'; ChoiceManager.description = 'Groups together and manages a set of ' + 'survey forms (e.g., ChoiceTable).'; ChoiceManager.title = false; ChoiceManager.className = 'choicemanager'; // ## Dependencies ChoiceManager.dependencies = {}; /** * ## ChoiceManager constructor * * Creates a new instance of ChoiceManager */ function ChoiceManager() { /** * ### ChoiceManager.dl * * The clickable list containing all the forms */ this.dl = null; /** * ### ChoiceManager.mainText * * The main text introducing the choices * * @see ChoiceManager.spanMainText */ this.mainText = null; /** * ### ChoiceManager.spanMainText * * The span containing the main text */ this.spanMainText = null; /** * ### ChoiceManager.forms * * The array available forms * * @see ChoiceManager.formsById */ this.forms = null; /** * ### ChoiceManager.forms * * A map form id to form * * Note: if a form does not have an id, it will not be added here. * * @see ChoiceManager.forms */ this.formsById = null; /** * ### ChoiceManager.order * * The order of the forms as displayed (if shuffled) */ this.order = null; /** * ### ChoiceManager.shuffleForms * * TRUE, if forms have been shuffled */ this.shuffleForms = null; /** * ### ChoiceManager.group * * The name of the group where the list belongs, if any */ this.group = null; /** * ### ChoiceManager.groupOrder * * The order of the list within the group */ this.groupOrder = null; // TODO: rename in sharedOptions. /** * ### ChoiceManager.formsOptions * * An object containing options to be added to every form * * Options are added only if forms are specified as object literals, * and can be overriden by each individual form. */ this.formsOptions = { title: false, frame: false, storeRef: false }; /** * ### ChoiceManager.simplify * * If TRUE, it returns getValues() returns forms.values * * @see ChoiceManager.getValue */ this.simplify = null; /** * ### ChoiceManager.freeText * * If truthy, a textarea for free-text comment will be added * * If 'string', the text will be added inside the the textarea */ this.freeText = null; /** * ### ChoiceManager.textarea * * Textarea for free-text comment */ this.textarea = null; /** * ### ChoiceManager.required * * TRUE if widget should be checked upon node.done. */ this.required = null; } // ## ChoiceManager methods /** * ### ChoiceManager.init * * Initializes the instance * * Available options are: * * - className: the className of the list (string, array), or false * to have none. * - group: the name of the group (number or string), if any * - groupOrder: the order of the list in the group, if any * - mainText: a text to be displayed above the list * - shuffleForms: if TRUE, forms are shuffled before being added * to the list * - freeText: if TRUE, a textarea will be added under the list, * if 'string', the text will be added inside the the textarea * - forms: the forms to displayed, formatted as explained in * `ChoiceManager.setForms` * - formsOptions: a set of default options to add to every form * * @param {object} options Configuration options * * @see ChoiceManager.setForms */ ChoiceManager.prototype.init = function(options) { var tmp; // Option shuffleForms, default false. if ('undefined' === typeof options.shuffleForms) tmp = false; else tmp = !!options.shuffleForms; this.shuffleForms = tmp; // Set the group, if any. if ('string' === typeof options.group || 'number' === typeof options.group) { this.group = options.group; } else if ('undefined' !== typeof options.group) { throw new TypeError('ChoiceManager.init: options.group must ' + 'be string, number or undefined. Found: ' + options.group); } // Set the groupOrder, if any. if ('number' === typeof options.groupOrder) { this.groupOrder = options.groupOrder; } else if ('undefined' !== typeof options.group) { throw new TypeError('ChoiceManager.init: options.groupOrder must ' + 'be number or undefined. Found: ' + options.groupOrder); } // Set the mainText, if any. if ('string' === typeof options.mainText) { this.mainText = options.mainText; } else if ('undefined' !== typeof options.mainText) { throw new TypeError('ChoiceManager.init: options.mainText must ' + 'be string or undefined. Found: ' + options.mainText); } // formsOptions. if ('undefined' !== typeof options.formsOptions) { if ('object' !== typeof options.formsOptions) { throw new TypeError('ChoiceManager.init: options.formsOptions' + ' must be object or undefined. Found: ' + options.formsOptions); } if (options.formsOptions.hasOwnProperty('name')) { throw new Error('ChoiceManager.init: options.formsOptions ' + 'cannot contain property name. Found: ' + options.formsOptions); } this.formsOptions = J.mixin(this.formsOptions, options.formsOptions); } this.freeText = 'string' === typeof options.freeText ? options.freeText : !!options.freeText; if ('undefined' !== typeof options.required) { this.required = !!options.required; } // If TRUE, it returns getValues returns forms.values. this.simplify = !!options.simplify; // After all configuration options are evaluated, add forms. if ('undefined' !== typeof options.forms) this.setForms(options.forms); }; /** * ### ChoiceManager.setForms * * Sets the available forms * * Each form element can be: * * - an instantiated widget * - a "widget-like" element (`append` and `getValues` methods must exist) * - an object with the `name` of the widget and optional settings, e.g.: * * ``` * { * name: 'ChoiceTable', * mainText: 'Did you commit the crime?', * choices: [ 'Yes', 'No' ], * } * ``` * * @param {array|function} forms The array of forms or a function * returning an array of forms * * @see ChoiceManager.order * @see ChoiceManager.isWidget * @see ChoiceManager.shuffleForms * @see ChoiceManager.buildForms * @see ChoiceManager.buildTableAndForms */ ChoiceManager.prototype.setForms = function(forms) { var form, formsById, i, len, parsedForms, name; if ('function' === typeof forms) { parsedForms = forms.call(node.game); if (!J.isArray(parsedForms)) { throw new TypeError('ChoiceManager.setForms: forms is a ' + 'callback, but did not returned an ' + 'array. Found: ' + parsedForms); } } else if (J.isArray(forms)) { parsedForms = forms; } else { throw new TypeError('ChoiceManager.setForms: forms must be array ' + 'or function. Found: ' + forms); } len = parsedForms.length; if (!len) { throw new Error('ChoiceManager.setForms: forms is an empty array.'); } // Manual clone forms. formsById = {}; forms = new Array(len); i = -1; for ( ; ++i < len ; ) { form = parsedForms[i]; if (!node.widgets.isWidget(form)) { // TODO: smart checking form name. Maybe in Stager already? name = form.name || 'ChoiceTable'; // Add defaults. J.mixout(form, this.formsOptions); form = node.widgets.get(name, form); } if (form.id) { if (formsById[form.id]) { throw new Error('ChoiceManager.setForms: duplicated ' + 'form id: ' + form.id); } } else { form.id = form.className + '_' + i; } forms[i] = form; formsById[form.id] = forms[i]; if (form.required || form.requiredChoice || form.correctChoice) { // False is set manually, otherwise undefined. if (this.required === false) { throw new Error('ChoiceManager.setForms: required is ' + 'false, but form "' + form.id + '" has required truthy'); } this.required = true; } } // Assigned verified forms. this.forms = forms; this.formsById = formsById; // Save the order in which the choices will be added. this.order = J.seq(0, len-1); if (this.shuffleForms) this.order = J.shuffle(this.order); }; /** * ### ChoiceManager.buildDl * * Builds the list of all forms * * Must be called after forms have been set already. * * @see ChoiceManager.setForms * @see ChoiceManager.order */ ChoiceManager.prototype.buildDl = function() { var i, len, dt; var form; i = -1, len = this.forms.length; for ( ; ++i < len ; ) { dt = document.createElement('dt'); dt.className = 'question'; form = this.forms[this.order[i]]; node.widgets.append(form, dt); this.dl.appendChild(dt); } }; ChoiceManager.prototype.append = function() { // Id must be unique. if (W.getElementById(this.id)) { throw new Error('ChoiceManager.append: id is not ' + 'unique: ' + this.id); } // MainText. if (this.mainText) { this.spanMainText = document.createElement('span'); this.spanMainText.className = ChoiceManager.className + '-maintext'; this.spanMainText.innerHTML = this.mainText; // Append mainText. this.bodyDiv.appendChild(this.spanMainText); } // Dl. this.dl = document.createElement('dl'); this.buildDl(); // Append Dl. this.bodyDiv.appendChild(this.dl); // Creates a free-text textarea, possibly with placeholder text. if (this.freeText) { this.textarea = document.createElement('textarea'); if (this.id) this.textarea.id = this.id + '_text'; if ('string' === typeof this.freeText) { this.textarea.placeholder = this.freeText; } this.textarea.className = ChoiceManager.className + '-freetext'; // Append textarea. this.bodyDiv.appendChild(this.textarea); } }; /** * ### ChoiceManager.listeners * * Implements Widget.listeners * * Adds two listeners two disable/enable the widget on events: * INPUT_DISABLE, INPUT_ENABLE * * @see Widget.listeners */ ChoiceManager.prototype.listeners = function() { var that = this; node.on('INPUT_DISABLE', function() { that.disable(); }); node.on('INPUT_ENABLE', function() { that.enable(); }); }; /** * ### ChoiceManager.disable * * Disables all forms */ ChoiceManager.prototype.disable = function() { var i, len; if (this.disabled) return; i = -1, len = this.forms.length; for ( ; ++i < len ; ) { this.forms[i].disable(); } this.disabled = true; this.emit('disabled'); }; /** * ### ChoiceManager.enable * * Enables all forms */ ChoiceManager.prototype.enable = function() { var i, len; if (!this.disabled) return; i = -1, len = this.forms.length; for ( ; ++i < len ; ) { this.forms[i].enable(); } this.disabled = false; this.emit('enabled') }; /** * ### ChoiceManager.verifyChoice * * Compares the current choice/s with the correct one/s * * @param {boolean} markAttempt Optional. If TRUE, the value of * current choice is added to the attempts array. Default * * @return {boolean|null} TRUE if current choice is correct, * FALSE if it is not correct, or NULL if no correct choice * was set * * @see ChoiceManager.attempts * @see ChoiceManager.setCorrectChoice */ ChoiceManager.prototype.verifyChoice = function(markAttempt) { var i, len, obj, form; obj = { id: this.id, order: this.order, forms: {} }; // Mark attempt by default. markAttempt = 'undefined' === typeof markAttempt ? true : markAttempt; i = -1, len = this.forms.length; for ( ; ++i < len ; ) { form = this.forms[i]; obj.forms[form.id] = form.verifyChoice(markAttempt); if (!obj.form[form.id]) obj.fail = true; } return obj; }; /** * ### ChoiceManager.setCurrentChoice * * Marks a choice as current in each form * * If the item allows it, multiple choices can be set as current. * * @param {number|string} The choice to mark as current */ ChoiceManager.prototype.setCurrentChoice = function(choice) { var i, len; i = -1, len = this.forms[i].length; for ( ; ++i < len ; ) { this.forms[i].setCurrentChoice(choice); } }; /** * ### ChoiceManager.unsetCurrentChoice * * Deletes the value for currentChoice in each form * * If `ChoiceManager.selectMultiple` is set the * * @param {number|string} Optional. The choice to delete * when multiple selections are allowed */ ChoiceManager.prototype.unsetCurrentChoice = function(choice) { var i, len; i = -1, len = this.forms[i].length; for ( ; ++i < len ; ) { this.forms[i].unsetCurrentChoice(choice); } }; /** * ### ChoiceManager.highlight * * Highlights the choice table * * @param {string} The style for the dl's border. * Default '1px solid red' * * @see ChoiceManager.highlighted */ ChoiceManager.prototype.highlight = function(border) { if (border && 'string' !== typeof border) { throw new TypeError('ChoiceManager.highlight: border must be ' + 'string or undefined. Found: ' + border); } if (!this.dl || this.highlighted === true) return; this.dl.style.border = border || '3px solid red'; this.highlighted = true; this.emit('highlighted'); }; /** * ### ChoiceManager.unhighlight * * Removes highlight from the choice dl * * @see ChoiceManager.highlighted */ ChoiceManager.prototype.unhighlight = function() { if (!this.dl || this.highlighted !== true) return; this.dl.style.border = ''; this.highlighted = false; this.emit('unhighlighted'); }; /** * ### ChoiceManager.reset * * Resets all forms * * @param {object} opts Optional. Reset options to pass each form */ ChoiceManager.prototype.reset = function(opts) { var i, len; i = -1; len = this.forms.length; for ( ; ++i < len ; ) { this.forms[i].reset(opts); } }; /** * ### ChoiceManager.getValues * * Returns the values for current selection and other paradata * * Paradata that is not set or recorded will be omitted * * @param {object} opts Optional. Configures the return value. * Available optionts: * * - markAttempt: If TRUE, getting the value counts as an attempt * to find the correct answer. Default: TRUE. * - highlight: If TRUE, forms that do not have a correct value * will be highlighted. Default: TRUE. * * @return {object} Object containing the choice and paradata * * @see ChoiceManager.verifyChoice */ ChoiceManager.prototype.getValues = function(opts) { var obj, i, len, form, lastErrored, res; obj = { order: this.order, forms: {}, missValues: [] }; if ('undefined' !== typeof this.id) obj.id = this.id; opts = opts || {}; if ('undefined' === typeof opts.markAttempt) opts.markAttempt = true; if ('undefined' === typeof opts.highlight) opts.highlight = true; if (opts.markAttempt) obj.isCorrect = true; i = -1, len = this.forms.length; for ( ; ++i < len ; ) { form = this.forms[i]; // If it is hidden or disabled we do not do validation. if (form.isHidden() || form.isDisabled()) { res = form.getValues({ markAttempt: false, highlight: false }); if (res) obj.forms[form.id] = res; } else { // ContentBox does not return a value. res = form.getValues(opts); if (!res) continue; obj.forms[form.id] = res; // Backward compatible (requiredChoice). if ((form.required || form.requiredChoice) && (obj.forms[form.id].choice === null || (form.selectMultiple && !obj.forms[form.id].choice.length))) { obj.missValues.push(form.id); lastErrored = form; } if (opts.markAttempt && obj.forms[form.id].isCorrect === false) { // obj.isCorrect = false; lastErrored = form; } } } if (lastErrored) { if (opts.highlight && 'function' === typeof lastErrored.bodyDiv.scrollIntoView) { lastErrored.bodyDiv.scrollIntoView({ behavior: 'smooth' }); } obj._scrolledIntoView = true; obj.isCorrect = false; // Adjust frame heights because of error msgs. // TODO: error msgs should not change the height. W.adjustFrameHeight(); } // if (obj.missValues.length) obj.isCorrect = false; if (this.textarea) obj.freetext = this.textarea.value; // Simplify everything, if requested. if (opts.simplify || this.simplify) { res = obj; obj = obj.forms; if (res.isCorrect === false) obj.isCorrect = false; if (res.freetext) obj.freetext = res.freetext; } return obj; }; /** * ### ChoiceManager.setValues * * Sets values for forms in manager as specified by the options * * @param {object} options Optional. Options specifying how to set * the values. If no parameter is specified, random values will * be set. */ ChoiceManager.prototype.setValues = function(opts) { var i, len; if (!this.forms || !this.forms.length) { throw new Error('ChoiceManager.setValues: no forms found.'); } opts = opts || {}; i = -1, len = this.forms.length; for ( ; ++i < len ; ) { this.forms[i].setValues(opts); } // Make a random comment. if (this.textarea) this.textarea.value = J.randomString(100, '!Aa0'); }; // ## Helper methods. })(node);