Deep dive: Generate repeatable groups from associated records
| Warning | Work in progress! We are in the process of updating the information on this page. Subject to change. |
| Deep dives |
|---|
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 based on the number of related incidents. Each group contains a lookup question filled with an incident.
Execution
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: [script]
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 prepare the questionnaire. To demonstrate how to call custom function from questionnaires, see how it is done in the attached sample template Aggregated Repeated Group v3.zip. Alternatively, you can do the same to reuse the function in your questionnaire: just adjust the values of shared variables to the names of your repeating group and aggregated question.
- 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.
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.
- LoadWorkorderIncident
- 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
const CHECKLIST_STEP_GROUP_NAME = "group";
const WO_INCIDENT_ENTITY_NAME = "msdyn_workorderincident";
const WO_INCIDENT_LOOKUP_QUESTION_NAME = "work-order-inlookupcident";
const WORKORDER_LOOKUP_QUESTION_NAME = "resco_workorder";
let templateGroupId = null;
let loadedWOIncidents = [];
let workOrderId = null;
const LOCALIZED_LOADING_MESSAGE = "Loading..."; // can be load from MobileCRM.Localization
let contentDefinitions = [];
let waitDialog = null;
const QUESTION_NAMES = {
INSTRUCTIONS: "-label-and-description", // question1, "-label-and-description" is the name of the question defined in the Questionnaire Designer
// add more question which content should be loaded from the step definition
}
// 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 workOrderValue = questionnaireForm.questions.find(q => q.name == WORKORDER_LOOKUP_QUESTION_NAME);
workOrderId = workOrderValue.value.id;
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);
}
}
/**
* Load the group id of the template group. Note that the group id will change with newer verstion of the template.
* @param {string} templateId the id of the current questionnaire template
*/
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", 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() {
LoadWorkOrderIncidents().then((checkListSteps) => {
checkListSteps.forEach((step) => {
loadedWOIncidents.push(step);
});
if (checkListSteps.length > 0) {
MobileCRM.UI.QuestionnaireForm.requestObject(function (questionnaireForm) {
let repeatableGroup = questionnaireForm.groups.find(g => g.templateGroup == templateGroupId);
let repeatableGroupAll = questionnaireForm.groups.filter(g => g.templateGroup == templateGroupId);
let repeatableGroupLastIndex = questionnaireForm.groups.find(g => g.repeatIndex == checkListSteps.length);
if (repeatableGroupLastIndex == undefined) {
repeatableGroup.repeatGroup(false, function () {
//two groups already exist, therefore lenght -2
repeatNext(checkListSteps.length - 2);
}, onError, null);
}
}, function (err) {
onError("Error QuestionnaireForm requestObject: " + err);
closeWaitDialog();
}, null);
}
}).catch((error) => {
onError(error);
});
}
/**
* Repeats the recursively
* @param {number} count the number of times the group should be repeated
*/
/**
* Repeats the recursively
* @param {number} count the number of times the group should be repeated
*/
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
{
// affter all groups are repeated, set the values
setGroupValues();
}
}
function LoadWorkOrderIncidents() {
return new Promise((resolve, reject) => {
var entity = new MobileCRM.FetchXml.Entity(WO_INCIDENT_ENTITY_NAME);
entity.addAttributes();
var filter = new MobileCRM.FetchXml.Filter();
filter.where("msdyn_workorder", "eq", workOrderId);
entity.filter = filter;
var fetch = new MobileCRM.FetchXml.Fetch(entity);
fetch.execute(
"DynamicEntities",
function (results) {
if (results.length > 0) {
resolve(results);
} else {
onError("No work order incidents found for the given work order.");
closeWaitDialog();
}
},
function (error) {
onError("Error fetching work order incidents: " + error);
closeWaitDialog();
},
null
);
});
}
/**
* Load the checklist steps from the definition entity
*/
function setGroupValues() {
MobileCRM.UI.QuestionnaireForm.requestObject(function (questionnaireForm) {
for (let i = 0; i < loadedWOIncidents.length; i++) {
try {
let currentWorkOrderIncident = loadedWOIncidents[i];
let currentWorkOrderIncidentReference = new MobileCRM.Reference(currentWorkOrderIncident.entityName, currentWorkOrderIncident.id, currentWorkOrderIncident.primaryName);
//let group = questionnaireForm.groups.find(g => g.id == _repeatable_roup_id);
// find the group with the lowest index
let newGroup = questionnaireForm.groups.find(g => g.repeatIndex == i + 1);
// construct the group index suffix. e.g. #001, #002, #003, ...
// "-label-and-description#001"
// "-label-and-description#002", ...
// this index suffix is added to the question name to identify the question in the group with that index as e.g., "<resco_questionname>#003"
let currentGroupIndex = `#${String(newGroup.repeatIndex).padStart(3, '0')}`;
//find specific question within the group by name and group id
let workOrderIncidentQuestion = questionnaireForm.questions.find(q => q.groupId == newGroup.id && q.name == WO_INCIDENT_LOOKUP_QUESTION_NAME + currentGroupIndex);
if (workOrderIncidentQuestion != null) {
workOrderIncidentQuestion.trySetAnswer(currentWorkOrderIncidentReference, function (error) {
onError("Error setting asset question answer: " + error);
}, null);
}
/*
prefill more questions here
let <ABC_QUESTION> = questionnaireForm.questions.find(q => q.groupId == newGroup.id && q.name == QUESTION_NAMES.ABC_QUESTION + currentGroupIndex);
if (ABC_QUESTION != null)
ABC_QUESTION.label = groupDefinition.ABC_QUESTIONVALUE;
*/
} catch (error) {
onError(error);
closeWaitDialog();
}
}
MobileCRM.UI.QuestionnaireForm.focusQuestion("instructions", onError, null);
closeWaitDialog();
}, function (err) {
onError("Error QuestionnaireForm requestObject: " + err);
closeWaitDialog();
}, null);
}
