js/form.js

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