UNPKG

samagra-x-enketo-core

Version:

Extensible Enketo form engine

1,194 lines (1,050 loc) 45.3 kB
import { FormModel } from './form-model'; import $ from 'jquery'; import { parseFunctionFromExpression, stripQuotes, getFilename, joinPath } from './utils'; import { getXPath, getChild, closestAncestorUntil, getSiblingElement } from './dom-utils'; import { t } from 'enketo/translator'; import config from 'enketo/config'; import inputHelper from './input'; import repeatModule from './repeat'; import tocModule from './toc'; import pageModule from './page'; import relevantModule from './relevant'; import itemsetModule from './itemset'; import progressModule from './progress'; import widgetModule from './widgets-controller'; import languageModule from './language'; import preloadModule from './preload'; import outputModule from './output'; import calculationModule from './calculate'; import requiredModule from './required'; import maskModule from './mask'; import readonlyModule from './readonly'; import FormLogicError from './form-logic-error'; import events from './event'; import './plugins'; import './extend'; /** * Class: Form * * Most methods are prototype method to facilitate customizations outside of enketo-core. * * @param {Element} formEl - HTML form element (a product of Enketo Transformer after transforming a valid ODK XForm) * @param {FormDataObj} data - Data object containing XML model, (partial) XML instance-to-load, external data and flag about whether instance-to-load has already been submitted before. * @param {object} [options] - form options * @param {boolean} [options.printRelevantOnly] - If `printRelevantOnly` is set to `true` or not set at all, printing the form only includes what is visible, ie. all the groups and questions that do not have a `relevant` expression or for which the expression evaluates to `true`. * @param {string} [options.language] - Overrides the default languages rules of the XForm itself. Pass any valid and present-in-the-form IANA subtag string, e.g. `ar`. * @class */ function Form( formEl, data, options ) { const $form = $( formEl ); if ( typeof formEl === 'string' ) { console.deprecate( 'Form instantiation using a selector', 'a HTML <form> element' ); formEl = $form[ 0 ]; } this.nonRepeats = {}; this.all = {}; this.options = typeof options !== 'object' ? {} : options; this.view = { $: $form, html: formEl, clone: formEl.cloneNode( true ) }; this.model = new FormModel( data ); this.repeatsPresent = !!this.view.html.querySelector( '.or-repeat' ); this.widgetsInitialized = false; this.repeatsInitialized = false; this.pageNavigationBlocked = false; this.initialized = false; } /** * Getter and setter functions */ Form.prototype = { /** * @type {Array} */ evaluationCascadeAdditions: [], /** * @type {Array} */ get evaluationCascade() { return [ this.calc.update.bind( this.calc ), this.repeats.countUpdate.bind( this.repeats ), this.relevant.update.bind( this.relevant ), this.output.update.bind( this.output ), this.itemset.update.bind( this.itemset ), this.required.update.bind( this.required ), this.readonly.update.bind( this.readonly ), this.validationUpdate ].concat( this.evaluationCascadeAdditions ); }, /** * @type {string} */ get recordName() { return this.view.$.attr( 'name' ); }, set recordName( name ) { this.view.$.attr( 'name', name ); }, /** * @type {boolean} */ get editStatus() { return this.view.html.dataset.edited === 'true'; }, set editStatus( status ) { // only trigger edit event once if ( status && status !== this.editStatus ) { this.view.html.dispatchEvent( events.Edited() ); } this.view.html.dataset.edited = status; }, /** * @type {string} */ get surveyName() { return this.view.$.find( '#form-title' ).text(); }, /** * @type {string} */ get instanceID() { return this.model.instanceID; }, /** * @type {string} */ get deprecatedID() { return this.model.deprecatedID; }, /** * @type {string} */ get instanceName() { return this.model.instanceName; }, /** * @type {string} */ get version() { return this.model.version; }, /** * @type {string} */ get encryptionKey() { return this.view.$.data( 'base64rsapublickey' ); }, /** * @type {string} */ get action() { return this.view.$.attr( 'action' ); }, /** * @type {string} */ get method() { return this.view.$.attr( 'method' ); }, /** * @type {string} */ get id() { return this.view.html.dataset.formId; }, /** * To facilitate forks that support multiple constraints per question * * @type {Array<string>} */ get constraintClassesInvalid() { return [ 'invalid-constraint' ]; }, /** * To facilitate forks that support multiple constraints per question * * @type {Array<string>} */ get constraintAttributes() { return [ 'data-constraint' ]; }, /** * @type {Array<string>} */ get languages() { return this.langs.languagesUsed; }, /** * @type {string} */ get currentLanguage() { return this.langs.currentLanguage; } }; /** * Returns a module and adds the form property to it. * * @param {object} module - Enketo Core module * @return {object} updated module */ Form.prototype.addModule = function( module ) { return Object.create( module, { form: { value: this } } ); }; /** * Function: init * * Initializes the Form instance (XML Model and HTML View). * * @return {Array<string>} List of initialization errors. */ Form.prototype.init = function() { let loadErrors = []; const that = this; this.toc = this.addModule( tocModule ); this.pages = this.addModule( pageModule ); this.langs = this.addModule( languageModule ); this.progress = this.addModule( progressModule ); this.widgets = this.addModule( widgetModule ); this.preloads = this.addModule( preloadModule ); this.relevant = this.addModule( relevantModule ); this.repeats = this.addModule( repeatModule ); this.input = this.addModule( inputHelper ); this.output = this.addModule( outputModule ); this.itemset = this.addModule( itemsetModule ); this.calc = this.addModule( calculationModule ); this.required = this.addModule( requiredModule ); this.mask = this.addModule( maskModule ); this.readonly = this.addModule( readonlyModule ); // Handle odk-instance-first-load event this.model.events.addEventListener( events.InstanceFirstLoad().type, event => { this.calc.performAction( 'setvalue', event ); this.calc.performAction( 'setgeopoint', event ); } ); // Handle odk-new-repeat event before initializing repeats this.view.html.addEventListener( events.NewRepeat().type, event => { this.calc.performAction( 'setvalue', event ); this.calc.performAction( 'setgeopoint', event ); } ); // Handle xforms-value-changed this.view.html.addEventListener( events.XFormsValueChanged().type, event => { this.calc.performAction( 'setvalue', event ); this.calc.performAction( 'setgeopoint', event ); } ); // Before initializing form view and model, passthrough some model events externally // Because of instance-first-load actions, this should be done before the model is initialized. This is important for custom // applications that submit each individual value separately (opposed to a full XML model at the end). this.model.events.addEventListener( events.DataUpdate().type, event => { that.view.html.dispatchEvent( events.DataUpdate( event.detail ) ); } ); // This probably does not need to be before model.init(); this.model.events.addEventListener( events.Removed().type, event => { that.view.html.dispatchEvent( events.Removed( event.detail ) ); } ); loadErrors = loadErrors.concat( this.model.init() ); if ( typeof this.model === 'undefined' || !( this.model instanceof FormModel ) ) { loadErrors.push( 'Form could not be initialized without a model.' ); return loadErrors; } try { this.preloads.init(); // before widgets.init (as instanceID used in offlineFilepicker widget) // store the current instanceID as data on the form element so it can be easily accessed by e.g. widgets this.view.$.data( { instanceID: this.model.instanceID } ); // before calc.update! this.grosslyViolateStandardComplianceByIgnoringCertainCalcs(); // before repeats.init to make sure the jr:repeat-count calculation has been evaluated this.calc.update(); // before itemset.update this.langs.init( this.options.language ); // before repeats.init so that template contains role="page" when applicable this.pages.init(); // after radio button data-name setting (now done in XLST) // Set temporary event handler to ensure calculations in newly added repeats are run for the first time const tempHandlerAddRepeat = event => this.calc.update( event.detail ); const tempHandlerRemoveRepeat = () => this.all = {}; this.view.html.addEventListener( events.AddRepeat().type, tempHandlerAddRepeat ); this.view.html.addEventListener( events.RemoveRepeat().type, tempHandlerRemoveRepeat ); this.repeatsInitialized = true; this.repeats.init(); this.view.html.removeEventListener( events.AddRepeat().type, tempHandlerAddRepeat ); this.view.html.removeEventListener( events.RemoveRepeat().type, tempHandlerRemoveRepeat ); // after repeats.init, but before itemset.update this.output.update(); // after repeats.init this.itemset.update(); // after repeats.init this.setAllVals(); this.readonly.update(); // after setAllVals(); // after setAllVals, after repeats.init this.options.input = this.input; this.options.pathToAbsolute = this.pathToAbsolute.bind( this ); this.options.evaluate = this.model.evaluate.bind( this.model ); this.options.getModelValue = this.getModelValue.bind( this ); this.widgetsInitialized = this.widgets.init( null, this.options ); // after widgets.init(), and after repeats.init(), and after pages.init() this.relevant.update(); // after widgets init to make sure widget handlers are called before // after loading existing instance to not trigger an 'edit' event this.setEventHandlers(); // Update field calculations again to make sure that dependent // field values are calculated this.calc.update(); this.required.update(); this.mask.init(); this.editStatus = false; if ( this.options.printRelevantOnly !== false ) { this.view.$.addClass( 'print-relevant-only' ); } setTimeout( () => { that.progress.update(); }, 0 ); this.initialized = true; return loadErrors; } catch ( e ) { console.error( e ); loadErrors.push( `${e.name}: ${e.message}` ); } document.querySelector( 'body' ).scrollIntoView(); return loadErrors; }; /** * @param {string} xpath - simple path to question * @return {Array<string>} A list of errors originated from `goToTarget`. Empty if everything went fine. */ Form.prototype.goTo = function( xpath ) { const errors = []; if ( !this.goToTarget( this.getGoToTarget( xpath ) ) ) { errors.push( t( 'alert.gotonotfound.msg', { path: xpath.substring( xpath.lastIndexOf( '/' ) + 1 ) } ) ); } return errors; }; /** * Obtains a string of primary instance. * * @param {{include: boolean}} [include] - Optional object items to exclude if false * @return {string} XML string of primary instance */ Form.prototype.getDataStr = function( include = {} ) { // By default everything is included if ( include.irrelevant === false ) { return this.getDataStrWithoutIrrelevantNodes(); } return this.model.getStr(); }; /** * Restores HTML form to pre-initialized state. It is meant to be called before re-initializing with * new Form ( .....) and form.init() * For this reason, it does not fix event handler, $form, formView.$ etc.! * It also does not affect the XML instance! * * @return {Element} the new form element */ Form.prototype.resetView = function() { //form language selector was moved outside of <form> so has to be separately removed if ( this.langs.formLanguages ) { this.langs.formLanguages.remove(); } this.view.html.replaceWith( this.view.clone ); return document.querySelector( 'form.or' ); }; /** * Implements jr:choice-name * TODO: this needs to work for all expressions (relevants, constraints), now it only works for calculated items * Ideally this belongs in the form Model, but unfortunately it needs access to the view * * @param {string} expr - XPath expression * @param {string} resTypeStr - type of result * @param {string} context - context path * @param {number} index - index of context * @param {boolean} tryNative - whether to try the native evaluator, i.e. if there is no risk it would create an incorrect result such as with date comparisons * @return {string} updated expression */ Form.prototype.replaceChoiceNameFn = function( expr, resTypeStr, context, index, tryNative ){ const choiceNames = parseFunctionFromExpression( expr, 'jr:choice-name' ); choiceNames.forEach( choiceName => { const params = choiceName[ 1 ]; if ( params.length === 2 ) { let label = ''; const value = this.model.evaluate( params[ 0 ], resTypeStr, context, index, tryNative ); let name = stripQuotes( params[ 1 ] ).trim(); name = name.startsWith( '/' ) ? name : joinPath( context, name ); const inputs = [ ...this.view.html.querySelectorAll( `[name="${name}"], [data-name="${name}"]` ) ]; const nodeName = inputs.length ? inputs[0].nodeName.toLowerCase() : null; if ( !value || !inputs.length ) { label = ''; } else if ( nodeName === 'select' ) { const found = inputs.find( input => input.querySelector( `[value="${value}"]` ) ); label = found ? found.querySelector( `[value="${value}"]` ).textContent : ''; } else if ( nodeName === 'input' ) { const list = inputs[0].getAttribute( 'list' ); if ( !list ){ const found = inputs.find( input => input.getAttribute( 'value' ) === value ); const firstSiblingLabelEl = found ? getSiblingElement( found, '.option-label.active' ) : []; label = firstSiblingLabelEl ? firstSiblingLabelEl.textContent : ''; } else { const firstSiblingListEl = getSiblingElement( inputs[0], `datalist#${CSS.escape( list )}` ); if ( firstSiblingListEl ){ const optionEl = firstSiblingListEl.querySelector( `[data-value="${value}"]` ); label = optionEl ? optionEl.getAttribute( 'value' ) : ''; } } } expr = expr.replace( choiceName[ 0 ], `"${label}"` ); } else { throw new FormLogicError( `jr:choice-name function has incorrect number of parameters: ${choiceName[ 0 ]}` ); } } ); return expr; }; /** * Uses current state of model to set all the values in the form. * Since not all data nodes with a value have a corresponding input element, * we cycle through the HTML form elements and check for each form element whether data is available. * * @param {jQuery} $group - group of elements for which form controls should be updated (with current model values) * @param {number} groupIndex - index of the group */ Form.prototype.setAllVals = function( $group, groupIndex ) { const that = this; const selector = ( $group && $group.attr( 'name' ) ) ? $group.attr( 'name' ) : null; groupIndex = ( typeof groupIndex !== 'undefined' ) ? groupIndex : null; this.model.node( selector, groupIndex ).getElements() .reduce( ( nodes, current ) => { const newNodes = [ ...current.querySelectorAll( '*' ) ].filter( ( n ) => n.children.length === 0 && n.textContent ); return nodes.concat( newNodes ); }, [] ) .forEach( element => { try { var value = element.textContent; var name = getXPath( element, 'instance' ); const index = that.model.node( name ).getElements().indexOf( element ); const control = that.input.find( name, index ); if ( control ) { that.input.setVal( control, value, null ); if ( that.input.getXmlType( control ) === 'binary' && value.startsWith( 'jr://' ) && element.getAttribute( 'src' ) ) { control.setAttribute( 'data-loaded-url', element.getAttribute( 'src' ) ); } } } catch ( e ) { console.error( e ); // TODO: Test if this correctly adds to loadErrors //loadErrors.push( 'Could not load input field value with name: ' + name + ' and value: ' + value ); throw new Error( `Could not load input field value with name: ${name} and value: ${value}` ); } } ); }; /** * @param {jQuery} $control - HTML form control * @return {string|undefined} Value */ Form.prototype.getModelValue = function( $control ) { const control = $control[ 0 ]; const path = this.input.getName( control ); const index = this.input.getIndex( control ); return this.model.node( path, index ).getVal(); }; /** * Finds nodes that have attributes with XPath expressions that refer to particular XML elements. * * @param {string} attr - The attribute name to search for * @param {string} [filter] - The optional filter to append to each selector * @param {UpdatedDataNodes} updated - object that contains information on updated nodes * @return {jQuery} - A jQuery collection of elements */ Form.prototype.getRelatedNodes = function( attr, filter, updated ) { let repeatControls = null; let controls; updated = updated || {}; filter = filter || ''; // The collection of non-repeat inputs, calculations and groups is cached (unchangeable) if ( !this.nonRepeats[ attr ] ) { controls = [ ...this.view.html.querySelectorAll( `:not(.or-repeat-info)[${attr}]` ) ] .filter( el => !el.closest( '.or-repeat' ) ); this.nonRepeats[ attr ] = this.filterRadioCheckSiblings( controls ); } // If the updated node is inside a repeat (and there are multiple repeats present) if ( typeof updated.repeatPath !== 'undefined' && updated.repeatIndex >= 0 ) { const repeatEl = [ ...this.view.html.querySelectorAll( `.or-repeat[name="${updated.repeatPath}"]` ) ][ updated.repeatIndex ]; controls = repeatEl ? [ ...repeatEl.querySelectorAll( `[${attr}]` ) ] : []; repeatControls = this.filterRadioCheckSiblings( controls ); } // If a new repeat was created, update the cached collection of all form controls with that attribute // If a repeat was deleted ( update.repeatPath && !updated.cloned), rebuild cache if ( !this.all[ attr ] || ( updated.repeatPath && !updated.cloned ) ) { // (re)build the cache // However, if repeats have not been initialized exclude nodes inside a repeat until the first repeat has been added during repeat initialization. // The default view repeat will be removed during initialization (and stored as template), before it is re-added, if necessary. // We need to avoid adding these fields to the initial cache, // so we don't waste time evaluating logic, and don't have to rebuild the cache after repeats have been initialized. this.all[ attr ] = this.repeatsInitialized ? this.filterRadioCheckSiblings( [ ...this.view.html.querySelectorAll( `[${attr}]` ) ] ) : this.nonRepeats[ attr ]; } else if ( updated.cloned && repeatControls ) { // update the cache this.all[ attr ] = this.all[ attr ].concat( repeatControls ); } /** * If the update was triggered from a repeat, it improves performance (a lot) * to exclude all those repeats that did not trigger it... * However, this will break if people are referring to nodes in other * repeats such as with /path/to/repeat[3]/node, /path/to/repeat[position() = 3]/node or indexed-repeat(/path/to/repeat/node, /path/to/repeat, 3). * We accept that for now. **/ let collection; if ( repeatControls ) { // The non-repeat fields have to be added too, e.g. to update a calculated item with count(to/repeat/node) at the top level collection = this.nonRepeats[ attr ].concat( repeatControls ); } else { collection = this.all[ attr ]; } let selector = []; // Add selectors based on specific changed nodes if ( !updated.nodes || updated.nodes.length === 0 ) { selector = [ `${filter}[${attr}]` ]; } else { updated.nodes.forEach( node => { selector = selector.concat( this.getQuerySelectorsForLogic( filter, attr, node ) ); } ); // add all the paths that use the /* selector at end of path selector = selector.concat( this.getQuerySelectorsForLogic( filter, attr, '*' ) ); } const selectorStr = selector.join( ', ' ); collection = selectorStr ? collection.filter( el => el.matches( selectorStr ) ) : collection; // TODO: exclude descendents of disabled elements? .find( ':not(:disabled) span.active' ) // TODO: remove jQuery wrapper, just return array of elements return $( collection ); }; /** * @param {Array<Element>} controls - radiobutton/checkbox HTML input elements * @return {Array<Element>} filtered controls without any sibling radiobuttons and checkboxes (only the first) */ Form.prototype.filterRadioCheckSiblings = controls => { const wrappers = []; return controls.filter( control => { // TODO: can this be further performance-optimized? const wrapper = control.type === 'radio' || control.type === 'checkbox' ? closestAncestorUntil( control, '.option-wrapper', '.question' ) : null; // Filter out duplicate radiobuttons and checkboxes if ( wrapper ) { if ( wrappers.includes( wrapper ) ) { return false; } wrappers.push( wrapper ); } return true; } ); }; /** * Crafts an optimized selector for element attributes that contain an expression with a target node name. * * @param {string} filter - The filter to use * @param {string} attr - The attribute to target * @param {string} nodeName - The XML nodeName to find * @return {string} The selector */ Form.prototype.getQuerySelectorsForLogic = ( filter, attr, nodeName ) => [ // The target node name is ALWAYS at the END of a path inside the expression. // #1: followed by space `${filter}[${attr}*="/${nodeName} "]`, // #2: followed by ) `${filter}[${attr}*="/${nodeName})"]`, // #3: followed by , if used as first parameter of multiple parameters `${filter}[${attr}*="/${nodeName},"]`, // #4: at the end of an expression `${filter}[${attr}$="/${nodeName}"]`, // #5: followed by ] (used in itemset filters) `${filter}[${attr}*="/${nodeName}]"]`, // #6: followed by [ (used when filtering nodes in repeat instances) `${filter}[${attr}*="/${nodeName}["]` ]; /** * Obtains the XML primary instance as string without nodes that have a relevant * that evaluates to false. * * Though this function may be slow it is slow when it doesn't matter much (upon saving). The * alternative is to add some logic to relevant.update to mark irrelevant nodes in the model * but that would slow down form loading and form traversal when it does matter. * * @return {string} Data string */ Form.prototype.getDataStrWithoutIrrelevantNodes = function() { const that = this; const modelClone = new FormModel( this.model.getStr() ); modelClone.init(); // Since we are removing nodes, we need to go in reverse order to make sure // the indices are still correct! this.getRelatedNodes( 'data-relevant' ).reverse().each( function() { const node = this; const relevant = that.input.getRelevant( node ); const index = that.input.getIndex( node ); const path = that.input.getName( node ); let target; /* * Copied from relevant.js: * * If the relevant is placed on a group and that group contains repeats with the same name, * but currently has 0 repeats, the context will not be available. */ if ( getChild( node, `.or-repeat-info[data-name="${path}"]` ) && !getChild( node, `.or-repeat[name="${path}"]` ) ) { target = null; } else { // If a calculation without a form control (i.e. in .calculated-items) inside a repeat // has a relevant, and there 0 instances of that repeat, // there is nothing to remove (and target is undefined) // https://github.com/enketo/enketo-core/issues/761 // TODO: It would be so much nicer if form-control-less calculations were placed inside the repeat instead. target = modelClone.node( path, index ).getElement(); } /* * If performance becomes an issue, some opportunities are: * - check if ancestor is relevant * - use cache of relevant.update * - check for repeatClones to avoid calculating index (as in relevant.update) */ if ( target && !that.model.evaluate( relevant, 'boolean',path, index ) ) { target.remove(); } } ); return modelClone.getStr(); }; /** * See https://groups.google.com/forum/?fromgroups=#!topic/opendatakit-developers/oBn7eQNQGTg * and http://code.google.com/p/opendatakit/issues/detail?id=706 * * This is using an aggressive name attribute selector to also find e.g. name="/../orx:meta/orx:instanceID", with * *ANY* namespace prefix. * * Once the following is complete this function can and should be removed: * * 1. ODK Collect starts supporting an instanceID preload item (or automatic handling of meta->instanceID without binding) * 2. Pyxforms changes the instanceID binding from calculate to preload (or without binding) * 3. Formhub has re-generated all stored XML forms from the stored XLS forms with the updated pyxforms * */ Form.prototype.grosslyViolateStandardComplianceByIgnoringCertainCalcs = function() { const $culprit = this.view.$.find( '[name$="instanceID"][data-calculate]' ); if ( $culprit.length > 0 ) { $culprit.removeAttr( 'data-calculate' ); } }; /** * This re-validates questions that have a dependency on a question that has just been updated. * * Note: it does not take care of re-validating a question itself after its value has changed due to a calculation update! * * @param {UpdatedDataNodes} updated - object that contains information on updated nodes */ Form.prototype.validationUpdate = function( updated = {} ) { if ( config.validateContinuously === true ) { let upd = { ...updated }; if ( updated.cloned ) { /* * We don't want requireds and constraints of questions in a newly created * repeat to be evaluated immediately after the repeat is created. * However, we do want constraints and requireds outside the repeat that * depend on e.g. the count() of repeats to be re-evaluated. * To achieve this we use a dirty trick and convert the "cloned" updated object * to a regular "node" updated object. */ upd = { nodes: updated.repeatPath.split( '/' ).reverse().slice( 0, 1 ) }; } // Find all inputs that have a dependency on the changed node. Avoid duplicates with Set. const nodes = new Set( this.getRelatedNodes( 'data-required', '', upd ).get() ); this.constraintAttributes.forEach( attr => this.getRelatedNodes( attr, '', upd ).get().forEach( nodes.add, nodes ) ); nodes.forEach( this.validateInput, this ); } }; /** * A big function that sets event handlers. */ Form.prototype.setEventHandlers = function() { const that = this; // Prevent default submission, e.g. when text field is filled in and Enter key is pressed this.view.$.attr( 'onsubmit', 'return false;' ); /* * The listener below catches both change and change.file events. * The .file namespace is used in the filepicker to avoid an infinite loop. * * Fields with the "ignore" class are dynamically added to the DOM in a widget and are supposed to be handled * by the widget itself, e.g. the search field in a geopoint widget. They should be ignored by the main engine. * * Readonly fields are not excluded because of this scenario: * 1. readonly field has a calculation * 2. readonly field becomes non-relevant (e.g. parent group with relevant) * 3. this clears value in view, which should propagate to model via 'change' event */ this.view.$.on( 'change.file', 'input:not(.ignore), select:not(.ignore), textarea:not(.ignore)', function() { const input = this; const n = { path: that.input.getName( input ), inputType: that.input.getInputType( input ), xmlType: that.input.getXmlType( input ), val: that.input.getVal( input ), index: that.input.getIndex( input ) }; // set file input values to the uniqified actual name of file (without c://fakepath or anything like that) if ( n.val.length > 0 && n.inputType === 'file' && input.files[ 0 ] && input.files[ 0 ].size > 0 ) { n.val = getFilename( input.files[ 0 ], input.dataset.filenamePostfix ); } if ( n.val.length > 0 && n.inputType === 'drawing' ) { n.val = getFilename( { name: n.val }, input.dataset.filenamePostfix ); } const updated = that.model.node( n.path, n.index ).setVal( n.val, n.xmlType ); if ( updated ) { that.validateInput( input ) .then( () => { // after internal processing is completed input.dispatchEvent( events.XFormsValueChanged( { repeatIndex: n.index } ) ); } ); } } ); // doing this on the focus event may have little effect on performance, because nothing else is happening :) this.view.html.addEventListener( 'focusin', event => { // update the form progress status this.progress.update( event.target ); } ); this.view.html.addEventListener( events.FakeFocus().type, event => { // update the form progress status this.progress.update( event.target ); } ); this.model.events.addEventListener( events.DataUpdate().type, event => { that.evaluationCascade.forEach( fn => { fn.call( that, event.detail ); }, true ); // edit is fired when the model changes after the form has been initialized that.editStatus = true; } ); this.view.html.addEventListener( events.AddRepeat().type, event => { const $clone = $( event.target ); // Set template-defined static defaults of added repeats in Form, setAllVals does not trigger change event this.setAllVals( $clone, event.detail.repeatIndex ); // Initialize calculations, relevant, itemset, required, output inside that repeat. this.evaluationCascade.forEach( fn => { fn.call( that, event.detail ); } ); this.progress.update(); } ); this.view.html.addEventListener( events.RemoveRepeat().type, () => { this.progress.update(); } ); this.view.html.addEventListener( events.ChangeLanguage().type, () => { this.output.update(); } ); this.view.$.find( '.or-group > h4' ).on( 'click', function() { // The resize trigger is to make sure canvas widgets start working. $( this ).closest( '.or-group' ).toggleClass( 'or-appearance-compact' ).trigger( 'resize' ); } ); }; /** * Removes an invalid mark on a question in the form UI. * * @param {Element} control - form control HTML element * @param {string} [type] - One of "constraint", "required" and "relevant". */ Form.prototype.setValid = function( control, type ) { const wrap = this.input.getWrapNode( control ); if ( !wrap ){ // TODO: this condition occurs, at least in tests for itemsets, but we need find out how. return; } const classes = type ? [ `invalid-${type}` ] : [ ...wrap.classList ].filter( cl => cl.indexOf( 'invalid-' ) === 0 ); wrap.classList.remove( ...classes ); }; /** * Marks a question as invalid in the form UI. * * @param {Element} control - form control HTML element * @param {string} [type] - One of "constraint", "required" and "relevant". */ Form.prototype.setInvalid = function( control, type = 'constraint' ) { const wrap = this.input.getWrapNode( control ); if ( !wrap ){ // TODO: this condition occurs, at least in tests for itemsets, but we need find out how. return; } if ( config.validatePage === false && this.isValid( control ) ) { this.blockPageNavigation(); } wrap.classList.add( `invalid-${type}` ); }; /** * * @param {*} control - form control HTML element * @param {*} result - result object obtained from Nodeset.validate */ Form.prototype.updateValidityInUi = function( control, result ){ const passed = result.requiredValid !== false && result.constraintValid !== false; // Update UI if ( result.requiredValid === false ) { this.setValid( control, 'constraint' ); this.setInvalid( control, 'required' ); } else if ( result.constraintValid === false ) { this.setValid( control, 'required' ); this.setInvalid( control, 'constraint' ); } else { this.setValid( control, 'constraint' ); this.setValid( control, 'required' ); } if ( !passed ){ control.dispatchEvent( events.Invalidated() ); } }; /** * Blocks page navigation for a short period. * This can be used to ensure that the user sees a new error message before moving to another page. */ Form.prototype.blockPageNavigation = function() { const that = this; this.pageNavigationBlocked = true; window.clearTimeout( this.blockPageNavigationTimeout ); this.blockPageNavigationTimeout = window.setTimeout( () => { that.pageNavigationBlocked = false; }, 600 ); }; /** * Checks whether the question is not currently marked as invalid. If no argument is provided, it checks the whole form. * * @param {Element} node - form control HTML element * @return {!boolean} Whether the question/form is not marked as invalid. */ Form.prototype.isValid = function( node ) { const invalidSelectors = [ '.invalid-required', '.invalid-relevant' ].concat( this.constraintClassesInvalid.map( cls => `.${cls}` ) ); if ( node ) { const question = this.input.getWrapNode( node ); const cls = question.classList; return !invalidSelectors.some( selector => cls.contains( selector ) ); } return !this.view.html.querySelector( invalidSelectors.join( ', ' ) ); }; /** * Clears non-relevant values. */ Form.prototype.clearNonRelevant = function() { this.relevant.update( null, true ); }; /** * Clears all non-relevant question values if necessary and then * validates all enabled input fields after first resetting everything as valid. * * @return {Promise} wrapping {boolean} whether the form contains any errors */ Form.prototype.validateAll = function() { const that = this; // to not delay validation unnecessarily we only clear non-relevants if necessary this.clearNonRelevant(); return this.validateContent( this.view.$ ) .then( valid => { that.view.html.dispatchEvent( events.ValidationComplete() ); return valid; } ); }; /** * Alias of validateAll * * @function */ Form.prototype.validate = Form.prototype.validateAll; /** * Validates all enabled input fields in the supplied container, after first resetting everything as valid. * * @param {jQuery} $container - HTML container element inside which to validate form controls * @return {Promise} wrapping {boolean} whether the container contains any errors */ Form.prototype.validateContent = function( $container ) { const that = this; const invalidSelector = [ '.invalid-required', '.invalid-relevant' ].concat( this.constraintClassesInvalid.map( cls => `.${cls}` ) ).join( ', ' ); //can't fire custom events on disabled elements therefore we set them all as valid $container.find( 'fieldset:disabled input, fieldset:disabled select, fieldset:disabled textarea, ' + 'input:disabled, select:disabled, textarea:disabled' ).each( function() { that.setValid( this ); } ); const validations = $container.find( '.question' ).addBack( '.question' ).map( function() { // only trigger validate on first input and use a **pure CSS** selector (huge performance impact) const elem = this .querySelector( 'input:not(.ignore):not(:disabled), select:not(.ignore):not(:disabled), textarea:not(.ignore):not(:disabled)' ); if ( !elem ) { return Promise.resolve(); } return that.validateInput( elem ); } ).toArray(); return Promise.all( validations ) .then( () => { const container = $container[ 0 ]; const firstError = container.matches( invalidSelector ) ? container : container.querySelector( invalidSelector ); if ( firstError ) { that.goToTarget( firstError ); } return !firstError; } ) .catch( () => // fail whole-form validation if any of the question // validations threw. false ); }; /** * @param {string} targetPath - simple relative or absolute path * @param {string} contextPath - absolute context path * @return {string} absolute path */ Form.prototype.pathToAbsolute = function( targetPath, contextPath ) { let target; if ( targetPath.indexOf( '/' ) === 0 ) { return targetPath; } // index is non-relevant (no positions in returned path) target = this.model.evaluate( targetPath, 'node', contextPath, 0, true ); return getXPath( target, 'instance', false ); }; /** * @typedef ValidateInputResolution * @property {boolean} requiredValid * @property {boolean} constraintValid */ /** * Validates question values. * * @param {Element} control - form control HTML element * @return {Promise<undefined|ValidateInputResolution>} resolves with validation result */ Form.prototype.validateInput = function( control ) { if ( !this.initialized ) { return Promise.resolve(); } const that = this; let getValidationResult; // All properties, except for the **very expensive** index property // There is some scope for performance improvement by determining other properties when they // are needed, but that may not be so significant. const n = { path: this.input.getName( control ), inputType: this.input.getInputType( control ), xmlType: this.input.getXmlType( control ), enabled: this.input.isEnabled( control ), constraint: this.input.getConstraint( control ), calculation: this.input.getCalculation( control ), required: this.input.getRequired( control ), readonly: this.input.getReadonly( control ), val: this.input.getVal( control ) }; // No need to validate, **nor send validation events**. Meant for simple empty "notes" only. if ( n.readonly && !n.val && !n.required && !n.constraint && !n.calculation ) { return Promise.resolve(); } // The enabled check serves a purpose only when an input field itself is marked as enabled but its parent fieldset is not. // If an element is disabled mark it as valid (to undo a previously shown branch with fields marked as invalid). if ( n.enabled && n.inputType !== 'hidden' ) { // Only now, will we determine the index. n.ind = this.input.getIndex( control ); getValidationResult = this.model.node( n.path, n.ind ).validate( n.constraint, n.required, n.xmlType ); } else { getValidationResult = Promise.resolve( { requiredValid: true, constraintValid: true } ); } return getValidationResult .then( result => { if ( n.inputType !== 'hidden' ) { this.updateValidityInUi( control, result ); } return result; } ) .catch( e => { console.error( 'validation error', e ); that.setInvalid( control, 'constraint' ); throw e; } ); }; /** * @param {string} path - path to HTML form control * @return {null|Element} HTML question element */ Form.prototype.getGoToTarget = function( path ) { let hits; let modelNode; let target; let intermediateTarget; let selector = ''; const repeatRegEx = /([^[]+)\[(\d+)\]([^[]*$)?/g; if ( !path ) { return; } modelNode = this.model.node( path ).getElement(); if ( !modelNode ) { return; } // Convert to absolute path, while maintaining positions. path = getXPath( modelNode, 'instance', true ); // Not inside a cloned repeat. target = this.view.html.querySelector( `[name="${path}"]` ); // If inside a cloned repeat (i.e. a repeat that is not first-in-series) if ( !target ) { intermediateTarget = this.view.html; while ( ( hits = repeatRegEx.exec( path ) ) !== null && intermediateTarget ) { selector += hits[ 1 ]; intermediateTarget = intermediateTarget .querySelectorAll( `[name="${selector}"], [data-name="${selector}"]` )[ hits[ 2 ] ]; if ( intermediateTarget && hits[ 3 ] ) { selector += hits[ 3 ]; intermediateTarget = intermediateTarget .querySelector( `[name="${selector}"],[data-name="${selector}"]` ); } target = intermediateTarget; } } return target ? this.input.getWrapNode( target ) : target; }; /** * Scrolls to an HTML question or group element, flips to the page it is on and focuses on the nearest form control. * * @param {HTMLElement} target - An HTML question or group element to scroll to * @return {boolean} whether target found */ Form.prototype.goToTarget = function( target ) { if ( target ) { if ( this.pages.active ) { // Flip to page this.pages.flipToPageContaining( $( target ) ); } // check if the target has a form control if ( target.closest( '.calculation, .setvalue, .setgeopoint' ) ) { // It is up to the apps to decide what to do with this event. target.dispatchEvent( events.GoToInvisible() ); } // check if the nearest question or group is irrelevant after page flip if ( target.closest( '.or-branch.disabled' ) ) { // It is up to the apps to decide what to do with this event. target.dispatchEvent( events.GoToIrrelevant() ); } // Scroll to element target.scrollIntoView(); // Focus on the first non .ignore form control // If the element is hidden (e.g. because it's been replaced by a widget), // the focus event will not fire, so we also trigger an applyfocus event that widgets can listen for. const input = target.querySelector( 'input:not(.ignore), textarea:not(.ignore), select:not(.ignore)' ); input.focus(); input.dispatchEvent( events.ApplyFocus() ); } return !!target; }; /** * Static property with required enketo-transformer version. * * @type {string} * @default */ Form.requiredTransformerVersion = '2.0.0'; export { Form, FormModel };