js/calculate.js

/**
 * @module calculate
 */

import config from 'enketo/config';
import { getAncestors, getSiblingElementsAndSelf } from './dom-utils';
import events from './event';
import { getCurrentPosition } from './geolocation';

export default {
    /**
     * Updates calculated items.
     *
     * @param {UpdatedDataNodes} updated - the object containing info on updated data nodes
     * @param {string} [filter] - CSS selector filter
     * @param {boolean} [emptyNonRelevant] - Whether to empty non-relevant calculation nodes
     */
    update( updated = {}, filter = '', emptyNonRelevant = false ) {
        let nodes;

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

        // Filter is used in custom applications that make a distinction between types of calculations.
        if ( updated.relevantPath ) {
            // Questions that are descendants of a group:
            nodes = this.form.getRelatedNodes( 'data-calculate', `[name^="${updated.relevantPath}/"]${filter}` )
                // Individual questions:
                .add( this.form.getRelatedNodes( 'data-calculate', `[name="${updated.relevantPath}"]${filter}` ) )
                // Individual radiobutton questions with a calculate....:
                .add( this.form.getRelatedNodes( 'data-calculate', `[data-name="${updated.relevantPath}"]${filter}` ) )
                .get();
        } else {
            nodes = this.form.getRelatedNodes( 'data-calculate', filter, updated )
                .get();
        }

        nodes.forEach( control => {
            const name = this.form.input.getName( control );
            const dataNodesObj = this.form.model.node( name );
            const dataNodes = dataNodesObj.getElements();

            const props = {
                name,
                expr: this.form.input.getCalculation( control ),
                dataType: this.form.input.getXmlType( control ),
                relevantExpr: this.form.input.getRelevant( control ),
                index: 0,
                dataNodesObj
            };

            if ( dataNodes.length > 1 ) {

                if ( updated.repeatPath && name.indexOf( updated.repeatPath + '/' ) !== -1 && dataNodes[updated.repeatIndex] ) {
                    /*
                     * If the update was triggered by a datanode inside a repeat
                     * and the dependent node is inside the same repeat, we can prevent the expensive index determination
                     */
                    const dataNodeName = ( name.lastIndexOf( '/' ) !== -1 ) ? name.substring( name.lastIndexOf( '/' ) + 1 ) : name;
                    const dataNode = this.form.model.node( updated.repeatPath, updated.repeatIndex ).getElement().querySelector( dataNodeName );
                    props.index = dataNodes.indexOf( dataNode );
                    this._updateCalc( control, props, emptyNonRelevant );
                } else if ( control.type === 'hidden' ) {
                    /*
                     * This case is the consequence of the  decision to place calculated items without a visible form control,
                     * as a separate group (.or-calculated-items, or .or-setvalue-items, or .or-setgeopoint-items), instead of in the Form DOM in the locations .
                     * This occurs when update is called with empty updated object and multiple repeats are present.
                     */
                    dataNodes.forEach( ( el, index ) => {
                        const obj = Object.create( props );
                        obj.index = index;
                        this._updateCalc( control, obj, emptyNonRelevant );
                    } );
                } else {
                    /*
                     * This occurs when the updated object contains a relevantPath that refers to a repeat and multiple repeats are
                     * present, without calculated items that HAVE a visible form control.
                     */
                    const repeatSiblings = getSiblingElementsAndSelf( control.closest( '.or-repeat' ), '.or-repeat' );
                    if ( repeatSiblings.length === dataNodes.length ) {
                        props.index = repeatSiblings.indexOf( control.closest( '.or-repeat' ) );
                        this._updateCalc( control, props, emptyNonRelevant );
                    }
                }
            } else if ( dataNodes.length === 1 ) {
                this._updateCalc( control, props, emptyNonRelevant );
            }

        } );
    },

    /**
     * @param {'setvalue' | 'setgeopoint'} action - the action being performed.
     * @param {CustomEvent} [event] - the event type that triggered the action.
     */
    _getNodesForAction( action, event ) {
        if ( event.type === new events.InstanceFirstLoad().type ) {
            // We ignore relevance for the data-instance-first-load, as that will likely never be what users want for a default value.
            // Do not use getRelatedNodes here, because the obtaining (and caching) of nodes inside repeats is (and should be) disabled at the
            // time this event fires.
            //
            // We change the order by first evaluating the non-formcontrol actions (in document order), and then
            // the ones with form controls.
            // https://github.com/OpenClinica/enketo-express-oc/issues/355#issuecomment-725640823
            return [ ...this.form.view.html.querySelectorAll( `.${action} [data-${action}][data-event*="${event.type}"]` ) ].concat(
                this.form.filterRadioCheckSiblings( [ ...this.form.view.html.querySelectorAll( `.question [data-${action}][data-event*="${event.type}"]` ) ] ) );
        } else if ( event.type === new events.NewRepeat().type ) {
            // Only this event requires specific index targeting through the "updated" object
            // We change the order by first evaluating the non-formcontrol actions (in document order), and then
            // the ones with form controls.
            // https://github.com/OpenClinica/enketo-express-oc/issues/355#issuecomment-725640823
            // https://github.com/OpenClinica/enketo-express-oc/issues/419
            return this.form.getRelatedNodes( `data-${action}`, `.${action} [data-event*="${event.type}"]`, event.detail ).get().concat(
                this.form.getRelatedNodes( `data-${action}`, `.question [data-event*="${event.type}"]`, event.detail ).get() );

        } else if ( event.type === new events.XFormsValueChanged().type ) {
            const question = event.target.closest( '.question' );

            return question ? [ ...question.querySelectorAll( `[data-${action}][data-event*="${event.type}"]` ) ] : [];
        }
    },

    /**
     * Runs actions.
     *
     * @param {'setvalue' | 'setgeopoint'} action - the action to perform.
     * @param {CustomEvent} [event] - the event type that triggered the action.
     */
    performAction( action, event ) {
        if ( !event ) {
            return;
        }

        if ( !this.form ) {
            throw new Error( `${action} action not correctly instantiated with form property.` );
        }

        const nodes = this._getNodesForAction( action, event );

        nodes.forEach( actionControl => {
            const name = this.form.input.getName( actionControl );
            const dataNodesObj = this.form.model.node( name );
            const dataNodes = dataNodesObj.getElements();

            const props = {
                name,
                dataType: this.form.input.getXmlType( actionControl ),
                relevantExpr: this.form.input.getRelevant( actionControl ),
                index: event.detail && typeof event.detail.repeatIndex !== 'undefined' ? event.detail.repeatIndex : 0,
                dataNodesObj,
                type: action,
            };

            if ( action === 'setvalue' ) {
                props.expr = actionControl.dataset.setvalue;
            }

            if ( dataNodes.length > 1 && event.type !== new events.NewRepeat().type && event.type !== new events.XFormsValueChanged().type ) {
                /*
                 * This case is the consequence of the decision to place action elements that are siblings of bind in the XForm
                 * as a separate group (.or-setvalue-items, .or-setgeopoint-items), instead of in the Form DOM in the locations where they belong.
                 * This occurs when update is called when multiple repeats are present.
                 * For now this is only relevant for events that are *not* odk-new-repeat and *not* xforms-value-changed.
                 */
                dataNodes.forEach( ( el, index ) => {
                    const obj = Object.create( props );
                    const control = actionControl;
                    obj.index = index;
                    this._updateCalc( control, obj );
                } );

            } else if ( event.type === new events.XFormsValueChanged().type ) {
                // Control for xforms-value-changed is located elsewhere, or does not exist.
                // First we test if the control can be found by looking for the same index as the trigger
                let control = this.form.input.find( props.name, props.index );
                if ( !control ){
                    // In case the trigger was inside a repeat, but the target is not.
                    props.index = 0;
                    control = this.form.input.find( props.name, 0 );
                }
                this._updateCalc( control, props );
            } else if ( dataNodes[ props.index ] ) {
                const control = actionControl;
                this._updateCalc( control, props );
            } else {
                console.error( 'performAction called for node that does not exist in model.' );
            }
        } );
    },
    /**
     * Updates a calculation.
     *
     * @param {Element} control - view element containing calculation
     * @param {*} props - properties of a calculation element
     * @param {boolean} [emptyNonRelevant] - Whether to set the calculation result to empty if non-relevant
     */
    _updateCalc( control, props, emptyNonRelevant ) {
        if ( !emptyNonRelevant && props.type !== 'setvalue' && props.type !== 'setgeopoint' && this._hasNeverBeenRelevant( control, props ) && !this._isRelevant( props ) ){
            return;
        }

        if ( props.type === 'setgeopoint' ) {
            const options = {
                enableHighAccuracy: true,
                maximumAge: 0,
            };

            getCurrentPosition( options ).then( ( { geopoint } ) => {
                this._updateValue( control, props, geopoint );
            } ).catch( () => {
                this._updateValue( control, props, '' );
            } );

            return;
        }

        const empty = emptyNonRelevant ? !this._isRelevant( props ) : false;

        // Not sure if using 'string' is always correct
        const newExpr = this.form.replaceChoiceNameFn( props.expr, 'string', props.name, props.index );

        // It is possible that the fixed expr is '' which causes an error in XPath
        // const xpathType = this.form.input.getInputType( control ) === 'number' ? 'number' : 'string';
        const result =  !empty && newExpr ? this.form.model.evaluate( newExpr, 'string', props.name, props.index ) : '';

        // Filter the result set to only include the target node
        this._updateValue( control, props, result );
    },

    /**
     * Updates a control's value after a calculation.
     *
     * @param {Element} control - view element containing calculation
     * @param {*} props - properties of a calculation element
     * @param {*} result - result of a calculation
     */
    _updateValue( control, props, result ) {
        // Filter the result set to only include the target node
        props.dataNodesObj.setIndex( props.index );

        const existingModelValue = props.dataNodesObj.getVal();

        // Set the value
        props.dataNodesObj.setVal( result, props.dataType );

        const newModelValue = props.dataNodesObj.getVal();

        // This is okay for an xforms-value-changed action (may be no form control)
        if ( !control ) {
            return;
        }

        // Not the most efficient to use input.setVal here as it will do another lookup
        // of the node, that we already have...
        // We should not use value "result" here because node.setVal() may have done a data type conversion

        if ( existingModelValue !== newModelValue ) {
            this.form.input.setVal( control, newModelValue );

            /*
             * We need to specifically call validate on the question itself, because the validationUpdate
             * in the evaluation cascade only updates questions with a _dependency_ on this question.
             */
            if ( control.type !== 'hidden' && config.validateContinuously === true ) {
                this.form.validateInput( control );
            }
        }
    },

    /**
     * Determines relevancy of node by re-evaluating relevant expressions of self and ancestors.
     *
     * @param {*} props - properties of a node
     * @return {boolean} whether the node is relevant
     */
    _isRelevant( props ) {
        let relevant = props.relevantExpr ? this.form.model.evaluate( props.relevantExpr, 'boolean', props.name, props.index ) : true;

        // Only look at ancestors if self is relevant.
        if ( relevant ) {
            const pathParts = props.name.split( '/' );
            /*
             * First determine immediate group parent of node, which will always be in correct location in DOM. This is where
             * we can use the index to be guaranteed to get the correct node.
             * (also for nodes in #calculated-items).
             *
             * Then get all the group parents of that node.
             *
             * TODO: determine index at every level to properly support repeats and nested repeats
             *
             * Note: getting the parents of control wouldn't work for nodes inside #calculated-items!
             */
            const parentPath = pathParts.splice( 0, pathParts.length - 1 ).join( '/' );
            let startElement;

            if ( props.index === 0 ) {
                startElement = this.form.view.html.querySelector( `.or-group[name="${parentPath}"],.or-group-data[name="${parentPath}"]` );
            } else {
                startElement = this.form.view.html.querySelectorAll( `.or-repeat[name="${parentPath}"]` )[ props.index ] ||
                    this.form.view.html.querySelectorAll( `.or-group[name="${parentPath}"],.or-group-data[name="${parentPath}"]` )[ props.index ];
            }
            const ancestorGroups = startElement ? [ startElement ].concat( getAncestors( startElement, '.or-group, .or-group-data' ) ) : [];

            if ( ancestorGroups.length ) {
                // Start at the highest level, and traverse down to the immediate parent group.
                relevant = ancestorGroups.filter( el => el.matches( '[data-relevant]' ) ).map( group => {
                    const nm = this.form.input.getName( group );

                    return {
                        context: nm,
                        // thankfully relevants on repeats are not possible with XLSForm-produced forms
                        index: [ ...this.form.view.html.querySelectorAll( `.or-group[name="${nm}"], .or-group-data[name="${nm}"]` ) ].indexOf( group ), // performance....
                        expr: this.form.input.getRelevant( group )
                    };
                } ).concat( [ {
                    context: props.name,
                    index: props.index,
                    expr: props.relevantExpr
                } ] ).every( item => item.expr ? this.form.model.evaluate( item.expr, 'boolean', item.context, item.index ) : true );
            }
        }

        return relevant;
    },

    _hasNeverBeenRelevant( control, props ){
        if ( control && control.closest( '.pre-init' ) ){
            return true;
        }
        // Check parents including when the calculation has no form control.
        const pathParts = props.name.split( '/' );
        /*
             * First determine immediate group parent of node, which will always be in correct location in DOM. This is where
             * we can use the index to be guaranteed to get the correct node.
             * (also for nodes in #calculated-items).
             *
             * Then get all the group parents of that node.
             *
             * TODO: determine index at every level to properly support repeats and nested repeats
             *
             * Note: getting the parents of control wouldn't work for nodes inside #calculated-items!
             */
        const parentPath = pathParts.splice( 0, pathParts.length - 1 ).join( '/' );
        let startElement;

        if ( props.index === 0 ) {
            startElement = this.form.view.html.querySelector( `.or-group[name="${parentPath}"],.or-group-data[name="${parentPath}"]` );
        } else {
            startElement = this.form.view.html.querySelectorAll( `.or-repeat[name="${parentPath}"]` )[ props.index ] ||
                    this.form.view.html.querySelectorAll( `.or-group[name="${parentPath}"],.or-group-data[name="${parentPath}"]` )[ props.index ];
        }

        return startElement ? !!startElement.closest( '.pre-init' ) : false;
    }

};