js/input.js

/**
 * Form control (input, select, textarea) helper functions.
 *
 * @module input
 */

import 'openrosa-xpath-evaluator/src/date-extensions';
import types from './types';
import events from './event';
import { closestAncestorUntil } from './dom-utils';

export default {
    /**
     * @param {Element} control - form control HTML element
     * @return {Element} Wrap node
     */
    getWrapNode( control ) {
        return control.closest( '.question, .calculation, .setvalue, .setgeopoint' );
    },
    /**
     * @param {Array<Element>} controls - form controls HTML elements
     * @return {Array<Element>} Wrap nodes
     */
    getWrapNodes( controls ) {
        const result = [];
        controls.forEach( control => {
            const question = this.getWrapNode( control );
            if ( !result.includes( question ) ) {
                result.push( question );
            }
        } );

        return result;
    },
    /**
     * @param {Element} control - form control HTML element
     * @return {object} control element properties
     */
    getProps( control ) {
        return {
            path: this.getName( control ),
            ind: this.getIndex( control ),
            inputType: this.getInputType( control ),
            xmlType: this.getXmlType( control ),
            constraint: this.getConstraint( control ),
            calculation: this.getCalculation( control ),
            relevant: this.getRelevant( control ),
            readonly: this.getReadonly( control ),
            val: this.getVal( control ),
            required: this.getRequired( control ),
            enabled: this.isEnabled( control ),
            multiple: this.isMultiple( control )
        };
    },
    /**
     * @param {Element} control - form control HTML element
     * @return {string} input type
     */
    getInputType( control ) {
        const nodeName = control.nodeName.toLowerCase();
        if ( nodeName === 'input' ) {
            if ( control.dataset.drawing ) {
                return 'drawing';
            }
            if ( control.type ) {
                if ( control.type === 'text' && this.getXmlType( control ) === 'date' ) {
                    // for browsers that don't support type='date' and return 'text' (e.g. Safari Desktop)
                    return 'date';
                } else if ( control.type === 'text' && this.getXmlType( control ) === 'datetime' ) {
                    // for browsers that don't support type='datetime-local' and return 'text' (e.g. Safari and Firefox Desktop)
                    return 'datetime-local';
                } else {
                    return control.type.toLowerCase();
                }
            }

            return console.error( '<input> node has no type' );

        } else if ( nodeName === 'select' ) {
            return 'select';
        } else if ( nodeName === 'textarea' ) {
            return 'textarea';
        } else if ( nodeName === 'fieldset' || nodeName === 'section' ) {
            return 'fieldset';
        } else {
            return console.error( 'unexpected input node type provided' );
        }
    },
    /**
     * @param {Element} control - form control HTML element
     * @return {string} constraint expression
     */
    getConstraint( control ) {
        return control.dataset.constraint;
    },
    /**
     * @param {Element} control - form control HTML element
     * @return {string|undefined} required expression
     */
    getRequired( control ) {
        // only return value if input is not a table heading input
        if ( !closestAncestorUntil( control, '.or-appearance-label', '.or' ) ) {
            return control.dataset.required;
        }
    },
    /**
     * @param {Element} control - form control HTML element
     * @return {string} relevant expression
     */
    getRelevant( control ) {
        return control.dataset.relevant;
    },
    /**
     * @param {Element} control - form control HTML element
     * @return {boolean} whether element is read only
     */
    getReadonly( control ) {
        return control.matches( '[readonly]' );
    },
    /**
     * @param {Element} control - form control HTML element
     * @return {string} calculate expression
     */
    getCalculation( control ) {
        return control.dataset.calculate;
    },
    /**
     * @param {Element} control - form control HTML element
     * @return {string} XML type
     */
    getXmlType( control ) {
        return ( control.dataset.typeXml || 'string' ).toLowerCase();
    },
    /**
     * @param {Element} control - form control HTML element
     * @return {string} name
     */
    getName( control ) {
        const name = control.dataset.name || control.getAttribute( 'name' );
        if ( !name ) {
            console.error( 'input node has no name' );
        }

        return name;
    },
    /**
     * @param {Element} control - form control HTML element
     * @return {number} - the repeat index of the form control
     */
    getIndex( control ) {
        return this.form.repeats.getIndex( control.closest( '.or-repeat' ) );
    },
    /**
     * @param {Element} control - form control HTML element
     * @return {boolean} whether element is multiple
     */
    isMultiple( control ) {
        return this.getInputType( control ) === 'checkbox' || control.multiple;
    },
    /**
     * @param {Element} control - form control HTML element
     * @return {boolean} whether element is enabled
     */
    isEnabled( control ) {
        return !( control.disabled || closestAncestorUntil( control, '.disabled', '.or' ) );
    },
    /**
     * @param {Element} control - form control HTML element
     * @return {string} element value
     */
    getVal( control ) {
        let value = '';
        const inputType = this.getInputType( control );
        const name = this.getName( control );

        switch ( inputType ) {
            case 'radio': {
                const checked = this.getWrapNode( control ).querySelector( `input[type="radio"][data-name="${name}"]:checked` );
                value = checked ? checked.value : '';
                break;
            }
            case 'checkbox': {
                value = [ ...this.getWrapNode( control ).querySelectorAll( `input[type="checkbox"][name="${name}"]:checked` ) ].map( input => input.value );
                break;
            }
            case 'select': {
                if ( this.isMultiple( control ) ) {
                    value = [ ...control.querySelectorAll( 'option:checked' ) ].map( option => option.value );
                } else {
                    const selected = control.querySelector( 'option:checked' );
                    value = selected ? selected.value : '';
                }
                break;
            }
            case 'datetime-local': {
                if ( control.value ) {
                    const dt = control.value.split( 'T' )[ 1 ].length === 5 ? control.value + ':00' : control.value;
                    // Add local timezone offset
                    // do not use .toISOLocalString() because new Date("2019-10-17T16:34:23.048") works differently in iOS/Safari
                    // Take care to get DST offsets right for the date value.
                    value = dt + new Date( dt ).getTimezoneOffsetAsTime();
                }
                break;
            }
            default: {
                value = control.value;
            }
        }

        return value || '';
    },
    /**
     * Finds a form control that is not a nested xforms-value-changed action
     *
     * @param {string} name - name attribute value
     * @param {number} index - repeat index
     * @return {Element} found element
     */
    find( name, index = 0 ) {
        let attr = 'name';
        if ( this.form.view.html.querySelector( `input[type="radio"][data-name="${name}"]:not(.ignore)` ) ) {
            attr = 'data-name';
        }
        const selector = `[${attr}="${name}"]:not([data-event="xforms-value-changed"])`;
        const question = this.getWrapNodes( this.form.view.html.querySelectorAll( selector ) )[ index ];

        return question ? question.querySelector( `[${attr}="${name}"]:not(.ignore)` ) : null;
    },
    /**
     * Sets the value of a form control (or group like radiobuttons)
     *
     * @param {Element} control - form control HTML element
     * @param {string|number} value - value to set
     * @param {Event} [event] - event to fire after setting value
     * @return {Element} first control whose value was set
     */
    setVal( control, value, event = events.InputUpdate() ) {
        let inputs;
        const type = this.getInputType( control );
        const xmlType = this.getXmlType( control );
        const question = this.getWrapNode( control );
        const name = this.getName( control );

        if ( type === 'radio' ) {
            // data-name is always present on radiobuttons
            inputs = question.querySelectorAll( `[data-name="${name}"]:not(.ignore)` );
        } else {
            // why not use this.getIndex?
            inputs = question.querySelectorAll( `[name="${name}"]:not(.ignore)` );

            if ( type === 'file' ) {
                // value of file input can be reset to empty but not to a non-empty value
                if ( value ) {
                    control.setAttribute( 'data-loaded-file-name', value );

                    // console.error('Cannot set value of file input field (value: '+value+'). If trying to load '+
                    //  'this record for editing this file input field will remain unchanged.');
                    return false;
                }
            }

            if ( xmlType === 'date' || xmlType === 'datetime' ) {
                if ( value ) {
                    // convert current value (loaded from instance) to a value that a native datepicker understands
                    // TODO: test for IE, FF, Safari when those browsers start including native datepickers
                    value = types[ xmlType.toLowerCase() ].convert( value );

                    if ( xmlType === 'datetime' ) {
                        // convert to local time zone
                        value = new Date( value ).toISOLocalString();
                        // chop off local timezone offset to display properly in (native datetime-local) widget
                        const parts = value.split( 'T' );
                        const date = parts[ 0 ];
                        const time = ( parts && parts[ 1 ] ) ? parts[ 1 ].split( /[Z\-+]/ )[ 0 ] : '00:00';
                        value = `${date}T${time}`;
                    }
                }
            }

            if ( type === 'time' ) {
                // convert to a local time value that HTML time inputs and the JS widget understand (01:02)
                if ( /(\+|-)/.test( value ) ) {
                    // Use today's date to incorporate daylight savings changes,
                    // Strip the thousands of a second, because most browsers fail to parse such a time.
                    // Add a space before the timezone offset to satisfy some browsers.
                    // For IE11, we also need to strip the Left-to-Right marks \u200E...
                    const ds = `${new Date().toLocaleDateString( 'en', {
                        month: 'short',
                        day: 'numeric',
                        year: 'numeric'
                    } ).replace( /\u200E/g, '' )} ${value.replace( /(\d\d:\d\d:\d\d)(\.\d{1,3})(\s?((\+|-)\d\d))(:)?(\d\d)?/, '$1 GMT$3$7' )}`;
                    const d = new Date( ds );
                    if ( d.toString() !== 'Invalid Date' ) {
                        value = `${d.getHours().toString().padStart( 2, '0' )}:${d.getMinutes().toString().padStart( 2, '0' )}`;
                    } else {
                        console.error( 'could not parse time:', value );
                    }
                }
            }
        }

        if ( this.isMultiple( control ) === true ) {
            // TODO: It's weird that setVal does not take an array value but getVal returns an array value for multiple selects!
            value = value.split( ' ' );
        } else if ( type === 'radio' ) {
            value = [ value ];
        }

        if ( inputs.length ) {
            const curVal = this.getVal( control );
            if ( curVal === undefined || curVal.toString() !== value.toString() ) {
                switch ( type ) {
                    case 'radio': {
                        if ( value.toString() === '' ){
                            inputs.forEach( input => input.checked = false );
                        } else {
                            const input = this.getWrapNode( control ).querySelector( `input[type="radio"][data-name="${name}"][value="${value}"]` );
                            if ( input ) {
                                input.checked = true;
                            }
                        }
                        break;
                    }
                    case 'checkbox': {
                        this.getWrapNode( control ).querySelectorAll( `input[type="checkbox"][name="${name}"]` )
                            .forEach( input => input.checked = value.includes( input.value ) );
                        break;
                    }
                    case 'select': {
                        if ( this.isMultiple( control ) ) {
                            control.querySelectorAll( 'option' ).forEach( option => option.selected = value.includes( option.value ) );
                        } else {
                            const option = control.querySelector( `option[value="${value}"]` );
                            if ( option ) {
                                option.selected = true;
                            } else {
                                control.querySelectorAll( 'option' ).forEach( option => option.selected = false );
                            }
                        }
                        break;
                    }
                    default: {
                        control.value = value;
                    }
                }

                // don't trigger on all radiobuttons/checkboxes
                if ( event ) {
                    inputs[ 0 ].dispatchEvent( event );
                    // Ensure that any calculations with form controls that serve as action triggers
                    // the action.
                    if ( event.type === events.InputUpdate().type ){
                        inputs[0].dispatchEvent( events.XFormsValueChanged() );
                    }
                }
            }
        }

        return inputs[ 0 ];
    },
    /**
     * Clears form input fields and triggers events when doing this.
     *
     * @param grp - Element whose DESCENDANT form controls to clear
     * @param event1 - first event to trigger
     * @param event2 - second event to trigger
     */
    clear( grp, event1, event2 ){
        // See original pre-December 2020 plugin.js for some additional stuff with file-preview, loadedFileName and selectedIndex
        // which I think was no longer necessary, or should be moved to the widgets instead
        // Note, issue https://github.com/enketo/enketo-core/issues/773, wrt to querySelectorAll use here.
        const questions = grp.matches( '.question' ) ? [ grp ] : grp.querySelectorAll( '.question' );
        questions.forEach( question => {
            const control = question.querySelector( 'input:not(.ignore), select:not(.ignore), textarea:not(.ignore)' );
            if ( control ){
                this.setVal( control, '', event1 );
                if ( event2 ){
                    control.dispatchEvent( event2 );
                }
            }
        } );
    },
    /**
     * @param {Element} control - form control HTML element
     * @return {Promise<undefined|ValidateInputResolution>} Promise that resolves
     */
    validate( control ) {
        return this.form.validateInput( control );
    }
};