UNPKG

samagra-x-enketo-core

Version:

Extensible Enketo form engine

345 lines (289 loc) 11.7 kB
import $ from 'jquery'; import types from './types'; import event from './event'; import { getXPath } from './dom-utils'; /** * @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', 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 targets = this.getElements(); if ( targets.length === 1 && newVal.toString() !== curVal.toString() ) { const target = targets[ 0 ]; // first change the value so that it can be evaluated in XPath (validated) target.textContent = newVal.toString(); // 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.nodeName; 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.nodeName; 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 } ) ); // 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 };