UNPKG

enketo-core

Version:

Extensible Enketo form engine

1,486 lines (1,313 loc) 52.4 kB
import $ from 'jquery'; import { t } from 'enketo/translator'; import config from 'enketo/config'; import { FormModel } from './form-model'; import { parseFunctionFromExpression, stripQuotes, getFilename, joinPath, } from './utils'; import { getXPath, getChild, closestAncestorUntil, getSiblingElement, scrollIntoViewIfNeeded, } from './dom-utils'; import { initTimeLocalization } from './format'; import inputHelper from './input'; import repeatModule from './repeat'; import tocModule from './toc'; import pageModule from './page'; import relevantModule from './relevant'; import itemsetModule from './itemset'; import progressModule from './progress'; import widgetModule from './widgets-controller'; import languageModule from './language'; import preloadModule from './preload'; import outputModule from './output'; import calculationModule from './calculate'; import requiredModule from './required'; import readonlyModule from './readonly'; import FormLogicError from './form-logic-error'; import events from './event'; import './plugins'; import './extend'; import { callOnIdle } from './timers'; import { detectFeatures, initCollections, resetCollections, setRefTypeClasses, } from './dom'; /** * @typedef FormOptions * @property {string} [language] Overrides the default languages rules of the XForm itself. Pass any valid and present-in-the-form IANA subtag string, e.g. `ar`. * @property {boolean} [printRelevantOnly] If `printRelevantOnly` is set to `true` * or not set at all, printing the form only includes what is visible, ie. all the * groups and questions that do not have a `relevant` expression or for which the * expression evaluates to `true`. */ /** * Class: Form * * Most methods are prototype method to facilitate customizations outside of enketo-core. * * @param {HTMLFormElement} formEl - HTML form element (a product of Enketo Transformer after transforming a valid ODK XForm) * @param {FormDataObj} data - Data object containing XML model, (partial) XML instance-to-load, external data and flag about whether instance-to-load has already been submitted before. * @param {FormOptions} [options] * @class */ function Form(formEl, data, options) { const $form = $(formEl); if (typeof formEl === 'string') { console.deprecate( 'Form instantiation using a selector', 'a HTML <form> element' ); formEl = $form[0]; } this.nonRepeats = {}; this.all = {}; this.options = options ?? {}; const formHTML = formEl.outerHTML; this.view = { $: $form, html: formEl, get clone() { const range = document.createRange(); return range.createContextualFragment(formHTML).firstElementChild; }, }; /** @type {ReturnType<typeof detectFeatures>} */ this.features = {}; /** @type {ReturnType<typeof initCollections>} */ this.collections = {}; this.model = new FormModel(data); this.widgetsInitialized = false; this.repeatsInitialized = false; this.pageNavigationBlocked = false; this.initialized = false; } /** * Getter and setter functions */ Form.prototype = { /** @type {string[] | null} */ repeatPathPrefixes: null, /** @type {Record<string, string | null>} */ nodePathToRepeatPath: {}, /** * @type {Array} */ evaluationCascadeAdditions: [], /** * @type {Array} */ get evaluationCascade() { const baseEvaluationCascade = [ this.calc.update.bind(this.calc), this.repeats.countUpdate.bind(this.repeats), this.relevant.update.bind(this.relevant), this.output.update.bind(this.output), this.itemset.update.bind(this.itemset), this.required.update.bind(this.required), this.readonly.update.bind(this.readonly), this.validationUpdate, ]; const { evaluationCascadeAdditions } = this; if (evaluationCascadeAdditions.length > 0) { baseEvaluationCascade.push(function (...args) { for (const fn of evaluationCascadeAdditions) { fn.apply(this, args); } }); } if (config.experimentalOptimizations.computeAsync) { return baseEvaluationCascade.map( (fn) => function (...args) { callOnIdle(() => fn.apply(this, args)); } ); } return baseEvaluationCascade; }, /** * @type {string} */ get recordName() { return this.view.$.attr('name'); }, set recordName(name) { this.view.$.attr('name', name); }, /** * @type {boolean} */ get editStatus() { return this.view.html.dataset.edited === 'true'; }, set editStatus(status) { // only trigger edit event once if (status && status !== this.editStatus) { this.view.html.dispatchEvent(events.Edited()); } this.view.html.dataset.edited = status; }, /** * @type {string} */ get surveyName() { return this.view.$.find('#form-title').text(); }, /** * @type {string} */ get instanceID() { return this.model.instanceID; }, /** * @type {string} */ get deprecatedID() { return this.model.deprecatedID; }, /** * @type {string} */ get instanceName() { return this.model.instanceName; }, /** * @type {string} */ get version() { return this.model.version; }, /** * @type {string} */ get encryptionKey() { return this.view.$.data('base64rsapublickey'); }, /** * @type {string} */ get action() { return this.view.$.attr('action'); }, /** * @type {string} */ get method() { return this.view.$.attr('method'); }, /** * @type {string} */ get id() { return this.view.html.dataset.formId; }, /** * To facilitate forks that support multiple constraints per question * * @type {Array<string>} */ get constraintClassesInvalid() { return ['invalid-constraint']; }, /** * To facilitate forks that support multiple constraints per question * * @type {Array<string>} */ get constraintAttributes() { return ['data-constraint']; }, /** * @type {Array<string>} */ get languages() { return this.langs.languagesUsed; }, /** * @type {string} */ get currentLanguage() { return this.langs.currentLanguage; }, }; /** * Returns a module and adds the form property to it. * * @template T * @param {T} module - Enketo Core module * @return {T & { form: Form }} updated module */ Form.prototype.addModule = function (module) { return Object.create(module, { form: { value: this, }, }); }; /** * Function: init * * Initializes the Form instance (XML Model and HTML View). * * @return {Array<string>} List of initialization errors. */ Form.prototype.init = function () { let loadErrors = []; const that = this; initTimeLocalization(this.view.html); this.toc = this.addModule(tocModule); this.pages = this.addModule(pageModule); this.langs = this.addModule(languageModule); this.progress = this.addModule(progressModule); this.widgets = this.addModule(widgetModule); this.preloads = this.addModule(preloadModule); this.relevant = this.addModule(relevantModule); this.repeats = this.addModule(repeatModule); this.input = this.addModule(inputHelper); this.output = this.addModule(outputModule); this.itemset = this.addModule(itemsetModule); this.calc = this.addModule(calculationModule); this.required = this.addModule(requiredModule); this.readonly = this.addModule(readonlyModule); const formEl = this.view.html; setRefTypeClasses(this); this.features = detectFeatures(formEl); this.collections = initCollections(this); widgetModule.initForm(formEl); const { instanceFirstLoadAction, newRepeatAction, valueChangedAction } = this.features; // Handle odk-instance-first-load event if (instanceFirstLoadAction) { this.model.events.addEventListener( events.InstanceFirstLoad().type, (event) => { this.calc.performAction('setvalue', event); this.calc.performAction('setgeopoint', event); } ); } if (newRepeatAction) { // Handle odk-new-repeat event before initializing repeats this.view.html.addEventListener(events.NewRepeat().type, (event) => { this.calc.performAction('setvalue', event); this.calc.performAction('setgeopoint', event); }); } if (valueChangedAction) { // Handle xforms-value-changed this.view.html.addEventListener( events.XFormsValueChanged().type, (event) => { this.calc.performAction('setvalue', event); this.calc.performAction('setgeopoint', event); } ); } // Before initializing form view and model, passthrough some model events externally // Because of instance-first-load actions, this should be done before the model is initialized. This is important for custom // applications that submit each individual value separately (opposed to a full XML model at the end). this.model.events.addEventListener(events.DataUpdate().type, (event) => { that.view.html.dispatchEvent(events.DataUpdate(event.detail)); }); // This probably does not need to be before model.init(); this.model.events.addEventListener(events.Removed().type, (event) => { that.view.html.dispatchEvent(events.Removed(event.detail)); }); loadErrors = loadErrors.concat(this.model.init()); if ( typeof this.model === 'undefined' || !(this.model instanceof FormModel) ) { loadErrors.push('Form could not be initialized without a model.'); return loadErrors; } try { this.preloads.init(); // before widgets.init (as instanceID used in offlineFilepicker widget) // store the current instanceID as data on the form element so it can be easily accessed by e.g. widgets this.view.$.data({ instanceID: this.model.instanceID, }); // before calc.init! this.grosslyViolateStandardComplianceByIgnoringCertainCalcs(); // before repeats.init to make sure the jr:repeat-count calculation has been evaluated this.calc.init(); // before itemset.update this.langs.init(this.options.language); // before repeats.init so that template contains role="page" when applicable this.pages.init(); const repeatPaths = Array.from( this.view.html.querySelectorAll('.or-repeat-info') ).map((element) => element.dataset.name); // Builds a cache of known repeat path prefixes `repeat.init`. // The cache is sorted by length, longest to shortest, to ensure // that lookups using this cache find the deepest nested repeat // for a given path. this.repeatPathPrefixes = repeatPaths .map((path) => `${path}/`) .sort((a, b) => b.length - a.length); if (repeatPaths.length > 0) { const nestedRepeats = Array.from( this.view.html.querySelectorAll('.or-repeat .or-repeat') ); const nestedRepeatPaths = nestedRepeats.map((repeat) => repeat.getAttribute('name') ); const nestedRepeatParents = repeatPaths.filter((prefix) => nestedRepeatPaths.some((path) => path.startsWith(prefix)) ); const recalculationPaths = [ ...nestedRepeatParents, ...nestedRepeatPaths, ]; let didRecalculate = false; const addRepeatType = events.AddRepeat().type; const removeRepeatType = events.RemoveRepeat().type; // after radio button data-name setting (now done in XLST) // Set temporary event handler to ensure calculations in newly added repeats are run for the first time const tempHandlerAddRepeat = ({ detail }) => { const recalculate = recalculationPaths.includes( detail.repeatPath ); if (recalculate) { this.calc.update(detail); didRecalculate = true; } }; const tempHandlerRemoveRepeat = (event) => { const recalculate = didRecalculate || recalculationPaths.includes( event.detail.initRepeatInfo.repeatPath ); if (recalculate) { this.all = {}; didRecalculate = false; } }; if (recalculationPaths.length > 0) { this.view.html.addEventListener( addRepeatType, tempHandlerAddRepeat ); this.view.html.addEventListener( removeRepeatType, tempHandlerRemoveRepeat ); } this.repeatsInitialized = true; this.repeats.init(); if (recalculationPaths.length > 0) { this.view.html.removeEventListener( addRepeatType, tempHandlerAddRepeat ); this.view.html.removeEventListener( removeRepeatType, tempHandlerRemoveRepeat ); } this.calc.update({ allRepeats: true, cloned: true, }); this.all = {}; } // after repeats.init, but before itemset.init this.output.init(); // after repeats.init this.itemset.init(); // after repeats.init this.setAllVals(); this.readonly.update(); // after setAllVals(); // after setAllVals, after repeats.init this.options.input = this.input; this.options.pathToAbsolute = this.pathToAbsolute.bind(this); this.options.evaluate = this.model.evaluate.bind(this.model); this.options.getModelValue = this.getModelValue.bind(this); this.widgetsInitialized = this.widgets.init(null, this.options); // after widgets.init(), and after repeats.init(), and after pages.init() this.relevant.init(); // after widgets init to make sure widget handlers are called before // after loading existing instance to not trigger an 'edit' event this.setEventHandlers(); // Update field calculations again to make sure that dependent // field values are calculated this.calc.update(); this.required.init(); this.editStatus = false; if (this.options.printRelevantOnly !== false) { this.view.$.addClass('print-relevant-only'); } this.goToTarget(this.view.html); setTimeout(() => { that.progress.update(); }, 0); this.initialized = true; return loadErrors; } catch (e) { console.error(e); loadErrors.push(`${e.name}: ${e.message}`); } document.body.scrollIntoView(); return loadErrors; }; /** * @param {string} xpath - simple path to question * @return {Array<string>} A list of errors originated from `goToTarget`. Empty if everything went fine. */ Form.prototype.goTo = function (xpath) { const errors = []; if (!this.goToTarget(this.getGoToTarget(xpath))) { errors.push( t('alert.gotonotfound.msg', { path: xpath.substring(xpath.lastIndexOf('/') + 1), }) ); } return errors; }; /** * Obtains a string of primary instance. * * @param {{include: boolean}} [include] - Optional object items to exclude if false * @return {string} XML string of primary instance */ Form.prototype.getDataStr = function (include = {}) { // By default everything is included if (include.irrelevant === false) { return this.getDataStrWithoutIrrelevantNodes(); } return this.model.getStr(); }; /** * Restores HTML form to pre-initialized state. It is meant to be called before re-initializing with * new Form ( .....) and form.init() * For this reason, it does not fix event handler, $form, formView.$ etc.! * It also does not affect the XML instance! * * @return {Element} the new form element */ Form.prototype.resetView = function () { this.widgets.reset(); // form language selector was moved outside of <form> so has to be separately removed if (this.langs.formLanguages) { this.langs.formLanguages.remove(); } this.view.html.replaceWith(this.view.clone); resetCollections(); return document.querySelector('form.or'); }; /** * Implements jr:choice-name * TODO: this needs to work for all expressions (relevants, constraints), now it only works for calculated items * Ideally this belongs in the form Model, but unfortunately it needs access to the view * * @param {string} expr - XPath expression * @param {string} resTypeStr - type of result * @param {string} context - context path * @param {number} index - index of context * @param {boolean} tryNative - whether to try the native evaluator, i.e. if there is no risk it would create an incorrect result such as with date comparisons * @return {string} updated expression */ Form.prototype.replaceChoiceNameFn = function ( expr, resTypeStr, context, index, tryNative ) { const choiceNames = parseFunctionFromExpression(expr, 'jr:choice-name'); choiceNames.forEach((choiceName) => { const params = choiceName[1]; if (params.length === 2) { let label = ''; const value = this.model.evaluate( params[0], resTypeStr, context, index, tryNative ); let name = stripQuotes(params[1]).trim(); name = name.startsWith('/') ? name : joinPath(context, name); const inputs = [ ...this.view.html.querySelectorAll( `[name="${name}"], [data-name="${name}"]` ), ]; const nodeName = inputs.length ? inputs[0].nodeName.toLowerCase() : null; if (!value || !inputs.length) { label = ''; } else if (nodeName === 'select') { const found = inputs.find((input) => input.querySelector(`[value="${CSS.escape(value)}"]`) ); label = found ? found.querySelector(`[value="${CSS.escape(value)}"]`) .textContent : ''; } else if (nodeName === 'input') { const list = inputs[0].getAttribute('list'); if (!list) { const found = inputs.find( (input) => input.getAttribute('value') === value ); const firstSiblingLabelEl = found ? getSiblingElement(found, '.option-label.active') : []; label = firstSiblingLabelEl ? firstSiblingLabelEl.textContent : ''; } else { const firstSiblingListEl = getSiblingElement( inputs[0], `datalist#${CSS.escape(list)}` ); if (firstSiblingListEl) { const optionEl = firstSiblingListEl.querySelector( `[data-value="${value}"]` ); label = optionEl ? optionEl.getAttribute('value') : ''; } } } expr = expr.replace(choiceName[0], `"${label}"`); } else { throw new FormLogicError( `jr:choice-name function has incorrect number of parameters: ${choiceName[0]}` ); } }); return expr; }; /** * Uses current state of model to set all the values in the form. * Since not all data nodes with a value have a corresponding input element, * we cycle through the HTML form elements and check for each form element whether data is available. * * @param {jQuery} $group - group of elements for which form controls should be updated (with current model values) * @param {number} groupIndex - index of the group */ Form.prototype.setAllVals = function ($group, groupIndex) { const that = this; const selector = $group && $group.attr('name') ? $group.attr('name') : null; groupIndex = typeof groupIndex !== 'undefined' ? groupIndex : null; this.model .node(selector, groupIndex) .getElements() .reduce((nodes, current) => { const newNodes = [...current.querySelectorAll('*')].filter( (n) => n.children.length === 0 && n.textContent ); return nodes.concat(newNodes); }, []) .forEach((element) => { let value; let name; try { value = element.textContent; name = getXPath(element, 'instance'); const index = that.model .node(name) .getElements() .indexOf(element); const control = that.input.find(name, index); if (control) { that.input.setVal(control, value, null); if ( that.input.getXmlType(control) === 'binary' && value.startsWith('jr://') && element.getAttribute('src') ) { control.setAttribute( 'data-loaded-url', element.getAttribute('src') ); } } } catch (e) { console.error(e); // TODO: Test if this correctly adds to loadErrors // loadErrors.push( 'Could not load input field value with name: ' + name + ' and value: ' + value ); throw new Error( `Could not load input field value with name: ${name} and value: ${value}` ); } }); }; /** * @param {jQuery} $control - HTML form control * @return {string|undefined} Value */ Form.prototype.getModelValue = function ($control) { const control = $control[0]; const path = this.input.getName(control); const index = this.input.getIndex(control); return this.model.node(path, index).getVal(); }; /** * Finds nodes that have attributes with XPath expressions that refer to particular XML elements. * * @param {string} attr - The attribute name to search for * @param {string} [filter] - The optional filter to append to each selector * @param {UpdatedDataNodes} updated - object that contains information on updated nodes * @return {jQuery} - A jQuery collection of elements */ Form.prototype.getRelatedNodes = function (attr, filter, updated) { let repeatControls = null; let controls; updated = updated || {}; filter = filter || ''; const { allRepeats, cloned, repeatPath } = updated; // The collection of non-repeat inputs, calculations and groups is cached (unchangeable) if (!allRepeats && !this.nonRepeats[attr]) { controls = [ ...this.view.html.querySelectorAll( `:not(.or-repeat-info)[${attr}]` ), ].filter((el) => !el.closest('.or-repeat')); this.nonRepeats[attr] = this.filterRadioCheckSiblings(controls); } if (allRepeats) { const controls = [ ...this.view.html.querySelectorAll(`form.or .or-repeat [${attr}]`), ]; repeatControls = this.filterRadioCheckSiblings(controls); } else if (typeof repeatPath !== 'undefined' && updated.repeatIndex >= 0) { const repeatEl = this.collections.repeats.getElementByRef( repeatPath, updated.repeatIndex ); controls = repeatEl ? [...repeatEl.querySelectorAll(`[${attr}]`)] : []; repeatControls = this.filterRadioCheckSiblings(controls); } // If a new repeat was created, update the cached collection of all form controls with that attribute // If a repeat was deleted (updated.repeatPath && !updated.cloned), rebuild cache. // Exclude outputs from the cache, because outputs can be added via itemsets (in labels). if (!this.all[attr] || (repeatPath && !cloned) || filter === '.or-output') { // (re)build the cache // However, if repeats have not been initialized exclude nodes inside a repeat until the first repeat has been added during repeat initialization. // The default view repeat will be removed during initialization (and stored as template), before it is re-added, if necessary. // We need to avoid adding these fields to the initial cache, // so we don't waste time evaluating logic, and don't have to rebuild the cache after repeats have been initialized. this.all[attr] = this.repeatsInitialized ? this.filterRadioCheckSiblings([ ...this.view.html.querySelectorAll(`[${attr}]`), ]) : this.nonRepeats[attr]; } else if (cloned && repeatControls) { // update the cache this.all[attr] = this.all[attr].concat(repeatControls); } /** * If the update was triggered from a repeat, it improves performance (a lot) * to exclude all those repeats that did not trigger it... * However, this will break if people are referring to nodes in other * repeats such as with /path/to/repeat[3]/node, /path/to/repeat[position() = 3]/node or indexed-repeat(/path/to/repeat/node, /path/to/repeat, 3). * We accept that for now. * */ let collection; if (repeatControls) { // The non-repeat fields have to be added too, e.g. to update a calculated item with count(to/repeat/node) at the top level collection = this.nonRepeats[attr].concat(repeatControls); } else { collection = this.all[attr]; } if (collection.length === 0) { return $(collection); } let selector = []; // Add selectors based on specific changed nodes if (!updated.nodes || updated.nodes.length === 0) { if ( repeatControls != null && cloned && filter === '.itemset-template' ) { selector = [`.or-repeat[name="${repeatPath}"] ${filter}[${attr}]`]; } else { selector = [`${filter}[${attr}]`]; } } else { updated.nodes.forEach((node) => { selector = selector.concat( this.getQuerySelectorsForLogic(filter, attr, node) ); }); // add all the paths that use the /* selector at end of path selector = selector.concat( this.getQuerySelectorsForLogic(filter, attr, '*') ); } if (selector.length === 0) { return $(collection); } const selectorStr = selector.join(', '); collection = collection.filter((el) => el.matches(selectorStr)); // TODO: exclude descendents of disabled elements? .find( ':not(:disabled) span.active' ) // TODO: remove jQuery wrapper, just return array of elements return $(collection); }; /** * @param {Array<Element>} controls - radiobutton/checkbox HTML input elements * @return {Array<Element>} filtered controls without any sibling radiobuttons and checkboxes (only the first) */ Form.prototype.filterRadioCheckSiblings = (controls) => { const wrappers = []; return controls.filter((control) => { // TODO: can this be further performance-optimized? const wrapper = control.type === 'radio' || control.type === 'checkbox' ? closestAncestorUntil(control, '.option-wrapper', '.question') : null; // Filter out duplicate radiobuttons and checkboxes if (wrapper) { if (wrappers.includes(wrapper)) { return false; } wrappers.push(wrapper); } return true; }); }; /** * Crafts an optimized selector for element attributes that contain an expression with a target node name. * * @param {string} filter - The filter to use * @param {string} attr - The attribute to target * @param {string} nodeName - The XML nodeName to find * @return {string} The selector */ Form.prototype.getQuerySelectorsForLogic = (filter, attr, nodeName) => [ // The target node name is ALWAYS at the END of a path inside the expression. // #1: followed by space `${filter}[${attr}*="/${nodeName} "]`, // #2: followed by ) `${filter}[${attr}*="/${nodeName})"]`, // #3: followed by , if used as first parameter of multiple parameters `${filter}[${attr}*="/${nodeName},"]`, // #4: at the end of an expression `${filter}[${attr}$="/${nodeName}"]`, // #5: followed by ] (used in itemset filters) `${filter}[${attr}*="/${nodeName}]"]`, // #6: followed by [ (used when filtering nodes in repeat instances) `${filter}[${attr}*="/${nodeName}["]`, ]; /** * Obtains the XML primary instance as string without nodes that have a relevant * that evaluates to false. * * Though this function may be slow it is slow when it doesn't matter much (upon saving). The * alternative is to add some logic to relevant.update to mark irrelevant nodes in the model * but that would slow down form loading and form traversal when it does matter. * * @return {string} Data string */ Form.prototype.getDataStrWithoutIrrelevantNodes = function () { const that = this; const modelClone = new FormModel(this.model.getStr()); modelClone.init(); // Since we are removing nodes, we need to go in reverse order to make sure // the indices are still correct! this.getRelatedNodes('data-relevant') .reverse() .each(function () { const node = this; const relevant = that.input.getRelevant(node); const index = that.input.getIndex(node); const path = that.input.getName(node); let target; /* * Copied from relevant.js: * * 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. */ if ( getChild(node, `.or-repeat-info[data-name="${path}"]`) && !getChild(node, `.or-repeat[name="${path}"]`) ) { target = null; } else { // If a calculation without a form control (i.e. in .calculated-items) inside a repeat // has a relevant, and there 0 instances of that repeat, // there is nothing to remove (and target is undefined) // https://github.com/enketo/enketo-core/issues/761 // TODO: It would be so much nicer if form-control-less calculations were placed inside the repeat instead. target = modelClone.node(path, index).getElement(); } /* * If performance becomes an issue, some opportunities are: * - check if ancestor is relevant * - use cache of relevant.update * - check for repeatClones to avoid calculating index (as in relevant.update) */ if ( target && !that.model.evaluate(relevant, 'boolean', path, index) ) { target.remove(); } }); return modelClone.getStr(); }; /** * See https://groups.google.com/forum/?fromgroups=#!topic/opendatakit-developers/oBn7eQNQGTg * and http://code.google.com/p/opendatakit/issues/detail?id=706 * * This is using an aggressive name attribute selector to also find e.g. name="/../orx:meta/orx:instanceID", with * *ANY* namespace prefix. * * Once the following is complete this function can and should be removed: * * 1. ODK Collect starts supporting an instanceID preload item (or automatic handling of meta->instanceID without binding) * 2. Pyxforms changes the instanceID binding from calculate to preload (or without binding) * 3. Formhub has re-generated all stored XML forms from the stored XLS forms with the updated pyxforms * */ Form.prototype.grosslyViolateStandardComplianceByIgnoringCertainCalcs = function () { const $culprit = this.view.$.find( '[name$="instanceID"][data-calculate]' ); if ($culprit.length > 0) { $culprit.removeAttr('data-calculate'); } }; /** * This re-validates questions that have a dependency on a question that has just been updated. * * Note: it does not take care of re-validating a question itself after its value has changed due to a calculation update! * * @param {UpdatedDataNodes} updated - object that contains information on updated nodes */ Form.prototype.validationUpdate = function (updated = {}) { if (config.validateContinuously === true) { let upd = { ...updated }; if (updated.cloned) { /* * We don't want requireds and constraints of questions in a newly created * repeat to be evaluated immediately after the repeat is created. * However, we do want constraints and requireds outside the repeat that * depend on e.g. the count() of repeats to be re-evaluated. * To achieve this we use a dirty trick and convert the "cloned" updated object * to a regular "node" updated object. */ upd = { nodes: updated.repeatPath.split('/').reverse().slice(0, 1), }; } // Find all inputs that have a dependency on the changed node. Avoid duplicates with Set. const nodes = new Set( this.features.required ? this.getRelatedNodes('data-required', '', upd).get() : [] ); this.constraintAttributes.forEach((attr) => this.getRelatedNodes(attr, '', upd).get().forEach(nodes.add, nodes) ); nodes.forEach(this.validateInput, this); } }; /** * A big function that sets event handlers. */ Form.prototype.setEventHandlers = function () { const that = this; // Prevent default submission, e.g. when text field is filled in and Enter key is pressed this.view.$.attr('onsubmit', 'return false;'); /* * The listener below catches both change and change.file events. * The .file namespace is used in the filepicker to avoid an infinite loop. * * Fields with the "ignore" class are dynamically added to the DOM in a widget and are supposed to be handled * by the widget itself, e.g. the search field in a geopoint widget. They should be ignored by the main engine. * * Readonly fields are not excluded because of this scenario: * 1. readonly field has a calculation * 2. readonly field becomes non-relevant (e.g. parent group with relevant) * 3. this clears value in view, which should propagate to model via 'change' event */ this.view.$.on( 'change.file', 'input:not(.ignore), select:not(.ignore), textarea:not(.ignore)', function () { const input = this; const n = { path: that.input.getName(input), inputType: that.input.getInputType(input), xmlType: that.input.getXmlType(input), val: that.input.getVal(input), index: that.input.getIndex(input), }; // set file input values to the uniqified actual name of file (without c://fakepath or anything like that) if ( n.val.length > 0 && n.inputType === 'file' && input.files[0] && input.files[0].size > 0 ) { n.val = getFilename( input.files[0], input.dataset.filenamePostfix ); } if (n.val.length > 0 && n.inputType === 'drawing') { n.val = getFilename( { name: n.val, }, input.dataset.filenamePostfix ); } const updated = that.model .node(n.path, n.index) .setVal(n.val, n.xmlType); if (updated) { that.validateInput(input).then(() => { // after internal processing is completed input.dispatchEvent( events.XFormsValueChanged({ repeatIndex: n.index }) ); }); } } ); // doing this on the focus event may have little effect on performance, because nothing else is happening :) this.view.html.addEventListener('focusin', (event) => { // update the form progress status this.progress.update(event.target); }); this.view.html.addEventListener(events.FakeFocus().type, (event) => { // update the form progress status this.progress.update(event.target); }); this.model.events.addEventListener(events.DataUpdate().type, (event) => { that.evaluationCascade.forEach((fn) => { fn.call(that, event.detail); }, true); // edit is fired when the model changes after the form has been initialized that.editStatus = true; }); this.view.html.addEventListener(events.AddRepeat().type, (event) => { const $clone = $(event.target); // Set template-defined static defaults of added repeats in Form, setAllVals does not trigger change event this.setAllVals($clone, event.detail.repeatIndex); // Initialize calculations, relevant, itemset, required, output inside that repeat. this.evaluationCascade.forEach((fn) => { fn.call(that, event.detail); }); this.progress.update(); }); this.view.html.addEventListener(events.RemoveRepeat().type, () => { this.progress.update(); }); this.view.html.addEventListener(events.ChangeLanguage().type, () => { this.output.update(); }); this.view.$.on('click', '.or-group > h4', function () { // The resize trigger is to make sure canvas widgets start working. $(this) .closest('.or-group') .toggleClass('or-appearance-compact') .trigger('resize'); }); }; /** * Removes an invalid mark on a question in the form UI. * * @param {Element} control - form control HTML element * @param {string} [type] - One of "constraint", "required" and "relevant". */ Form.prototype.setValid = function (control, type) { const wrap = this.input.getWrapNode(control); if (!wrap) { // TODO: this condition occurs, at least in tests for itemsets, but we need find out how. return; } const classes = type ? [`invalid-${type}`] : [...wrap.classList].filter((cl) => cl.indexOf('invalid-') === 0); wrap.classList.remove(...classes); }; /** * Marks a question as invalid in the form UI. * * @param {Element} control - form control HTML element * @param {string} [type] - One of "constraint", "required" and "relevant". */ Form.prototype.setInvalid = function (control, type = 'constraint') { const wrap = this.input.getWrapNode(control); if (!wrap) { // TODO: this condition occurs, at least in tests for itemsets, but we need find out how. return; } if (config.validatePage === false && this.isValid(control)) { this.blockPageNavigation(); } wrap.classList.add(`invalid-${type}`); }; /** * * @param {*} control - form control HTML element * @param {*} result - result object obtained from Nodeset.validate */ Form.prototype.updateValidityInUi = function (control, result) { const passed = result.requiredValid !== false && result.constraintValid !== false; // Update UI if (result.requiredValid === false) { this.setValid(control, 'constraint'); this.setInvalid(control, 'required'); } else if (result.constraintValid === false) { this.setValid(control, 'required'); this.setInvalid(control, 'constraint'); } else { this.setValid(control, 'constraint'); this.setValid(control, 'required'); } if (!passed) { control.dispatchEvent(events.Invalidated()); } }; /** * Blocks page navigation for a short period. * This can be used to ensure that the user sees a new error message before moving to another page. */ Form.prototype.blockPageNavigation = function () { const that = this; this.pageNavigationBlocked = true; window.clearTimeout(this.blockPageNavigationTimeout); this.blockPageNavigationTimeout = window.setTimeout(() => { that.pageNavigationBlocked = false; }, 600); }; /** * Checks whether the question is not currently marked as invalid. If no argument is provided, it checks the whole form. * * @param {Element} node - form control HTML element * @return {!boolean} Whether the question/form is not marked as invalid. */ Form.prototype.isValid = function (node) { const invalidSelectors = [ '.invalid-value,', '.invalid-required', '.invalid-relevant', ].concat(this.constraintClassesInvalid.map((cls) => `.${cls}`)); if (node) { const question = this.input.getWrapNode(node); const cls = question.classList; return !invalidSelectors.some((selector) => cls.contains(selector)); } return !this.view.html.querySelector(invalidSelectors.join(', ')); }; /** * Clears non-relevant values. */ Form.prototype.clearNonRelevant = function () { this.relevant.update(null, true); }; /** * Clears all non-relevant question values if necessary and then * validates all enabled input fields after first resetting everything as valid. * * @return {Promise} wrapping {boolean} whether the form contains any errors */ Form.prototype.validateAll = function () { const that = this; // to not delay validation unnecessarily we only clear non-relevants if necessary this.clearNonRelevant(); return this.validateContent(this.view.$).then((valid) => { that.view.html.dispatchEvent(events.ValidationComplete()); return valid; }); }; /** * Alias of validateAll * * @function */ Form.prototype.validate = Form.prototype.validateAll; /** * Validates all enabled input fields in the supplied container, after first resetting everything as valid. * * @param {jQuery} $container - HTML container element inside which to validate form controls * @return {Promise} wrapping {boolean} whether the container contains any errors */ Form.prototype.validateContent = function ($container) { const that = this; const invalidSelector = [ '.invalid-value', '.invalid-required', '.invalid-relevant', ] .concat(this.constraintClassesInvalid.map((cls) => `.${cls}`)) .join(', '); // can't fire custom events on disabled elements therefore we set them all as valid $container .find( 'fieldset:disabled input, fieldset:disabled select, fieldset:disabled textarea, ' + 'input:disabled, select:disabled, textarea:disabled' ) .each(function () { that.setValid(this); }); const validations = $container .find('.question') .addBack('.question') .map(function () { // only trigger validate on first input and use a **pure CSS** selector (huge performance impact) const elem = this.querySelector( 'input:enabled:not(.ignore), select:enabled:not(.ignore), textarea:enabled:not(.ignore)' ); if (!elem) { return Promise.resolve(); } return that.validateInput(elem); }) .toArray(); return Promise.all(validations) .then(() => { const container = $container[0]; const firstError = container.matches(invalidSelector) ? container : container.querySelector(invalidSelector); if (firstError) { that.goToTarget(firstError); } return !firstError; }) .catch( () => // fail whole-form validation if any of the question // validations threw. false ); }; /** * @param {string} targetPath - simple relative or absolute path * @param {string} contextPath - absolute context path * @return {string} absolute path */ Form.prototype.pathToAbsolute = function (targetPath, contextPath) { let target; if (targetPath.indexOf('/') === 0) { return targetPath; } // index is non-relevant (no positions in returned path) target = this.model.evaluate(targetPath, 'node', contextPath, 0, true); return getXPath(target, 'instance', false); }; /** * @typedef ValidateInputResolution * @property {boolean} requiredValid * @property {boolean} constraintValid */ /** * Validates question values. * * @param {Element} control - form control HTML element * @return {Promise<undefined|ValidateInputResolution>} resolves with validation result */ Form.prototype.validateInput = function (control) { if (!this.initialized) { return Promise.resolve(); } const that = this; let getValidationResult; // All properties, except for the **very expensive** index property // There is some scope for performance improvement by determining other properties when they // are needed, but that may not be so significant. const n = { path: this.input.getName(control), inputType: this.input.getInputType(control), xmlType: this.input.getXmlType(control), enabled: this.input.isEnabled(control), constraint: this.input.getConstraint(control), calculation: this.input.getCalculation(control), required: this.input.getRequired(control), readonly: this.input.getReadonly(control), val: this.input.getVal(control), }; // No need to validate, **nor send validation events**. Meant for simple empty "notes" only. if ( n.readonly && !n.val && !n.required && !n.constraint && !n.calculation ) { return Promise.resolve(); } // The enabled check serves a purpose only when an input field itself is marked as enabled but its parent fieldset is not. // If an element is disabled mark it as valid (to undo a previously shown branch with fields marked as invalid). if (n.enabled && n.inputType !== 'hidden') { // Only now, will we determine the index. n.ind = this.input.getIndex(control); getValidationResult = this.model .node(n.path, n.ind) .validate(n.constraint, n.required, n.xmlType); } else { getValidationResult = Promise.resolve({ requiredValid: true, constraintValid: true, }); } return getValidationResult .then((result) => { if (n.inputType !== 'hidden') { this.updateValidityInUi(control, result); } return result; }) .catch((e) => { console.error('validation error', e); that.setInvalid(control, 'constraint'); throw e; }); }; /** * @param {string} path - path to HTML form control * @return {null|Element} HTML question element */ Form.prototype.getGoToTarget = function (path) { let hits; let modelNode; let target; let intermediateTarget; let selector = ''; const repeatRegEx = /([^[]+)\[(\d+)\]([^[]*$)?/g; if (!path) { return; } modelNode = this.model.node(path).getElement(); if (!modelNode) { return; } // Convert to absolute path, while maintaining positions. path = getXPath(modelNode, 'instance', true); // Not inside a cloned repeat. target = this.view.html.querySelector(`[name="${path}"]`); // If inside a cloned repeat (i.e. a repeat that is not first-in-series) if (!target) { intermediateTarget = this.view.html; while ((hits = repeatRegEx.exec(path)) !== null && intermediateTarget) { selector += hits[1]; intermediateTarget = intermediateTarget.querySelectorAll( `[name="${selector}"], [data-name="${selector}"]` )[hits[2]]; if (intermediateTarget && hits[3]) { selector += hits[3]; intermediateTarget = intermediateTarget.qu