/**
* 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 );
} );
}
};