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 };