UNPKG

samagra-x-enketo-core

Version:

Extensible Enketo form engine

438 lines (389 loc) 20 kB
/** * Repeat module. * * Two important concepts are used: * 1. The first XLST-added repeat view is cloned to serve as a template of that repeat. * 2. Each repeat series has a sibling .or-repeat-info element that stores info that is relevant to that series. * * Note that with nested repeats you may have many more series of repeats than templates, because a nested repeat * may have multiple series. * * @module repeat */ import $ from 'jquery'; import events from './event'; import { t } from 'enketo/translator'; import dialog from 'enketo/dialog'; import { getSiblingElements, getSiblingElement, getChildren, getSiblingElementsAndSelf } from './dom-utils'; import { isStaticItemsetFromSecondaryInstance } from './itemset'; import config from 'enketo/config'; const disableFirstRepeatRemoval = config.repeatOrdinals === true; export default { /** * Initializes all Repeat Groups in form (only called once). */ init() { const that = this; let $repeatInfos; this.staticLists = []; if ( !this.form ) { throw new Error( 'Repeat module not correctly instantiated with form property.' ); } $repeatInfos = this.form.view.$.find( '.or-repeat-info' ); this.templates = {}; // Add repeat numbering elements $repeatInfos .siblings( '.or-repeat' ) .prepend( '<span class="repeat-number"></span>' ) // add empty class for calculation-only repeats .addBack() .filter( function() { // remove whitespace if ( this.firstChild && this.firstChild.nodeType === 3 ) { this.firstChild.textContent = ''; } return !this.querySelector( '.question' ); } ) .addClass( 'empty' ); // Add repeat buttons $repeatInfos.filter( '*:not([data-repeat-fixed]):not([data-repeat-count])' ) .append( '<button type="button" class="btn btn-default add-repeat-btn"><i class="icon icon-plus"> </i></button>' ) .siblings( '.or-repeat' ) .append( `<div class="repeat-buttons"><button type="button" ${disableFirstRepeatRemoval ? ' disabled ' : ' '}class="btn btn-default remove"><i class="icon icon-minus"> </i></button></div>` ); /** * The model also requires storing repeat templates for repeats that do not have a jr:template. * Since the model has no knowledge of which node is a repeat, we direct this here. */ this.form.model.extractFakeTemplates( $repeatInfos.map( function() { return this.dataset.name; } ).get() ); /** * Clone all repeats to serve as templates * in reverse document order to properly deal with nested repeat templates * * Widgets not yet initialized. Values not yet set. */ $repeatInfos.siblings( '.or-repeat' ).reverse().each( function() { const templateEl = this.cloneNode( true ); const xPath = templateEl.getAttribute( 'name' ); this.remove(); $( templateEl ).removeClass( 'contains-current current' ).find( '.current' ).removeClass( 'current' ); // Clear all values (this is required for setvalue/odk-instance-first-load populated values) // The default values will be added anyway in the repeats.add function. that.form.input.clear( templateEl ); that.templates[ xPath ] = templateEl; } ); $repeatInfos.reverse() .each( function() { // don't do nested repeats here, they will be dealt with recursively if ( !$( this ).closest( '.or-repeat' ).length ) { that.updateDefaultFirstRepeatInstance( this ); } } ) // If there is no repeat-count attribute, check how many repeat instances // are in the model, and update view if necessary. .get() .forEach( that.updateViewInstancesFromModel.bind( this ) ); // delegated handlers (strictly speaking not required, but checked for doubling of events -> OK) this.form.view.$.on( 'click', 'button.add-repeat-btn:enabled', function() { // Create a clone that.add( $( this ).closest( '.or-repeat-info' )[ 0 ] ); // Prevent default return false; } ); this.form.view.$.on( 'click', 'button.remove:enabled', function() { that.confirmDelete( this.closest( '.or-repeat' ) ); //prevent default return false; } ); this.countUpdate(); return true; }, // Make this function overwritable confirmDelete( repeatEl ) { const that = this; dialog.confirm( { heading: t( 'confirm.repeatremove.heading' ), msg: t( 'confirm.repeatremove.msg' ) } ) .then( confirmed => { if ( confirmed ) { //remove clone that.remove( $( repeatEl ) ); } } ) .catch( console.error ); }, /* * Obtains the 0-based absolute index of the provided repeat or repeat-info element * The goal of this function is to make non-nested repeat index determination as fast as possible. * * In nested cases, the "absolute index" for a repeat instance refers to the index across all repeat * instances with that name regardless of nesting (the repeat structure is conceptually flattened). * There is one repeat-info element for each sequences of repeats of the given name. The "absolute index" * of a repeat-info in nested cases refers to the index across all sequences of repeat instances with that name. * * The repeat-info concept was added in the context of supporting zero instances of a repeat. It would be good * to expand on its documentation. */ getIndex( el ) { if ( !el || !this.form.repeatsPresent ) { return 0; } const isInfoElement = el.classList.contains( 'or-repeat-info' ); const toCountSelector = isInfoElement ? `.or-repeat-info[data-name="${el.dataset.name}"]` : `.or-repeat[name="${el.getAttribute( 'name' )}"]`; let predecessorCount = isInfoElement ? 0 : Number( el.querySelector( '.repeat-number' ).textContent ) - 1; let checkEl = el; while ( checkEl ) { while ( checkEl.previousElementSibling && checkEl.previousElementSibling.matches( '.or-repeat' ) ) { checkEl = checkEl.previousElementSibling; predecessorCount += checkEl.querySelectorAll( toCountSelector ).length; } const parent = checkEl.parentElement; checkEl = parent ? parent.closest( '.or-repeat' ) : null; } return predecessorCount; }, /** * [updateViewInstancesFromModel description] * * @param {Element} repeatInfo - repeatInfo element * @return {number} length of repeat series in model */ updateViewInstancesFromModel( repeatInfo ) { const repeatPath = repeatInfo.dataset.name; // All we need is to find out in which series we are. const repeatSeriesIndex = this.getIndex( repeatInfo ); const repInModelSeries = this.form.model.getRepeatSeries( repeatPath, repeatSeriesIndex ); const repInViewSeries = getSiblingElements( repeatInfo, '.or-repeat' ); // First rep is already included (by XSLT transformation) if ( repInModelSeries.length > repInViewSeries.length ) { this.add( repeatInfo, repInModelSeries.length - repInViewSeries.length, 'model' ); // Now check the repeat counts of all the descendants of this repeat and its new siblings // Note: not tested with triple-nested repeats, but probably taking the better safe-than-sorry approach, // so should be okay except for performance. getSiblingElements( repeatInfo, '.or-repeat' ) .reduce( ( acc, current ) => acc.concat( [ ...current.querySelectorAll( '.or-repeat-info:not([data-repeat-count])' ) ] ), [] ) .forEach( this.updateViewInstancesFromModel.bind( this ) ); } return repInModelSeries.length; }, /** * [updateDefaultFirstRepeatInstance description] * * @param {Element} repeatInfo - repeatInfo element */ updateDefaultFirstRepeatInstance( repeatInfo ) { const repeatPath = repeatInfo.dataset.name; if ( !this.form.model.data.instanceStr && !this.templates[ repeatPath ].classList.contains( 'or-appearance-minimal' ) ) { const repeatSeriesIndex = this.getIndex( repeatInfo ); const repeatSeriesInModel = this.form.model.getRepeatSeries( repeatPath, repeatSeriesIndex ); if ( repeatSeriesInModel.length === 0 ) { this.add( repeatInfo, 1, 'magic' ); } getSiblingElements( repeatInfo, '.or-repeat' ) .reduce( ( acc, current ) => acc.concat( [ ...current.querySelectorAll( '.or-repeat-info:not([data-repeat-count])' ) ] ), [] ) .forEach( this.updateDefaultFirstRepeatInstance.bind( this ) ); } }, /** * [updateRepeatInstancesFromCount description] * * @param {Element} repeatInfo - repeatInfo element */ updateRepeatInstancesFromCount( repeatInfo ) { const repCountPath = repeatInfo.dataset.repeatCount || ''; if ( !repCountPath ) { return; } /* * We cannot pass an .or-repeat context to model.evaluate() if the number or repeats in a series is zero. * However, but we do still need a context for nested repeats where the count of the nested repeat * is determined in a node inside the parent repeat. To do so we use the repeat comment in model as context. */ const repPath = repeatInfo.dataset.name; let numRepsInCount = this.form.model.evaluate( repCountPath, 'number', this.form.model.getRepeatCommentSelector( repPath ), this.getIndex( repeatInfo ), true ); numRepsInCount = isNaN( numRepsInCount ) ? 0 : numRepsInCount; const numRepsInView = getSiblingElements( repeatInfo, `.or-repeat[name="${repPath}"]` ).length; let toCreate = numRepsInCount - numRepsInView; if ( toCreate > 0 ) { this.add( repeatInfo, toCreate, 'count' ); } else if ( toCreate < 0 ) { toCreate = Math.abs( toCreate ) >= numRepsInView ? -numRepsInView + ( disableFirstRepeatRemoval ? 1 : 0 ) : toCreate; for ( ; toCreate < 0; toCreate++ ) { const $last = $( repeatInfo ).siblings( '.or-repeat' ).last(); this.remove( $last ); } } // Now check the repeat counts of all the descendants of this repeat and its new siblings, level-by-level. // TODO: this does not find .or-repeat > .or-repeat (= unusual syntax) getSiblingElementsAndSelf( repeatInfo, '.or-repeat' ) .reduce( ( acc, current ) => acc.concat( getChildren( current, '.or-group, .or-group-data' ) ), [] ) .reduce( ( acc, current ) => acc.concat( getChildren( current, '.or-repeat-info[data-repeat-count]' ) ), [] ) .forEach( this.updateRepeatInstancesFromCount.bind( this ) ); }, /** * Checks whether repeat count value has been updated and updates repeat instances * accordingly. * * @param {UpdatedDataNodes} updated - The object containing info on updated data nodes. */ countUpdate( updated = {} ) { const repeatInfos = this.form.getRelatedNodes( 'data-repeat-count', '.or-repeat-info', updated ).get(); repeatInfos.forEach( this.updateRepeatInstancesFromCount.bind( this ) ); }, /** * Clone a repeat group/node. * * @param {Element} repeatInfo - A repeatInfo element. * @param {number=} toCreate - Number of clones to create. * @param {string=} trigger - The trigger ('magic', 'user', 'count', 'model') * @return {boolean} Cloning success/failure outcome. */ add( repeatInfo, toCreate = 1, trigger = 'user' ) { if ( !repeatInfo ) { console.error( 'Nothing to clone' ); return false; } let repeatIndex; const repeatPath = repeatInfo.dataset.name; let repeats = getSiblingElements( repeatInfo, '.or-repeat' ); let clone = this.templates[ repeatPath ].cloneNode( true ); // Determine the index of the repeat series. let repeatSeriesIndex = this.getIndex( repeatInfo ); let modelRepeatSeriesLength = this.form.model.getRepeatSeries( repeatPath, repeatSeriesIndex ).length; // Determine the index of the repeat inside its series const prevSibling = repeatInfo.previousElementSibling; let repeatIndexInSeries = prevSibling && prevSibling.classList.contains( 'or-repeat' ) ? Number( prevSibling.querySelector( '.repeat-number' ).textContent ) : 0; // Add required number of repeats for ( let i = 0; i < toCreate; i++ ) { // Fix names of radio button groups clone.querySelectorAll( '.option-wrapper' ).forEach( this.fixRadioName ); this.processDatalists( clone.querySelectorAll( 'datalist' ), repeatInfo ); // Insert the clone repeatInfo.parentElement.insertBefore( clone, repeatInfo ); if ( repeatIndexInSeries > 0 ) { // Also add the clone class for all 2+ numbers as this is // used for performance optimization in several places. clone.classList.add( 'clone' ); } // Update the repeat number clone.querySelector( '.repeat-number' ).textContent = repeatIndexInSeries + 1; // Update the variable containing the view repeats in the current series. repeats.push( clone ); // Create a repeat in the model if it doesn't already exist if ( repeats.length > modelRepeatSeriesLength ) { this.form.model.addRepeat( repeatPath, repeatSeriesIndex ); modelRepeatSeriesLength++; } // This is the index of the new repeat in relation to all other repeats of the same name, // even if they are in different series. repeatIndex = repeatIndex || this.getIndex( clone ); const updated = { repeatIndex, repeatPath, trigger, cloned: true }; // The odk-new-repeat event (before the event that triggers re-calculations etc) if ( trigger === 'user' || trigger === 'count' ) { clone.dispatchEvent( events.NewRepeat( updated ) ); } // This will trigger setting default values, calculations, readonly, relevancy, language updates, and automatic page flips. clone.dispatchEvent( events.AddRepeat( updated ) ); // Initialize widgets in clone after default values have been set if ( this.form.widgetsInitialized ) { this.form.widgets.init( $( clone ), this.form.options ); } // now create the first instance of any nested repeats if necessary clone.querySelectorAll( '.or-repeat-info:not([data-repeat-count])' ) .forEach( this.updateDefaultFirstRepeatInstance.bind( this ) ); clone = this.templates[ repeatPath ].cloneNode( true ); repeatIndex++; repeatIndexInSeries++; } // enable or disable + and - buttons this.toggleButtons( repeatInfo ); return true; }, remove( $repeat ) { const that = this; const $next = $repeat.next( '.or-repeat, .or-repeat-info' ); const repeatPath = $repeat.attr( 'name' ); const repeatIndex = this.getIndex( $repeat[ 0 ] ); const repeatInfo = $repeat.siblings( '.or-repeat-info' )[ 0 ]; $repeat.remove(); that.numberRepeats( repeatInfo ); that.toggleButtons( repeatInfo ); // Trigger the removerepeat on the next repeat or repeat-info(always present) // so that removerepeat handlers know where the repeat was removed $next[ 0 ].dispatchEvent( events.RemoveRepeat() ); // Now remove the data node that.form.model.node( repeatPath, repeatIndex ).remove(); }, fixRadioName( element ) { const random = Math.floor( ( Math.random() * 10000000 ) + 1 ); element.querySelectorAll( 'input[type="radio"]' ) .forEach( el => { el.setAttribute( 'name', random ); } ); }, fixDatalistId( element ) { const newId = element.id + Math.floor( ( Math.random() * 10000000 ) + 1 ); element.parentNode.querySelector( `input[list="${element.id}"]` ).setAttribute( 'list', newId ); element.id = newId; }, processDatalists( datalists, repeatInfo ) { datalists.forEach( datalist => { const template = datalist.querySelector( '.itemset-template[data-items-path]' ); const expr = template ? template.dataset.itemsPath : null; if ( !isStaticItemsetFromSecondaryInstance( expr ) ) { this.fixDatalistId( datalist ); } else { const id = datalist.id; const input = getSiblingElement( datalist, 'input[list]' ); if ( input ) { // For very long static datalists, a huge performance improvement can be achieved, by using the // same datalist for all repeat instances that use it. if ( this.staticLists.includes( id ) ) { datalist.remove(); } else { // Let all identical input[list] questions amongst all repeat instances use the same // datalist by moving it under repeatInfo. // It will survive removal of all repeat instances. const parent = datalist.parentElement; const name = input.name; const dl = parent.querySelector( 'datalist' ); const detachedList = parent.removeChild( dl ); detachedList.setAttribute( 'data-name', name ); repeatInfo.appendChild( detachedList ); const translations = parent.querySelector( '.or-option-translations' ); const detachedTranslations = parent.removeChild( translations ); detachedTranslations.setAttribute( 'data-name', name ); repeatInfo.appendChild( detachedTranslations ); const labels = parent.querySelector( '.itemset-labels' ); const detachedLabels = parent.removeChild( labels ); detachedLabels.setAttribute( 'data-name', name ); repeatInfo.appendChild( detachedLabels ); this.staticLists.push( id ); //input.classList.add( 'shared' ); } } } } ); }, toggleButtons( repeatInfo ) { $( repeatInfo ) .siblings( '.or-repeat' ) .children( '.repeat-buttons' ) .find( 'button.remove' ) .prop( 'disabled', false ) .first() .prop( 'disabled', disableFirstRepeatRemoval ); }, numberRepeats( repeatInfo ) { $( repeatInfo ) .siblings( '.or-repeat' ) .each( ( idx, repeat ) => { $( repeat ).children( '.repeat-number' ).text( idx + 1 ); } ); } };