UNPKG

samagra-x-enketo-core

Version:

Extensible Enketo form engine

391 lines (371 loc) 16.1 kB
/** * 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 ); } };