UNPKG

enketo-core

Version:

Extensible Enketo form engine

424 lines (367 loc) 12.7 kB
import $ from 'jquery'; import types from './types'; import event from './event'; import { getXPath } from './dom-utils'; import { isNodeRelevant, setNonRelevantValue } from './relevant'; /** * @typedef NodesetFilter * @property {boolean} onlyLeaf * @property {boolean} noEmpty */ /** * Class dealing with nodes and nodesets of the XML instance * * @class * @param {string} [selector] - SimpleXPath or jQuery selector * @param {number} [index] - The index of the target node with that selector * @param {NodesetFilter} [filter] - Filter object for the result nodeset * @param {FormModel} model - Instance of FormModel */ const Nodeset = function (selector, index, filter, model) { const defaultSelector = model.hasInstance ? '/model/instance[1]//*' : '//*'; this.model = model; this.originalSelector = selector; this.selector = typeof selector === 'string' && selector.length > 0 ? selector : defaultSelector; filter = typeof filter !== 'undefined' && filter !== null ? filter : {}; this.filter = filter; this.filter.onlyLeaf = typeof filter.onlyLeaf !== 'undefined' ? filter.onlyLeaf : false; this.filter.noEmpty = typeof filter.noEmpty !== 'undefined' ? filter.noEmpty : false; this.index = index; }; /** * @return {Element} Single node */ Nodeset.prototype.getElement = function () { return this.getElements()[0]; }; /** * @return {Array<Element>} List of nodes */ Nodeset.prototype.getElements = function () { let nodes; let /** @type {string} */ val; // cache evaluation result if (!this._nodes) { this._nodes = this.model.evaluate( this.selector, 'nodes-ordered', null, null, true ); // noEmpty automatically excludes non-leaf nodes if (this.filter.noEmpty === true) { this._nodes = this._nodes.filter((node) => { val = node.textContent; return node.children.length === 0 && val.trim().length > 0; }); } // this may still contain empty leaf nodes else if (this.filter.onlyLeaf === true) { this._nodes = this._nodes.filter( (node) => node.children.length === 0 ); } } nodes = this._nodes; if (typeof this.index !== 'undefined' && this.index !== null) { nodes = typeof nodes[this.index] === 'undefined' ? [] : [nodes[this.index]]; } return nodes; }; /** * Sets the index of the Nodeset instance * * @param {number} [index] - The 0-based index */ Nodeset.prototype.setIndex = function (index) { this.index = index; }; /** * Sets data node values. * * @param {(string|Array<string>)} [newVals] - The new value of the node. * @param {string} [xmlDataType] - XML data type of the node * * @return {null|UpdatedDataNodes} `null` is returned when the node is not found or multiple nodes were selected, * otherwise an object with update information is returned. */ Nodeset.prototype.setVal = function (newVals, xmlDataType) { let /** @type {string} */ newVal; let updated; let customData; const curVal = this.getVal(); if (typeof newVals !== 'undefined' && newVals !== null) { newVal = Array.isArray(newVals) ? newVals.join(' ') : newVals.toString(); } else { newVal = ''; } newVal = this.convert(newVal, xmlDataType); const strVal = String(newVal); const targets = this.getElements(); if (targets.length === 1 && strVal !== curVal.toString()) { const target = targets[0]; // First change the value so that it can be evaluated in XPath (validated). if (isNodeRelevant(target)) { target.textContent = strVal; } else { setNonRelevantValue(target, strVal); } // then return validation result updated = this.getClosestRepeat(); updated.nodes = [target.nodeName]; customData = this.model.getUpdateEventData(target, xmlDataType); updated = customData ? $.extend({}, updated, customData) : updated; this.model.events.dispatchEvent(event.DataUpdate(updated)); // add type="file" attribute for file references if (xmlDataType === 'binary') { if (newVal.length > 0) { target.setAttribute('type', 'file'); // The src attribute if for default binary values (added by enketo-transformer) // As soon as the value changes this attribute can be removed to clean up. target.removeAttribute('src'); } else { target.removeAttribute('type'); } } return updated; } if (targets.length > 1) { console.error( 'nodeset.setVal expected nodeset with one node, but received multiple' ); return null; } if (targets.length === 0) { console.warn( `Data node: ${this.selector} with null-based index: ${this.index} not found. Ignored.` ); return null; } return null; }; /** * Obtains the data value of the first node. * * @return {string|undefined} data value of first node or `undefined` if zero nodes */ Nodeset.prototype.getVal = function () { const nodes = this.getElements(); return nodes.length ? nodes[0].textContent : undefined; }; /** * Note: If repeats have not been cloned yet, they are not considered a repeat by this function * * @return {{repeatPath: string, repeatIndex: number}|{}} Empty object for nothing found */ Nodeset.prototype.getClosestRepeat = function () { let el = this.getElement(); let { nodeName } = el; while ( nodeName && nodeName !== 'instance' && !( el.nextElementSibling && el.nextElementSibling.nodeName === nodeName ) && !( el.previousElementSibling && el.previousElementSibling.nodeName === nodeName ) ) { el = el.parentElement; nodeName = el ? el.nodeName : null; } return !nodeName || nodeName === 'instance' ? {} : { repeatPath: getXPath(el, 'instance'), repeatIndex: this.model.determineIndex(el), }; }; /** * Remove a repeat node */ Nodeset.prototype.remove = function () { const dataNode = this.getElement(); if (dataNode) { const { nodeName } = dataNode; const repeatPath = getXPath(dataNode, 'instance'); let repeatIndex = this.model.determineIndex(dataNode); const removalEventData = this.model.getRemovalEventData(dataNode); if (!this.model.templates[repeatPath]) { // This allows the model itseldataNodeout requiring the controller to call .extractFakeTemplates() // to extract non-jr:templates by assuming that node.remove() would only called for a repeat. this.model.extractFakeTemplates([repeatPath]); } // warning: jQuery.next() to be avoided to support dots in the nodename let nextNode = dataNode.nextElementSibling; dataNode.remove(); this._nodes = null; // For internal use this.model.events.dispatchEvent( event.DataUpdate({ nodes: null, repeatPath, repeatIndex, removed: true, // Introduced to handle relevance on model nodes with no form controls (calculates) }) ); // For all next sibling repeats to update formulas that use e.g. position(..) // For internal use while (nextNode && nextNode.nodeName === nodeName) { nextNode = nextNode.nextElementSibling; this.model.events.dispatchEvent( event.DataUpdate({ nodes: null, repeatPath, repeatIndex: repeatIndex++, }) ); } // For external use, if required with custom data. this.model.events.dispatchEvent(event.Removed(removalEventData)); } else { console.error( `could not find node ${this.selector} with index ${this.index} to remove ` ); } }; /** * Convert a value to a specified data type (though always stringified) * * @param {string} [x] - Value to convert * @param {string} [xmlDataType] - XML data type * @return {string} - String representation of converted value */ Nodeset.prototype.convert = (x, xmlDataType) => { if (x.toString() === '') { return x; } if ( typeof xmlDataType !== 'undefined' && xmlDataType !== null && typeof types[xmlDataType.toLowerCase()] !== 'undefined' && typeof types[xmlDataType.toLowerCase()].convert !== 'undefined' ) { return types[xmlDataType.toLowerCase()].convert(x); } return x; }; /** * @param {string} constraintExpr - The XPath expression * @param {string} requiredExpr - The XPath expression * @param {string} xmlDataType - XML data type * @return {Promise} promise that resolves with a ValidateInputResolution object */ Nodeset.prototype.validate = function ( constraintExpr, requiredExpr, xmlDataType ) { const that = this; const result = {}; // Avoid checking constraint if required is invalid return this.validateRequired(requiredExpr) .then((passed) => { result.requiredValid = passed; return passed === false ? null : that.validateConstraintAndType(constraintExpr, xmlDataType); }) .then((passed) => { result.constraintValid = passed; return result; }); }; /** * Validate a value with an XPath Expression and /or xml data type * * @param {string} [expr] - The XPath expression * @param {string} [xmlDataType] - XML data type * @return {Promise} wrapping a boolean indicating if the value is valid or not; error also indicates invalid field, or problem validating it */ Nodeset.prototype.validateConstraintAndType = function (expr, xmlDataType) { const that = this; let value; if ( !xmlDataType || typeof types[xmlDataType.toLowerCase()] === 'undefined' ) { xmlDataType = 'string'; } // This one weird trick results in a small validation performance increase. // Do not obtain *the value* if the expr is empty and data type is string, select, select1, binary knowing that this will always return true. if ( !expr && (xmlDataType === 'string' || xmlDataType === 'select' || xmlDataType === 'select1' || xmlDataType === 'binary') ) { return Promise.resolve(true); } value = that.getVal(); if (value.toString() === '') { return Promise.resolve(true); } return Promise.resolve() .then(() => types[xmlDataType.toLowerCase()].validate(value)) .then((typeValid) => { if (!typeValid) { return false; } const exprValid = expr ? that.model.evaluate( expr, 'boolean', that.originalSelector, that.index ) : true; return exprValid; }); }; // TODO: rename to isTrue? /** * @param {string} [expr] - The XPath expression * @return {boolean} Whether node is required */ Nodeset.prototype.isRequired = function (expr) { return !expr || expr.trim() === 'false()' ? false : expr.trim() === 'true()' || this.model.evaluate( expr, 'boolean', this.originalSelector, this.index ); }; /** * Validates if requiredness is fulfilled. * * @param {string} [expr] - The XPath expression * @return {Promise<boolean>} Promise that resolves with a boolean */ Nodeset.prototype.validateRequired = function (expr) { const that = this; // if the node has a value or there is no required expression if (!expr || this.getVal()) { return Promise.resolve(true); } // if the node does not have a value and there is a required expression return Promise.resolve().then( () => // if the expression evaluates to true, the field is required, and the function returns false. !that.isRequired(expr) ); }; export { Nodeset };