UNPKG

samagra-x-enketo-core

Version:

Extensible Enketo form engine

384 lines (342 loc) 18.4 kB
/** * Updates itemsets. * * @module itemset */ import { parseFunctionFromExpression } from './utils'; import dialog from 'enketo/dialog'; import { closestAncestorUntil, getChild, getSiblingElement, elementDataStore as data } from './dom-utils'; import events from './event'; import { t } from 'enketo/translator'; /** * This function tries to determine whether an XPath expression for a nodeset from an external instance is static. * Hopefully in the future it can do this properly, but for now it considers any expression * with a non-numeric (position) predicate to be dynamic. * This function relies on external instances themselves to be static. * * @static * @param {string} expr - XPath expression to analyze * @return {boolean} Whether expression contains a predicate */ function isStaticItemsetFromSecondaryInstance( expr ) { const refersToInstance = /^\s*instance\(.+\)/.test( expr ); if ( !refersToInstance ) { return false; } const containsPredicate = /\[.+\]/.test( expr ); if ( !containsPredicate ) { return true; } const containsNumericPredicate = /\[\d+\]/.test( expr ); return containsNumericPredicate; } export { isStaticItemsetFromSecondaryInstance }; export default { /** * @param {UpdatedDataNodes} [updated] - The object containing info on updated data nodes. */ update( updated = {} ) { const that = this; const fragmentsCache = {}; let nodes; if ( !this.form ) { throw new Error( 'Output module not correctly instantiated with form property.' ); } if ( updated.relevantPath ) { // Questions that are descendants of a group: nodes = this.form.getRelatedNodes( 'data-items-path', '.itemset-template' ).get() .filter( template => { return template.querySelector( `[type="checkbox"][name^="${updated.relevantPath}/"]` ) // checkboxes, ancestor relevant || template.querySelector( `[type="radio"][data-name^="${updated.relevantPath}/"]` ) // radiobuttons, ancestor relevant || template.parentElement.matches( `select[name^="${updated.relevantPath}/"]` ) // select minimal, ancestor relevant || template.parentElement.parentElement.querySelector( `input[list][name^="${updated.relevantPath}/"]` ) // autocomplete, ancestor relevant || template.querySelector( `[type="checkbox"][name="${updated.relevantPath}"]` ) // checkboxes, self relevant || template.querySelector( `[type="radio"][data-name="${updated.relevantPath}"]` ) // radiobuttons, self relevant || template.parentElement.matches( `select[name="${updated.relevantPath}"]` ) // select minimal, self relevant || template.parentElement.parentElement.querySelector( `input[list][name="${updated.relevantPath}"]` ); // autocomplete, self relevant } ); // TODO: missing case: static shared itemlist in repeat } else { nodes = this.form.getRelatedNodes( 'data-items-path', '.itemset-template', updated ) .get(); } const clonedRepeatsPresent = this.form.repeatsPresent && this.form.view.html.querySelector( '.or-repeat.clone' ); const alerts = []; nodes.forEach( template => { const shared = template.parentElement.parentElement.matches( '.or-repeat-info' ); const inputAttributes = {}; // Nodes are in document order, so we discard any nodes in questions/groups that have a disabled parent if ( closestAncestorUntil( template, '.disabled', '.or' ) ) { return; } const newItems = {}; const prevItems = data.get( template, 'items' ) || {}; const templateNodeName = template.nodeName.toLowerCase(); const list = template.parentElement.matches( 'select, datalist' ) ? template.parentElement : null; let input; if ( templateNodeName === 'label' ) { const optionInput = getChild( template, 'input' ); [].slice.call( optionInput.attributes ).forEach( attr => { inputAttributes[ attr.name ] = attr.value; } ); // If this is a ranking widget: input = optionInput.classList.contains( 'ignore' ) ? getSiblingElement( optionInput.closest( '.option-wrapper' ), 'input.rank' ) : optionInput; } else if ( list && list.nodeName.toLowerCase() === 'select' ) { input = list; } else if ( list && list.nodeName.toLowerCase() === 'datalist' ) { if ( shared ) { // only the first input, is that okay? input = that.form.view.html.querySelector( `input[name="${list.dataset.name}"]` ); } else { input = getSiblingElement( list, 'input:not(.widget)' ); } } const labelsContainer = getSiblingElement( template.closest( 'label, select, datalist' ), '.itemset-labels' ); const itemsXpath = template.dataset.itemsPath; let labelType = labelsContainer.dataset.labelType; let labelRef = labelsContainer.dataset.labelRef; // TODO: if translate() becomes official, move determination of labelType to enketo-xslt // and set labelRef correct in enketo-xslt const matches = parseFunctionFromExpression( labelRef, 'translate' ); if ( matches.length ) { labelRef = matches[ 0 ][ 1 ][ 0 ]; labelType = 'langs'; } const valueRef = labelsContainer.dataset.valueRef; // Shared datalists are under .or-repeat-info. Context is not relevant as these are static lists (without relative nodes). const context = that.form.input.getName( input ); /* * Determining the index is expensive, so we only do this when the itemset is inside a cloned repeat and not shared. * It can be safely set to 0 for other branches. */ const index = ( !shared && clonedRepeatsPresent && closestAncestorUntil( input, '.or-repeat.clone', '.or' ) ) ? that.form.input.getIndex( input ) : 0; const safeToTryNative = true; // Caching has no advantage here. This is a very quick query (natively). const instanceItems = this.form.model.evaluate( itemsXpath, 'nodes', context, index, safeToTryNative ); // This property allows for more efficient 'itemschanged' detection newItems.length = instanceItems.length; // TODO: This may cause problems for large itemsets. Use md5 instead? newItems.text = instanceItems.map( item => item.textContent ).join( '' ); if ( newItems.length === prevItems.length && newItems.text === prevItems.text ) { return; } data.put( template, 'items', newItems ); /** * Remove current items before rebuilding a new itemset from scratch. */ // the current <option> and <input> elements // datalist will catch the shared datalists inside .or-repeat-info const question = template.closest( '.question, datalist' ); [ ...question.querySelectorAll( templateNodeName ) ].filter( el => el !== template ).forEach( el => el.remove() ); // labels for current <option> elements const next = question.nextElementSibling; // next is a somewhat fragile match for option-translations belonging to a shared datalist in // .or-repeat-info if there are multiple shared datalists. const optionsTranslations = next && next.matches( '.or-option-translations' ) ? next : question.querySelector( '.or-option-translations' ); if ( optionsTranslations ) { [ ...optionsTranslations.children ].forEach( child => child.remove() ); } let optionsFragment = document.createDocumentFragment(); let optionsTranslationsFragment = document.createDocumentFragment(); let translations = []; const cacheKey = `${context}:${itemsXpath}`; if ( fragmentsCache[ cacheKey ] ) { // important: leave cache intact by cloning optionsFragment = fragmentsCache[ cacheKey ].optionsFragment.cloneNode( true ); optionsTranslationsFragment = fragmentsCache[ cacheKey ].optionsTranslationsFragment.cloneNode( true ); } else { instanceItems.forEach( item => { /* * Note: $labelRefs could either be * - a single itext reference * - a collection of labels with different lang attributes * - a single label */ const labels = that.getNodesFromItem( labelRef, item ); if ( !labels || !labels.length ) { translations = [ { language: '', label: 'error', active: true } ]; } else { switch ( labelType ) { case 'itext': // Search in the special .itemset-labels created in enketo-transformer for labels with itext ref. translations = [ ...labelsContainer.querySelectorAll( `[data-itext-id="${labels[ 0 ].textContent}"]` ) ].map( label => { const language = label.getAttribute( 'lang' ); const type = label.nodeName; const src = label.src; const text = label.textContent; const active = label.classList.contains( 'active' ); const alt = label.alt; return { language, type, text, active, src, alt }; } ); break; case 'langs': translations = labels.map( label => { const lang = label.getAttribute( 'lang' ); // Two falsy values should set active to true. const active = ( !lang && !that.form.langs.currentLanguage ) || ( lang === that.form.langs.currentLanguage ); return { language: lang, type: 'span', text: label.textContent, active }; } ); break; default: translations = [ { language: '', type: 'span', text: labels && labels.length ? labels[ 0 ].textContent : 'error', active: true } ]; } } // Obtain the value of the secondary instance item found. const value = that.getNodeFromItem( valueRef, item ).textContent; /** * #510 Show warning if select_multiple value has spaces */ const multiple = ( inputAttributes[ 'data-type-xml' ] == 'select' ) && ( inputAttributes[ 'type' ] == 'checkbox' ) || ( list && list.multiple ); if ( multiple && ( value.indexOf( ' ' ) > -1 ) ) { alerts[ alerts.length ] = t( 'alert.valuehasspaces.multiple', { value: value } ); } if ( templateNodeName === 'label' ) { optionsFragment.appendChild( that.createInput( inputAttributes, translations, value ) ); } else if ( templateNodeName === 'option' ) { let activeLabel = ''; if ( translations.length > 1 ) { translations.forEach( translation => { if ( translation.active ) { activeLabel = translation.text; } optionsTranslationsFragment.appendChild( that.createOptionTranslation( translation, value ) ); } ); } else { activeLabel = translations[ 0 ].text; } optionsFragment.appendChild( that.createOption( activeLabel, value ) ); } } ); // Do not cache radio button questions inside a repeat because each set (in each repeat) should maintain unique name attribute if ( isStaticItemsetFromSecondaryInstance( itemsXpath ) && !( input.type === 'radio' && input.closest( '.or-repeat' ) ) ) { fragmentsCache[ cacheKey ] = { optionsFragment: optionsFragment.cloneNode( true ), optionsTranslationsFragment: optionsTranslationsFragment.cloneNode( true ) }; } } template.parentNode.appendChild( optionsFragment ); if ( optionsTranslations ) { optionsTranslations.appendChild( optionsTranslationsFragment ); } /** * Attempt to populate inputs with current value in model (except for ranking input) * Note that if the current value is not empty and the new itemset does not * include (an) item(s) with this/se value(s), this will clear/update the model and * this will trigger a dataupdate event. This may call this update function again. */ // It is not necessary to do this for default values in static itemsets because setAllVals takes care of this. let currentValue = that.form.model.node( context, index ).getVal(); if ( currentValue !== '' ) { if ( input.classList.contains( 'rank' ) ) { currentValue = ''; } that.form.input.setVal( input, currentValue, events.Change() ); } if ( list || input.classList.contains( 'rank' ) ) { input.dispatchEvent( events.ChangeOption() ); } } ); if ( alerts.length > 0 ) { /** * We're assuming the enketo-core-consuming app has a dialog that supports some basic HTML rendering */ dialog.alert( alerts.join( '<br>' ) ); } }, /** * Minimal XPath evaluation helper that queries from a single item context. * * @param {string} expr - The XPath expression * @param {string} context - context path * @param {boolean} single - whether to only return a single (first) node * @return {Array<Element>} found nodes */ getNodesFromItem( expr, context, single ) { if ( !expr ) { throw new Error( 'Error: could not query instance item, no expression provided' ); } const type = single ? 9 : 7; const evaluateFnName = typeof this.form.model.xml.evaluate !== 'undefined' ? 'evaluate' : 'jsEvaluate'; const result = this.form.model.xml[ evaluateFnName ]( expr, context, this.form.model.getNsResolver(), type, null ); const response = []; if ( !single ) { for ( let j = 0; j < result.snapshotLength; j++ ) { response.push( result.snapshotItem( j ) ); } } else { response.push( result.singleNodeValue ); } return response; }, /** * @param {string} expr - XPath expression * @param {string} context - evalation context path * @return {Element|null} found nodes */ getNodeFromItem( expr, context ) { const nodes = this.getNodesFromItem( expr, context, true ); return nodes.length ? nodes[ 0 ] : null; }, /** * Creates a HTML option element * * @param {string} label - option label * @param {string} value - option value * @return {Element} created option */ createOption( label, value ) { const option = document.createElement( 'option' ); option.textContent = label; option.value = value; return option; }, /** * Creates an option translation <span> element * * @param {object} translation - translation object * @param {string} [translation.type] - type of element to create, defaults to span * @param {string} [translation.text] - translation text * @param {string} value - option value * @return {Element} created element */ createOptionTranslation( translation, value ) { const el = document.createElement( translation.type || 'span' ); if ( translation.text ) { el.textContent = translation.text; el.classList.add( 'option-label' ); } el.classList.toggle( 'active', translation.active ); if ( translation.language ) { el.lang = translation.language; } el.dataset.optionValue = value; if ( translation.src ) { el.src = translation.src; el.alt = translation.alt; } return el; }, /** * Creates an input HTML element * * @param {Array<object>} attributes - attributes to add to input * @param {Array<object>} translations - translation to add * @param {string} value - option value * @return {Element} label element (wrapper) */ createInput( attributes, translations, value ) { const that = this; const label = document.createElement( 'label' ); const input = document.createElement( 'input' ); Object.getOwnPropertyNames( attributes ).forEach( attr => { input.setAttribute( attr, attributes[ attr ] ); } ); input.value = value; label.appendChild( input ); translations.forEach( translation => { label.appendChild( that.createOptionTranslation( translation, value ) ); } ); return label; } };