UNPKG

@oat-sa/tao-core-sdk

Version:
745 lines (672 loc) 26.1 kB
/** * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; under version 2 * of the License (non-upgradable). * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * * Copyright (c) 2013-2022 Open Assessment Technologies SA (under the project TAO-PRODUCT); */ /** * @author Bertrand Chevrier <bertrand@taotesting.com> * @requires jquery * @requires lodash * @requires handlebars * @requires core/encoder/encoders */ import $ from 'jquery'; import _ from 'lodash'; import Handlebars from 'handlebars'; import Encoders from 'core/encoder/encoders'; import Filters from 'core/filter/filters'; /** * Get the value of a property defined by the path into the object * @param {Object} obj - the object to locate property into * @param {string} path - the property path * @returns {*} */ const locate = function locate(obj, path) { const nodes = path.split('.'); const size = nodes.length; let i = 1; let result; if (size >= 1) { result = obj[nodes[0]]; if (typeof result !== 'undefined') { for (i = 1; i < size; i++) { result = result[nodes[i]]; if (typeof result === 'undefined') { break; } } } } return result; }; /** * Set the value of a property defined by the path into the object * @param {Object} obj - the object to locate property into * @param {string} path - the property path * @param {string|boolean|number} value - the value to assign */ const update = function update(obj, path, value) { const nodes = path.split('.'); const size = nodes.length; let i; for (i = 0; i < size; i++) { if (i === size - 1) { obj[nodes[i]] = value; return; } else { if (!obj[nodes[i]]) { if (i + 1 < size && /^\d$/.test(nodes[i + 1])) { obj[nodes[i]] = []; } else { obj[nodes[i]] = {}; } } obj = obj[nodes[i]]; } } }; /** * Removes the property from the object * @param {Object} obj - the object to locate property into * @param {string} path - the property path */ const remove = function remove(obj, path) { const nodes = path.split('.'); const size = nodes.length; let i; for (i = 0; i < size; i++) { if (i === size - 1) { if (_.isArray(obj)) { obj.splice(parseInt(nodes[i], 10), 1); } else { delete obj[nodes[i]]; } return; } else { obj = obj[nodes[i]]; } } }; /** * Sort a property array in the object * regarding the ordered defined into the nodes (using the data-bind-index attribute). * @param {Object} obj - the object to locate property into * @param {string} path - the property path * @param {jQueryElement} $node - the element that contains the items * @param {Boolean} [retry=false] - if we are in fault tolerancy context, to prevent deep recursivity */ const order = function order(obj, path, $node, retry) { const values = locate(obj, path); let changed = false; if (_.isArray(values)) { $node.children('[data-bind-index]').each(function (position) { const $item = $(this); const index = parseInt($item.data('bind-index'), 10); if (values[index]) { values[index].index = position; changed = changed || position !== index; } else { //fault tolerancy in case of removal that do not trigger the right event if (!retry) { _.delay(function () { order(obj, path, $node, true); }, 100); } return false; } }); if (changed === true) { values.sort(function (a, b) { return a.index - b.index; }); } } }; /** * Synchronize indexes of a property array in the object * regarding the ordered defined into the nodes (using the data-bind-index attribute). * @param {Object} obj - the object to locate property into * @param {string} path - the property path * @param {jQueryElement} $node - the element that contains the items */ const resyncIndexes = function resyncIndexes(obj, path, $node) { const values = locate(obj, path); if (_.isArray(values)) { _.forEach(values, function (value, position) { values[position].index = position; if ($node) { $node .children('[data-bind-index]') .eq(position) .attr('data-bind-index', position.toString()) .data('bind-index', position.toString()); } }); } }; /** * For radio and checkbox, the element that listen for events is the group and not the single node. * It enables you to get the right element(s). * * @param {jQueryElement} $node * @param {jQueryElement} $container * @returns {jQueryElement} */ const toBind = function toBind($node, $container) { if ($node[0].type && $node[0].name) { if ($node[0].type === 'radio' || $node[0].type === 'checkbox') { return $(`[name='${$node[0].name}']`, $container); } } return $node; }; /** * Unbind event registered using <i>this._bind</i> function. * @param {jQueryElement} $node - the node to bind * @param {jQueryElement} $container - the node container * @param {String} eventName - the name of the event to bind * @private */ const _unbind = function _unbind($node, $container, eventName) { if ($node.length > 0) { const bounds = $._data($node[0], 'events'); if ( bounds && _(bounds[eventName]) .filter({ namespace: 'internalbinder' }) .size() > 0 ) { toBind($node, $container).off(`${eventName}.internalbinder`); } } }; /** * Bind wrapper to ensure the event is bound only once using a namespace * @param {jQueryElement} $node - the node to bind * @param {jQueryElement} $container - the node container * @param {String} eventName - the name of the event to bind * @param {Function} cb - a jQuery event handler */ const _bindOnce = function _bindOnce($node, $container, eventName, cb) { _unbind($node, $container, eventName); if ($node.length > 0) { const bounds = $._data($node[0], 'events'); if ( !bounds || _(bounds[eventName]) .filter({ namespace: 'internalbinder' }) .size() < 1 ) { toBind($node, $container).on(`${eventName}.internalbinder`, function (e, ...args) { if ($(this).is(e.target)) { cb(...args); } }); } } }; /** * Constructor, define the model and the DOM container to bind * @exports core/DataBinder * @constructs * @param {jQueryElement} $container * @param {Object} model * @param {Object} options - to be documented */ const DataBinder = function DataBinder($container, model, options) { const self = this; this.$container = $container; this.model = model || {}; this.encoders = _.clone(Encoders); this.filters = _.clone(Filters); if (options) { if (_.isPlainObject(options.encoders)) { _.forEach(options.encoders, function (encoder, name) { self.encoders.register(name, encoder.encode, encoder.decode); }); } if (_.isPlainObject(options.filters)) { _.forEach(options.filters, function (filter, name) { self.filters.register(name, filter); }); } this.templates = options.templates || {}; } }; /** * Assign value and listen for change on a particular node. * @memberOf DataBinder * @private * @param {jQueryElement} $node - the elements to bind * @param {string} path - the path to the model value to bind * @param {Object} model - the model bound * @param {boolean} [domFirst = false] - if the node content must be assigned to the model value first */ DataBinder.prototype._bindNode = function _bindNode($node, path, model, domFirst) { if (!$node.data('bound')) { if (domFirst === true || typeof locate(model, path) === "undefined") { update(model, path, this._getNodeValue($node)); } this._setNodeValue($node, locate(model, path)); this._listenUpdates($node, path, model); this._listenRemoves($node, path, model); $node.data('bound', path); } }; /** * Bind array value to a node. * @memberOf DataBinder * @private * @param {jQueryElement} $node - the elements to bind * @param {string} path - the path to the model value to bind * @param {Object} model - the model bound * @param {boolean} [domFirst = false] - if the node content must be assigned to the model value first */ DataBinder.prototype._bindArrayNode = function _bindArrayNode($node, path, model, domFirst) { const self = this; let template; let values; if (!$node.data('bound')) { values = locate(model, path); //the item content is either defined by an external template or as the node content if ($node.data('bind-tmpl')) { template = self.templates[$node.data('bind-tmpl')]; //fallback to inner template if (typeof template !== 'function' && $($node.data('bind-tmpl')).length > 0) { template = Handlebars.compile($($node.data('bind-tmpl')).html()); } } else { template = Handlebars.compile($node.html()); } if (!values || !_.isArray(values)) { //create the array in the model if not exists update(model, path, []); } else if ($node.data('bind-filter')) { //apply filtering values = this.filters.filter($node.data('bind-filter'), values); } $node.empty(); _.forEach(values, function (value, index) { value.index = index; //the model as an index property, used for reordering const $newNode = $(template(value).trim()); $newNode .appendTo($node) .filter(':first') .attr('data-bind-index', index); //we add the index to the 1st inserted node to keep it in sync //bind the content of the inserted nodes self.bind($newNode, self.model, `${path}.${index}.`, domFirst); //listen for removal on the item node self._listenRemoves($newNode, `${path}.${index}`, self.model); }); //listen for reordering and item addition on the list node self._listenUpdates($node, path, model); self._listenAdds($node, path, model); $node.data('bound', path); } }; /** * Assign value and listen for change on a particular node. * @memberOf DataBinder * @private * @param {jQueryElement} $node - the elements to bind * @param {string} path - the path to the model value to bind * @param {Object} model - the model bound * @param {boolean} [domFirst = false] - if the node content must be assigned to the model value first */ DataBinder.prototype._bindRmNode = function _bindRmNode($node, path, model, domFirst) { if (!$node.data('bound')) { this._listenUpdates($node, path, model); if (domFirst === true) { $node.trigger('change'); } $node.data('bound', path); } }; /** * Listen for updates on a particular node. (listening the 'change' event) * @memberOf DataBinder * @private * @param {jQueryElement} $node - the elements to bind * @param {string} path - the path to the model value to bind * @param {Object} model - the model bound * @fires DataBinder#update.binder * @fires DataBinder#change.binder */ DataBinder.prototype._listenUpdates = function _listenUpdates($node, path, model) { const self = this; _bindOnce($node, this.$container, 'change', function () { if ($node.is('[data-bind-each]')) { //sort the model, sync the indexes and rebind the content order(model, path, $node); resyncIndexes(model, path, $node); $node.data('bind-each', path); self._rebind($node); /** * The model has been sorted * @event DataBinder#order.binder * @param {Object} model - the up to date model */ self.$container.trigger('order.binder', [self.model]); } else if ($node.is('[data-bind-rm]')) { //remove the model element if the node value is true const value = self._getNodeValue($node); if (value === true) { remove(model, path); } /** * The model has been updated * @event DataBinder#update.binder * @param {Object} model - the up to date model */ self.$container.trigger('delete.binder', [self.model]); } else { //update the model with the node value update(model, path, self._getNodeValue($node)); //if we remove an element of an array, we need to resync indexes and bindings self._resyncIndexOnceRm($node, path); /** * The model has been updated * @event DataBinder#update.binder * @param {Object} model - the up to date model */ self.$container.trigger('update.binder', [self.model]); } /** * The model has changed (update, add or remove) * @event DataBinder#change.binder * @param {Object} model - the up to date model */ self.$container.trigger('change.binder', [self.model]); }); }; /** * Listen for node removal on a bound array. (listening the 'remove' event) * @memberOf DataBinder * @private * @param {jQueryElement} $node - the elements to bind * @param {string} path - the path to the model value to bind * @param {Object} model - the model bound * @fires DataBinder#delete.binder * @fires DataBinder#change.binder */ DataBinder.prototype._listenRemoves = function _listenRemoves($node, path, model) { const self = this; _bindOnce($node, this.$container, 'delete', function (undoable) { if (undoable === true) { //can be linked tp the ui/deleter self._resyncIndexOnceRm($node, path, undoable); $node.parent().one('deleted.deleter', function () { doRemoval(); }); if ($node.is('[data-bind-index]')) { $node.one('undo.deleter', function () { const $parentNode = $node.parent().closest('[data-bind-each]'); const parentPath = path.replace(/\.[0-9]+$/, ''); resyncIndexes(self.model, parentPath, $parentNode); //we need to rebind the model to the new paths const re = new RegExp(`${$parentNode.data('bind-each')}$`); // only in the end of the string self._rebind($parentNode, parentPath.replace(re, '')); }); } } else { doRemoval(); self._resyncIndexOnceRm($node, path); } function doRemoval() { remove(model, path); /** * An property of the model is removed * @event DataBinder#delete.binder * @param {Object} model - the up to date model */ self.$container.trigger('delete.binder', [self.model]).trigger('change.binder', [self.model]); } }); }; /** * Listen for node addition on a bound array. (listening the 'add' event) * @memberOf DataBinder * @private * @param {jQueryElement} $node - the elements to bind * @param {string} path - the path to the model value to bind * @fires DataBinder#add.binder * @fires DataBinder#change.binder */ DataBinder.prototype._listenAdds = function _listenAdds($node, path) { const self = this; _bindOnce($node, this.$container, 'add', function (content, data) { const size = $node.children('[data-bind-index]').length; $node .children() .not('[data-bind-index]') .each(function () { //got the inserted node const $newNode = $(this); const realPath = `${path}.${size}`; $newNode.attr('data-bind-index', size); if (data) { //if data is given through the event, we use it ti create the value //(if the same value is set through the dom, it will override it cf. domFirst) update(self.model, realPath, data); } //bind the node and it's content using the domFirst approach (to create the related model) self.bind($newNode, self.model, `${realPath}.`, true); self._listenRemoves($newNode, realPath, self.model); }); /** * The model contains a new property * @event DataBinder#add.binder * @param {Object} model - the up to date model */ self.$container.trigger('add.binder', [self.model]).trigger('change.binder', [self.model]); //rethrow on the node $node.trigger('add.binder', [content, data]); }); }; /** * Used to resynchronized the items of a `each` binding once one of them was removed * @memberOf DataBinder * @private * @param {jQueryElement} $node - the elements to bind * @param {string} path - the path to the model value to bind * @param {boolean} undoable - is node hidden temporary? */ DataBinder.prototype._resyncIndexOnceRm = function _resyncIndexOnceRm($node, path, undoable) { const self = this; if ($node.is('[data-bind-index]')) { const removedIndex = parseInt($node.data('bind-index'), 10); const $parentNode = $node.parent().closest('[data-bind-each]'); const parentPath = path.replace(/\.[0-9]+$/, ''); resyncIndexes(self.model, parentPath); if ($parentNode.children('[data-bind-index]').length - 1 !== removedIndex) { //if removed not the last element //we need to rebind after sync because the path are not valid anymore $parentNode .children('[data-bind-index]') .filter(`:gt(${removedIndex})`) .each(function () { const $item = $(this); const newIndex = parseInt($item.data('bind-index'), 10) - 1; //we also update the indexes $item.attr('data-bind-index', newIndex).data('bind-index', newIndex.toString()); }); } if (undoable) { // do not have 2 elements with the same index // will be changed on undo action $node.attr('data-bind-index', '-1' ).data('bind-index', '-1'); } //we need to rebind the model to the new paths const re = new RegExp(`${$parentNode.data('bind-each')}$`); // only in the end of the string self._rebind($parentNode, parentPath.replace(re, '')); } }; /** * Set the value into a node. * If an encoder is defined in the node, the encode method is called. * @memberOf DataBinder * @private * @param {jQueryElement} $node - the node that accept the value * @param {string|boolean|number} value - the value to set */ DataBinder.prototype._setNodeValue = function _setNodeValue($node, value) { const self = this; if (typeof value !== 'undefined') { //decode value if ($node.data('bind-encoder')) { value = this.encoders.encode($node.data('bind-encoder'), value); } //assign value if (_.includes(['INPUT', 'SELECT', 'TEXTAREA'], $node[0].nodeName)) { if ($node.is(":text, input[type='hidden'], textarea, select")) { $node.val(value).trigger('change'); } else if ($node.is(':radio, :checkbox')) { toBind($node, self.$container).each(function () { const $elt = $(this); $elt.prop('checked', $elt.val() === value); }); } } else if ($node.hasClass('button-group')) { $node.find('[data-bind-value]').each(function () { const $elt = $(this); if ($elt.data('bind-value').toString() === value) { $elt.addClass('active'); } else { $elt.removeClass('active'); } }); } else if ($node.data('bind-html') === true) { $node.html(value); } else { $node.text(value); } } }; /** * Set the value from a node. * If an encoder is defined in the node, the decode method is called. * @memberOf DataBinder * @private * @param {jQueryElement} $node - the node to get the value from * @returns {string|boolean|number} value - the value to set */ DataBinder.prototype._getNodeValue = function _getNodeValue($node) { const self = this; let value; if (_.includes(['INPUT', 'SELECT', 'TEXTAREA'], $node[0].nodeName)) { if ($node.is(":text, input[type='hidden'], textarea, select")) { value = $node.val(); } else if ($node.is(':radio, :checkbox')) { value = toBind($node, self.$container).filter(':checked').val(); } else if ($node.hasClass('button-group')) { $node.find('[data-bind-value]').each(function () { const $elt = $(this); if ($elt.hasClass('active')) { value = $elt.data('bind-value').toString(); } }); } } else if ($node.data('bind-html') === true) { value = $node.html(); } else { value = $node.text(); } //decode value if ($node.data('bind-encoder')) { value = this.encoders.decode($node.data('bind-encoder'), value); } return value; }; /** * Start the binding! * @memberOf DataBinder * @public * @param {jQueryElement} $elt - the container of the elements to bind (also itself boundable) * @param {Object} model - the model to bind * @param {string} [prefix = ''] - a prefix into the model path, used internally on rebound * @param {boolean} [domFirst = false] - if the node content must be assigned to the model value first */ DataBinder.prototype.bind = function bind($elt, model, prefix, domFirst) { const self = this; /** * Find dataAttrName * @param {JQeryElement} $boundElt * @param {string} dataAttrName * @param {string} binding */ const bindElements = function bindElements($boundElt, dataAttrName, binding) { const selector = `[data-${dataAttrName}]`; $boundElt .find(selector) .addBack() .filter(selector) .each(function () { const $node = $(this); const path = prefix + $node.data(dataAttrName); self[binding]($node, path, model, domFirst); }); }; $elt = $elt || this.$container; model = model || this.model; prefix = prefix || ''; domFirst = domFirst || false; //Array binding bindElements($elt, 'bind-each', '_bindArrayNode'); //Remove binding, if bound value === true, then path is removed from the model bindElements($elt, 'bind-rm', '_bindRmNode'); //simple binding (the container can also bound something in addition to children) bindElements($elt, 'bind', '_bindNode'); }; /** * Rebind, after ordering for instance. * @memberOf DataBinder * @private * @param {jQueryElement} $elt - the container of the elements to bind (also itself boundable) * @param {string} [prefix = ''] - a prefix into the model path, used internally on rebound */ DataBinder.prototype._rebind = function _rebind($elt, prefix) { const self = this; prefix = prefix || ''; if ($elt.is('[data-bind-each]')) { const path = prefix + $elt.data('bind-each'); const values = locate(self.model, path); _.forEach(values, function (value, index) { const $childNode = $elt.children(`[data-bind-index="${index}"]`); self._rebind($childNode, `${path}.${index}.`); self._listenRemoves($childNode, `${path}.${index}`, self.model); }); //listen for reordering and item addition on the list node if (typeof values !== 'undefined') { self._listenUpdates($elt, path, self.model); self._listenAdds($elt, path, self.model); } } else { $elt.find('[data-bind]').each(function () { const $node = $(this); const boundPath = prefix + $node.data('bind'); self._listenUpdates($node, boundPath, self.model); self._listenRemoves($node, boundPath, self.model); }); $elt.find('[data-bind-each]') .not(function () { return $(this).closest('[data-bind-index]').get(0) !== $elt.get(0); // only first level to have proper path }) .each(function () { self._rebind($(this), prefix); }); } }; //only the DataBinder is exposed export default DataBinder;