enketo-core
Version:
Extensible Enketo form engine
474 lines (452 loc) • 17.5 kB
JavaScript
/**
* Form control (input, select, textarea) helper functions.
*
* @module input
*/
import {
getTimezoneOffsetAsTime,
toISOLocalString,
} from 'openrosa-xpath-evaluator/src/date-extensions';
import types from './types';
import events from './event';
import { closestAncestorUntil } from './dom-utils';
/**
* @typedef {import('./form').Form} Form
*/
export default {
/**
* @type {Form}
*/
// @ts-expect-error - this will be populated during form init, but assigning
// its type here improves intellisense.
form: null,
/**
* @param {HTMLElement} control - form control HTML element
* @return {HTMLElement} 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';
}
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';
}
return control.type.toLowerCase();
}
return console.error('<input> node has no type');
}
if (nodeName === 'select') {
return 'select';
}
if (nodeName === 'textarea') {
return 'textarea';
}
if (nodeName === 'fieldset' || nodeName === 'section') {
return 'fieldset';
}
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 {HTMLElement} control - form control HTML element
* @return {string|undefined} required expression
*/
getRequired(control) {
const { required } = control.dataset;
// only return value if input is not a table heading input
if (
required != null &&
!closestAncestorUntil(control, '.or-appearance-label', '.or')
) {
return 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 + getTimezoneOffsetAsTime(new Date(dt));
}
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) {
return this.form.collections.refTargetContainers
.getElementByRef(name, index)
?.querySelector('.ref-target:not(.ignore)');
},
/**
* 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 = toISOLocalString(new Date(value));
// 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.
const todayDate = new Date().toLocaleDateString('en', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
const valueTime = value.replace(
/(\d\d:\d\d:\d\d)(\.\d{1,3})(\s?((\+|-)\d\d))(:)?(\d\d)?/,
'$1 GMT$3$7'
);
const d = new Date(`${todayDate} ${valueTime}`);
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="${CSS.escape(
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="${CSS.escape(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);
},
};