Deep dive: Generate repeatable groups from associated records
In this guide, we will demonstrate how a custom JavaScript function can be used to generate as many repeatable groups as there are associated records of the parent. This use case showcases a scenario where we open a questionnaire directly from a work order, with as many repeatable groups as there are associated work order incidents. These repeatable groups are automatically generated. Each group contains a lookup question filled with an incident.
Execution
This is how the script execution looks like in the app:
Prerequisites
For this script to work, your questionnaire must contain the following:
- Workorder lookup question.
- Repeatable group with workorder lookup incident question.
If your scenario requires different fields or questions, you must redefine the script variables and fetches. Additionally, if you want to work with multiple questions in the repeatable group, these have to be predefined beforehand.
Download sample files
For your convenience, we are providing you sample file:
File:GenerateRepeatableGroupsScript.zip - JavaScript code
Set up your app project
In this section, we add the JavaScript code to your app project.
- Start Woodford and edit the app project used for opening the questionnaire.
- Go to Components > Offline HTML.
- Upload the attached file [script].html or use New File template and paste the script above inside the <script></script> tags. If you already have an HTML file for your questionnaire, just add the function to that file.

- Verify that the Offline HTML folder also includes an updated version of JSBridge.js (used for facilitating the communication between our custom script and the app). Also, the uploaded script must link that file. See Including the JSBridge.js file for detailed instructions.
- Save all changes and publish the app project.
Set up the questionnaire
Finally, we need to add the script reference to the questionnaire.
- Start Questionnaire Designer.
- Click Import and select the downloaded questionnaire.
- Edit the questionnaire and verify the following settings. If you are using your own, custom questionnaire, use these as inspiration for recreating the needed functionality.
- Go to questionnaire Options and on the Rules tab, set Script Path to generateRepeatableGroups.html.

About the script
It consists of two main async functions, repeatGroup and trySetAnswer. To successfully generate repeatable groups and then fill them with data, we have to do each step separately. The reason is that if we did this in sequence, trySetAnswer might try to set an answer for a group that doesn't yet exist. Therefore, we use successCallback of the repeatGroup to call trySetAnswer.
Execution order
These are the main functions used in the script:
- generateQuestionnaireContent
- loads the questionnaire context
- Initializes loadTemplateGroupId and generates groups
- loadTemplateGroupId
- finds the ID of the group we want to repeat
- generateGroups
- Initializes LoadWorkorderIncidents and repeatNext, by invoking repeatGroupAsync, we already have two instances of group, therefore we subtract 2.
- LoadIncidents
- Fetches and returns dynamic entities with incidents.
- repeatNext
- generates as many repeatable groups as there are incidents
- initializes setGroupValues.
- setGroupValues
- fills in the questions from the incident into already created repeatable groups.
Script
// CONFIGURABLE PARAMETERS - Customize these for different entity types
const PARAMS = {
// name of the question group defined in Questionnaire Designer
checklist_step_group_name: "group",
// entity name containing the incident/task items (e.g., "task", "incident", "resco_incident")
incident_entity_name: "task",
// lookup question name for the incident entity within the group
incident_lookup_question_name: "lookup",
// parent lookup question name that links to the parent entity (e.g., "resco_regardingid" for workorder)
parent_lookup_question_name: "resco_regardingid",
// relationship field name used in FetchXml filter (e.g., "msdyn_workorder" for workorder)
parent_relationship_field_name: "regardingobjectid",
};
let templateGroupId = null;
let loadedIncidents = [];
let parentEntityId = null;
const LOCALIZED_LOADING_MESSAGE = "Loading..."; // can be load from MobileCRM.Localization
let waitDialog = null;
// wait dialog needs to be always closed, otherwise the UI will be blocked
function closeWaitDialog() {
if (waitDialog !== null) {
waitDialog.close();
waitDialog = null;
}
}
function onError(text) {
closeWaitDialog();
MobileCRM.bridge.alert(text);
}
window.onload = function () {
initialize();
}
function initialize() {
waitDialog = MobileCRM.UI.Form.showPleaseWait(LOCALIZED_LOADING_MESSAGE);
generateQuestionnaireContent();
}
function generateQuestionnaireContent() {
try {
MobileCRM.UI.QuestionnaireForm.requestObject(function (questionnaireForm) {
let questionnaire = questionnaireForm.questionnaire;
let parentEntityValue = questionnaireForm.questions.find(q => q.name == PARAMS.parent_lookup_question_name);
parentEntityId = parentEntityValue.value.id;
if (parentEntityId == null) {
onError("Parent entity reference is null.");
return;
}
loadTemplateGroupId(questionnaire.properties.resco_templateid.id)
.then((loadedTemplateGroupId) => {
templateGroupId = loadedTemplateGroupId;
generateGroups();
})
.catch((error) => {
onError(error);
});
}, function (err) {
onError("Error QuestionnaireForm requestObject: " + err);
}, null);
} catch (error) {
onError(error);
}
}
function loadTemplateGroupId(templateId) {
return new Promise((resolve, reject) => {
var entity = new MobileCRM.FetchXml.Entity("resco_questiongroup");
entity.addAttribute("resco_questiongroupid");
var filter = new MobileCRM.FetchXml.Filter();
filter.where("resco_name", "eq", PARAMS.checklist_step_group_name);
filter.where("resco_questionnaireid", "eq", templateId);
entity.filter = filter;
var fetch = new MobileCRM.FetchXml.Fetch(entity);
fetch.execute(
"Array",
function (result) {
if (result.length > 0 && result[0][0] != undefined) {
var groupId = result[0][0];
resolve(groupId);
} else {
onError("Template group not found.");
}
},
function (error) {
onError("Error fetching question group: " + error
);
},
null
);
});
}
function generateGroups() {
LoadIncidents().then((checkListSteps) => {
checkListSteps.forEach((step) => {
loadedIncidents.push(step);
});
if (checkListSteps.length > 0) {
MobileCRM.UI.QuestionnaireForm.requestObject(function (questionnaireForm) {
let repeatableGroup = questionnaireForm.groups.find(g => g.templateGroup == templateGroupId);
let repeatableGroupLastIndex = questionnaireForm.groups.find(g => g.repeatIndex == checkListSteps.length);
if (repeatableGroupLastIndex == undefined) {
repeatableGroup.repeatGroupAsync()
.then(() => {
//two groups already exist, therefore lenght -2
repeatNext(checkListSteps.length - 2);
})
.catch((err) => {
onError("Error repeating group: " + err);
closeWaitDialog();
});
}
}, function (err) {
onError("Error QuestionnaireForm requestObject: " + err);
closeWaitDialog();
}, null);
} else {
// No incidents to load, close wait dialog
closeWaitDialog();
}
}).catch((error) => {
onError(error);
closeWaitDialog();
});
}
function repeatNext(count) {
if (count > 0) {
MobileCRM.UI.QuestionnaireForm.requestObject(function (questionnaireForm) {
let repeatableGroup = questionnaireForm.groups.find(g => g.templateGroup == templateGroupId);
repeatableGroup.repeatGroupAsync()
.then(() => {
repeatNext(count - 1)
})
.catch(err => {
onError("Error QuestionnaireForm requestObject: " + err);
closeWaitDialog();
})
}, function (err) {
onError("Error QuestionnaireForm requestObject: " + err);
closeWaitDialog();
}, null);
}
else
{
// after all groups are repeated, set the values
setGroupValues();
}
}
function LoadIncidents() {
return new Promise((resolve, reject) => {
var entity = new MobileCRM.FetchXml.Entity(PARAMS.incident_entity_name);
entity.addAttributes();
var filter = new MobileCRM.FetchXml.Filter();
filter.where(PARAMS.parent_relationship_field_name, "eq", parentEntityId);
entity.filter = filter;
var fetch = new MobileCRM.FetchXml.Fetch(entity);
fetch.execute(
"DynamicEntities",
function (results) {
if (results.length > 0) {
resolve(results);
} else {
onError("No incidents found for the given parent entity.");
}
},
function (error) {
onError("Error fetching incidents: " + error);
},
null
);
});
}
//Load the checklist steps from the definition entity
function setGroupValues() {
MobileCRM.UI.QuestionnaireForm.requestObject(function (questionnaireForm) {
for (let i = 0; i < loadedIncidents.length; i++) {
try {
let currentIncident = loadedIncidents[i];
let currentIncidentReference = new MobileCRM.Reference(currentIncident.entityName, currentIncident.id, currentIncident.primaryName);
let newGroup = questionnaireForm.groups.find(g => g.templateGroup == templateGroupId && g.repeatIndex == i + 1);
let currentGroupIndex = `#${String(newGroup.repeatIndex).padStart(3, '0')}`;
let incidentQuestion = questionnaireForm.questions.find(q => q.groupId == newGroup.id && q.name == PARAMS.incident_lookup_question_name + currentGroupIndex);
if (incidentQuestion != null) {
incidentQuestion.trySetAnswer(currentIncidentReference, function (error) {
onError("Error setting incident question answer: " + error);
}, null);
}
} catch (error) {
onError(error);
closeWaitDialog();
}
}
closeWaitDialog();
MobileCRM.bridge.alert("There were " + loadedIncidents.length + " incidents loaded.");
}, function (err) {
onError("Error QuestionnaireForm requestObject: " + err);
closeWaitDialog();
}, null);
}