js/relevant.js

/**
 * @module relevant
 *
 * @description Updates branches
 */

import events from './event';
import { closestAncestorUntil, getChild, getChildren } from './dom-utils';

export default {
    /**
     * @param {UpdatedDataNodes} [updated] - The object containing info on updated data nodes.
     * @param {boolean} forceClearNonRelevant -  whether to empty the values of non-relevant nodes
     */
    update( updated, forceClearNonRelevant = false ) {

        if ( !this.form ) {
            throw new Error( 'Branch module not correctly instantiated with form property.' );
        }

        const nodes = this.form.getRelatedNodes( 'data-relevant', '', updated ).get();

        this.updateNodes( nodes, forceClearNonRelevant );
    },
    /**
     * @param {Array<Element>} nodes - Nodes to update
     * @param {boolean} forceClearNonRelevant - whether to empty the values of non-relevant nodes
     */
    updateNodes( nodes, forceClearNonRelevant = false ) {
        let branchChange = false;
        const relevantCache = {};
        const alreadyCovered = [];
        const clonedRepeatsPresent = this.form.repeatsPresent && this.form.view.html.querySelector( '.or-repeat.clone' );

        nodes.forEach( node => {

            // Note that node.getAttribute('name') is not the same as p.path for repeated radiobuttons!
            if ( alreadyCovered.indexOf( node.getAttribute( 'name' ) ) !== -1 ) {
                return;
            }

            // Since this result is almost certainly not empty, closest() is the most efficient
            const branchNode = node.closest( '.or-branch' );

            const p = {};
            let cacheIndex = null;

            p.relevant = this.form.input.getRelevant( node );
            p.path = this.form.input.getName( node );

            if ( !branchNode ) {
                if ( !closestAncestorUntil( node.parentsUntil( node, '#or-calculated-items', '.or' ) ) ) {
                    console.error( 'could not find branch node for ', node );
                }

                return;
            }

            /*
             * Check if the (calculate without form control) node is part of a repeat that has no instances
             */
            const pathParts = p.path.split( '/' );
            if ( pathParts.length > 3 ) {
                const parentPath = pathParts.splice( 0, pathParts.length - 1 ).join( '/' );
                const parentGroups = [ ...this.form.view.html.querySelectorAll( `.or-group[name="${parentPath}"],.or-group-data[name="${parentPath}"]` ) ]
                    // now remove the groups that have a repeat-info child without repeat instance siblings
                    .filter( group => getChild( group, '.or-repeat' ) || !getChild( group, '.or-repeat-info' ) );
                // If the parent doesn't exist in the DOM it means there is a repeat ancestor and there are no instances of that repeat.
                // Hence that relevant does not need to be evaluated (and would fail otherwise because the context doesn't exist).
                if ( parentGroups.length === 0 ) {
                    return;
                }
            }

            /*
             * Determining ancestry is expensive. Using the knowledge most forms don't use repeats and
             * if they do, they usually don't have cloned repeats during initialization we perform first a check for .repeat.clone.
             * The first condition is usually false (and is a very quick one-time check) so this presents a big performance boost
             * (6-7 seconds of loading time on the bench6 form)
             */
            // TODO: these checks fail miserably for calculated items that do not have a form control
            const insideRepeat = clonedRepeatsPresent && closestAncestorUntil( branchNode, '.or-repeat', '.or',  );
            const insideRepeatClone = clonedRepeatsPresent && closestAncestorUntil( branchNode, '.or-repeat.clone', '.or' );

            /*
             * 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. This same logic is applied in output.js.
             */
            let context = p.path;
            if ( getChild( node, `.or-repeat-info[data-name="${p.path}"]` ) && !getChild( node,  `.or-repeat[name="${p.path}"]` ) ) {
                context = null;
            }

            /*
             * Determining the index is expensive, so we only do this when the branch is inside a cloned repeat.
             * It can be safely set to 0 for other branches.
             */
            p.ind = ( context && insideRepeatClone ) ? this.form.input.getIndex( node ) : 0;
            /*
             * Caching is only possible for expressions that do not contain relative paths to nodes.
             * So, first do a *very* aggresive check to see if the expression contains a relative path.
             * This check assumes that child nodes (e.g. "mychild = 'bob'") are NEVER used in a relevant
             * expression, which may prove to be incorrect.
             */
            if ( p.relevant.indexOf( '..' ) === -1 ) {
                if ( !insideRepeat ) {
                    cacheIndex = p.relevant;
                } else {
                    // The path is stripped of the last nodeName to record the context.
                    // This might be dangerous, but until we find a bug, it helps in those forms where one group contains
                    // many sibling questions that each have the same relevant.
                    cacheIndex = `${p.relevant}__${p.path.substring( 0, p.path.lastIndexOf( '/' ) )}__${p.ind}`;
                }
            }
            let result;
            if ( cacheIndex && typeof relevantCache[ cacheIndex ] !== 'undefined' ) {
                result = relevantCache[ cacheIndex ];
            } else {
                result = this.evaluate( p.relevant, context, p.ind );
                relevantCache[ cacheIndex ] = result;
            }

            if ( !insideRepeat ) {
                alreadyCovered.push( node.getAttribute( 'name' ) );
            }

            if ( this.process( branchNode, p.path, result, forceClearNonRelevant ) === true ) {
                branchChange = true;
            }
        } );

        if ( branchChange ) {
            this.form.view.$.trigger( 'changebranch' );
        }
    },
    /**
     * Evaluates a relevant expression (for future fancy stuff this is placed in a separate function)
     *
     * @param {string} expr - relevant XPath expression to evaluate
     * @param {string} contextPath - Path of the context node
     * @param {number} index - index of context node
     * @return {boolean} result of evaluation
     */
    evaluate( expr, contextPath, index ) {
        const result = this.form.model.evaluate( expr, 'boolean', contextPath, index );

        return result;
    },
    /**
     * Processes the evaluation result for a branch
     *
     * @param {Element} branchNode - branch node
     * @param {string} path - path of branch node
     * @param {boolean} result - result of relevant evaluation
     * @param {boolean} forceClearNonRelevant - whether to empty the values of non-relevant nodes
     */
    process( branchNode, path, result, forceClearNonRelevant = false ) {
        if ( result === true ) {
            return this.enable( branchNode, path );
        } else {
            return this.disable( branchNode, path, forceClearNonRelevant );
        }
    },

    /**
     * Checks whether branch currently has 'relevant' state
     *
     * @param {Element} branchNode - branch node
     * @return {boolean} whether branch is currently relevant
     */
    selfRelevant( branchNode ) {
        return !branchNode.classList.contains( 'disabled' ) && !branchNode.classList.contains( 'pre-init' );
    },

    /**
     * Enables and reveals a branch node/group
     *
     * @param {Element} branchNode - The Element to reveal and enable
     * @param {string} path - path of branch node
     * @return {boolean} whether the relevant changed as a result of this action
     */
    enable( branchNode, path ) {
        let change = false;

        if ( !this.selfRelevant( branchNode ) ) {
            change = true;
            branchNode.classList.remove( 'disabled', 'pre-init' );
            // Update calculated items, both individual question or descendants of group
            this.form.calc.update( {
                relevantPath: path
            } );
            this.form.itemset.update( {
                relevantPath: path
            } );
            // Update outputs that are children of branch
            // TODO this re-evaluates all outputs in the form which is not efficient!
            this.form.output.update();
            this.form.widgets.enable( branchNode );
            this.activate( branchNode );
        }

        return change;
    },

    /**
     * Disables and hides a branch node/group
     *
     * @param {Element} branchNode - The element to hide and disable
     * @param {string} path - path of branch node
     * @param {boolean} forceClearNonRelevant - whether to empty the values of non-relevant nodes
     * @return {boolean} whether the relevancy changed as a result of this action
     */
    disable( branchNode, path, forceClearNonRelevant ) {
        const neverEnabled = branchNode.classList.contains( 'pre-init' );
        let changed = false;

        if ( neverEnabled || this.selfRelevant( branchNode ) || forceClearNonRelevant ) {
            changed = true;
            if ( forceClearNonRelevant ) {
                this.clear( branchNode, path );
            }

            this.deactivate( branchNode );
        }

        return changed;
    },
    /**
     * Clears values from branchnode.
     * This function is separated so it can be overridden in custom apps.
     *
     * @param {Element} branchNode - branch node
     * @param {string} path - path of branch node
     */
    clear( branchNode, path ) {
        // A change event ensures the model is updated
        // An inputupdate event is required to update widgets
        this.form.input.clear( branchNode, events.Change(), events.InputUpdate() );
        // Update calculated items if branch is a group
        // We exclude question branches here because those will have been cleared already in the previous line.
        if ( branchNode.matches( '.or-group, .or-group-data' ) ) {
            this.form.calc.update( {
                relevantPath: path
            }, '', true );
        }
    },
    /**
     * @param {Element} branchNode - branch node
     * @param {boolean} bool - value to set disabled property to
     */
    setDisabledProperty( branchNode, bool ) {
        const type = branchNode.nodeName.toLowerCase();

        if ( type === 'label' ) {
            getChildren( branchNode,  'input, select, textarea' ).forEach( el => el.disabled = bool );
        } else if ( type === 'fieldset' || type === 'section' ) {
            // TODO: a <section> cannot be disabled like this
            branchNode.disabled = bool;
        } else {
            branchNode.querySelectorAll( 'fieldset, input, select, textarea' ).forEach( el => el.disabled = bool );
        }
    },
    /**
     * Activates form controls.
     * This function is separated so it can be overridden in custom apps.
     *
     * @param {Element} branchNode - branch node
     */
    activate( branchNode ) {
        this.setDisabledProperty( branchNode, false );
    },
    /**
     * Deactivates form controls.
     * This function is separated so it can be overridden in custom apps.
     *
     * @param {Element} branchNode - branch node
     */
    deactivate( branchNode ) {
        branchNode.classList.add( 'disabled' );
        this.form.widgets.disable( branchNode );
        this.setDisabledProperty( branchNode, true );
    }
};