const _ = require('lodash');
const getForm = () => $('form');
const getValidationErrors = () => getForm()
.find('.invalid-required:not(.disabled), .invalid-constraint:not(.disabled), .invalid-relevant:not(.disabled)')
.children('span.active:not(.question-label)')
.filter(function() {
return $(this).css('display') === 'block';
});
const getSiblingElement = ( element, selector = '*' ) =>{
let found;
let current = element.parentElement.firstElementChild;
while ( current && !found ) {
if ( current !== element && current.matches( selector ) ) {
found = current;
}
current = current.nextElementSibling;
}
return found;
};
// Copied from https://github.com/enketo/enketo-core/blob/master/src/js/page.js
const getPages = () => {
const form = getForm()[0];
if(!form.classList.contains('pages')) {
// This is not a multipage form
return [form];
}
const allPages = [...getForm()[0].querySelectorAll( '[role="page"]' )];
return allPages.filter( el => {
return !el.closest( '.disabled' ) &&
( el.matches( '.question' ) || el.querySelector( '.question:not(.disabled)' ) ||
// or-repeat-info is only considered a page by itself if it has no sibling repeats
// When there are siblings repeats, we use CSS trickery to show the + button underneath the last
// repeat.
( el.matches( '.or-repeat-info' ) && !getSiblingElement( el, '.or-repeat' ) ) );
} );
};
const getCurrentPage = () => {
const pages = getPages();
return pages.find( page => page.classList.contains( 'current' ) ) || pages[pages.length - 1];
};
class FormFiller {
constructor(options) {
this.options = _.defaults(options, {
verbose: true,
});
this.log = (...args) => this.options.verbose && console.log('FormFiller', ...args);
}
/**
* An object describing the result of filling a form.
* @typedef {Object} FillResult
* @property {FillError[]} errors A list of errors
* @property {string} section The page number on which the errors occurred
* @property {Object} report The report object which resulted from submitting the filled report. Undefined if an error blocks form submission.
* @property {Object[]} additionalDocs An array of database documents which are created in addition to the report.
*/
/**
* An object describing an error which has occurred while filling a form.
* @typedef {Object} FillError
* @property {string} type A classification of the error [ 'validation', 'general', 'page' ]
* @property {string} msg Description of the error
*/
async fillForm(multiPageAnswer) {
const { isComplete, errors } = await fillForm(this, multiPageAnswer);
return { isComplete, errors };
}
// Modified from enketo-core/src/js/Form.js validateContent
async getVisibleValidationErrors() {
const validationErrors = getValidationErrors();
return Array.from(validationErrors)
.map(span => ({
type: 'validation',
question: span.parentElement.innerText,
msg: span.innerText,
}));
}
}
const fillForm = async (self, multiPageAnswer) => {
self.log(`Filling form in ${multiPageAnswer.length} pages.`);
const results = [];
for (const pageIndex in multiPageAnswer) {
const pageAnswer = multiPageAnswer[pageIndex];
const result = await fillPage(self, pageAnswer);
results.push(result);
if (result.errors.length > 0) {
return {
errors: result.errors,
section: `page-${pageIndex}`,
answers: pageAnswer,
};
}
}
let errors;
let isComplete;
let pageHasAdvanced;
// attempt to submit all the way to the end (replacement for validateAll)
do {
pageHasAdvanced = await nextPage();
errors = await self.getVisibleValidationErrors();
const pages = getPages();
isComplete = pages.indexOf(getCurrentPage()) === pages.length - 1;
} while (pageHasAdvanced && !isComplete && !errors.length);
const incompleteError = isComplete ? [] : [{ type: 'general', msg: 'Form is incomplete' }];
return {
isComplete,
errors: [...incompleteError, ...errors],
};
};
const fillPage = async (self, pageAnswer) => {
self.log(`Answering ${pageAnswer.length} questions.`);
const answeredQuestions = new Set();
for (let i = 0; i < pageAnswer.length; i++) {
const answer = pageAnswer[i];
const $questions = getVisibleQuestions();
if ($questions.length <= i) {
return {
errors: [{
type: 'page',
answers: pageAnswer,
section: `answer-${i}`,
msg: `Attempted to fill ${pageAnswer.length} questions, but only ${$questions.length} are visible.`,
}],
};
}
const nextUnansweredQuestion = Array.from($questions).find(question => !answeredQuestions.has(question));
answeredQuestions.add(nextUnansweredQuestion);
fillQuestion(nextUnansweredQuestion, answer);
}
const allPagesSuccessful = await nextPage();
const validationErrors = await self.getVisibleValidationErrors();
const advanceFailure = allPagesSuccessful || validationErrors.length ? [] : [{
type: 'general',
msg: 'Failed to advance to next page',
}];
return {
errors: [...advanceFailure, ...validationErrors],
};
};
const fillQuestion = (question, answer) => {
if(answer === null || answer === undefined) {
return;
}
const $question = $(question);
const allInputs = $question.find('input:not([type="hidden"]),textarea,button,select');
const firstInput = Array.from(allInputs)[0];
if (!firstInput) {
throw 'No input field found within question';
}
if (firstInput.localName === 'textarea') {
return allInputs.val(answer).trigger('change');
}
switch (firstInput.type) {
case 'button':
// select_one appearance:minimal
if (firstInput.className.includes('dropdown-toggle')) {
$question.find(`input[value="${answer}"]:not([checked="checked"])`).click();
}
// repeate section
else {
if (!Number.isInteger(answer)) {
throw `Failed to answer question which is a "+" for repeat section. This question expects an answer which is an integer - representing how many times to click the +. "${answer}"`;
}
for (let i = 0; i < answer; ++i) {
allInputs.click();
}
}
break;
case 'radio':
$question.find(`input[value="${answer}"]`).click();
break;
case 'date':
case 'tel':
case 'number':
allInputs.val(answer).trigger('change');
break;
case 'text':
if (allInputs.eq(0).parents('.datetimepicker').length) {
const [date, time] = answer.split(' ', 2);
if (!time) {
throw new Error('Elements of type datetime expect input in format: "2022-12-31 13:21"');
}
allInputs.eq(0).datepicker('setDate', date);
allInputs.eq(1).val(time).trigger('change');
} else if (allInputs.eq(0).parents('.timepicker').length) {
allInputs.eq(0).timepicker('setTime', answer);
} else if (allInputs.parent().hasClass('date')) {
allInputs.first().datepicker('setDate', answer);
} else {
allInputs.val(answer).trigger('change');
}
break;
case 'checkbox': {
/*
There are two accepted formats for multi-select checkboxes
Option 1 - A set of comma-delimited boolean strings representing the state of the boxes. eg. "true,false,true" checks the first and third box
Option 2 - A set of comma-delimited values to be checked. eg. "heart_condition,none" checks the two boxes with corresponding values
*/
const answerArray = Array.isArray(answer) ? answer.map(answer => answer.toString()) : answer.split(',');
const isNonBooleanString = str => !str || !['true', 'false'].includes(str.toLowerCase());
const answerContainsSpecificValues = answerArray.some(isNonBooleanString);
// [value != ""] is necessary because blank lines in `choices` table of xlsx can cause empty unrendered input
const options = $question.find('input[value!=""]');
if (!answerContainsSpecificValues) {
answerArray.forEach((val, index) => {
const propValue = val === true || val.toLowerCase() === 'true' ? 'checked' : '';
$(options[index]).prop('checked', propValue).trigger('change');
});
} else {
options.prop('checked', '');
answerArray.forEach(val => $question.find(`input[value="${val}"]`).prop('checked', 'checked').trigger('change'));
}
break;
}
case 'select-one':
allInputs.val(answer).trigger('change');
break;
default:
throw `Unhandled input type ${firstInput.type}`;
}
};
const getVisibleQuestions = () => {
const currentPage = $(getCurrentPage());
if (!currentPage) {
throw Error('Form has no active pages');
}
if (currentPage.hasClass('question')) {
return currentPage;
}
const findQuestionsInSection = section => {
const inquisitiveChildren = Array.from($(section)
.children(`
section:not(.disabled,.or-appearance-hidden,.or-appearance-android-app-launcher),
fieldset:not(.disabled,.note,.or-appearance-hidden,.or-appearance-label,#or-calculated-items),
label:not(.disabled,.readonly,.or-appearance-hidden),
div.or-repeat-info:not(.disabled,.or-appearance-hidden):not([data-repeat-count]),
i,
b
`));
const result = [];
for (const child of inquisitiveChildren) {
const questions = ['section', 'i', 'b'].includes(child.localName) ? findQuestionsInSection(child) : [child];
result.push(...questions);
}
return result;
};
return findQuestionsInSection(currentPage);
};
const nextPage = async () => {
const currentPageIndex = getPages().indexOf(getCurrentPage());
const nextButton = $('button.next-page');
if(nextButton.is(':hidden')) {
return !getValidationErrors().length;
}
return new Promise(resolve => {
const observer = new MutationObserver(() => {
if(getPages().indexOf(getCurrentPage()) > currentPageIndex) {
observer.disconnect();
return resolve(true);
}
if(getValidationErrors().length) {
observer.disconnect();
return resolve(false);
}
});
observer.observe(getForm().get(0), {
childList: true,
subtree: true,
attributeFilter: ['class', 'display'],
});
nextButton.click();
});
};
module.exports = FormFiller;