Deep dive: Create records from repeatable groups
In this guide, we will demonstrate how custom JavaScript can create records from repeatable group instances in Questionnaire. This use case showcases the creation of Incidents. Each repeatable group instance includes an Asset lookup and a short-text question. In the newly created Incident, we want to see the parent Account, Questionnaire where it originated, associated Asset, and the short-text question as a title.
Execution
This is how the script execution looks like in the app:
Prerequisites
For this script to function properly, the following conditions must be met:
- Each question from the repeatable group you want to save into a record must exist as a field in the destination table (Asset lookup question --> Asset lookup field in the Incident).
- Be wary of server logic when creating new records. All required fields must be filled in. Otherwise, the sync will fail, and records won't be uploaded.
- Data type
Each question must be stored in a field that corresponds to its specific data type:
- Lookup question → Lookup field.
- Date and time question → Date and time field.
- Option set question → Option set field (values in the option set must match accordingly in order to be saved correctly).
Download sample files
For your convenience, we are providing you sample file:
File:CreateFromRepeatableGroups.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 or create dedicated folder for questionnaire scripts.
- 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 createFromRepeatableGroups.html (if the script is directly in root) or file://Questionnaire/createFromRepeatableGroups.html (if in subfolder).

About the script
The whole script can be defined via the CONFIG object. It consists of general properties like childEntity and questionGroupName, field mapping, and trigger config. Trigger config allows record creation to be optional. This can be used when not every repeatable group instance should be translated into a new record.
Script
const CONFIG = {
childEntity: "incident",
questionGroupName: "case",
// trigger configuration for creating records
trigger: {
enabled: false, // set to false to disable trigger check
questionName: "create-case", // question to check within each group
expectedAnswer: true, // answer that triggers record creation
},
fieldsMapping: {
questionnaireLookupField: "crbb7_questionnaire",
staticFields: [
{
questionName: "parentq", // question name in questionnaire
questionField: "customerid", // field on child entity
},
],
// Map question names to entity fields
// Example: { questionName: "question-name", fieldName: "entity_field" }
dynamicFields: [
{
questionName: "asset",
fieldName: "crbb7_asset",
},
{
questionName: "subject",
fieldName: "title",
},
{
questionName: "another-question-name",
fieldName: "msdyn_field",
},
{
questionName: "option-set",
fieldName: "casetypecode",
},
{
questionName: "date-and-time",
fieldName: "followupby",
}
],
}
};
let templateGroupId = null;
let loadedGroupInstances = [];
function onError(text) {
MobileCRM.bridge.alert("Error: " + text);
}
window.onload = function () {
initialize();
}
function initialize() {
MobileCRM.UI.QuestionnaireForm.onSave(onQuestionnaireSave,true,null);
}
async function onQuestionnaireSave(questionnaireForm) {
try {
const questionnaire = questionnaireForm.questionnaire;
templateGroupId = await loadTemplateGroupId(questionnaire.properties.resco_templateid.id);
processRepeatableGroups(questionnaireForm);
} catch (error) {
onError(error);
}
}
/**
* Load the group id of the template group
* @param {string} templateId the id of the current questionnaire template
*/
async function loadTemplateGroupId(templateId) {
return new Promise((resolve, reject) => {
const entity = new MobileCRM.FetchXml.Entity("resco_questiongroup");
entity.addAttribute("resco_questiongroupid");
const filter = new MobileCRM.FetchXml.Filter();
filter.where("resco_name", "eq", CONFIG.questionGroupName);
filter.where("resco_questionnaireid", "eq", templateId);
entity.filter = filter;
const fetch = new MobileCRM.FetchXml.Fetch(entity);
fetch.execute(
"Array",
function (result) {
if (result.length > 0 && result[0][0] != undefined) {
const groupId = result[0][0];
resolve(groupId);
} else {
reject("Template group '" + CONFIG.questionGroupName + "' not found.");
}
},
function (error) {
reject("Error fetching question group: " + error);
},
null
);
});
}
/**
* Find all repeatable group instances and extract data from each
*/
function processRepeatableGroups(questionnaireForm) {
try {
const repeatableGroups = questionnaireForm.groups.filter(g => g.templateGroup == templateGroupId);
if (repeatableGroups.length === 0) {
onError("No repeatable group instances found.");
return;
}
// Extract data from each group instance that passes trigger check
repeatableGroups.forEach((group) => {
// Check if group passes trigger condition
if (!passesTriggerCheck(questionnaireForm, group)) {
return; // Skip this group
}
const groupData = extractGroupData(questionnaireForm, group);
loadedGroupInstances.push(groupData);
});
// Build and save records
const records = buildRecords(questionnaireForm, loadedGroupInstances);
saveRecords(records);
} catch (error) {
onError("Error processing repeatable groups: " + error);
}
}
/**
* Extract field data from a repeatable group instance
*/
function extractGroupData(questionnaireForm, group) {
const groupData = {
groupId: group.id,
repeatIndex: group.repeatIndex,
fields: {}
};
// Extract dynamic fields
if (CONFIG.fieldsMapping.dynamicFields && CONFIG.fieldsMapping.dynamicFields.length > 0) {
CONFIG.fieldsMapping.dynamicFields.forEach(({ questionName, fieldName }) => {
// Construct question name with index suffix (e.g., "question-name#001")
const indexedQuestionName = questionName + "#" + String(group.repeatIndex).padStart(3, '0');
const question = questionnaireForm.questions.find(q =>
q.groupId == group.id &&
q.name == indexedQuestionName
);
if (question && question.value !== null) {
groupData.fields[fieldName] = question.value;
}
});
}
return groupData;
}
/**
* Check if a repeatable group instance passes the trigger condition
*/
function passesTriggerCheck(questionnaireForm, group) {
if (!CONFIG.trigger.enabled) {
return true; // If trigger is disabled, all groups pass
}
// Construct question name with index suffix (e.g., "question-name#001")
const indexedQuestionName = CONFIG.trigger.questionName + "#" + String(group.repeatIndex).padStart(3, '0');
// Find the question with the trigger question name in this group
const triggerQuestion = questionnaireForm.questions.find(q =>
q.groupId == group.id &&
q.name == indexedQuestionName
);
if (!triggerQuestion) {
return false; // Question not found, fail the check
}
// Compare the question value with the expected answer
return triggerQuestion.value == CONFIG.trigger.expectedAnswer;
}
/**
* Build DynamicEntity records from extracted group data
*/
function buildRecords(questionnaireForm, groupDataArray) {
const records = [];
try {
const questionnaireId = questionnaireForm.questionnaire.id;
const questionnaireRef = new MobileCRM.Reference(
"resco_questionnaireanswer",
questionnaireId
);
groupDataArray.forEach((groupData) => {
const props = {};
// Set questionnaire lookup field
props[CONFIG.fieldsMapping.questionnaireLookupField] = questionnaireRef;
// Set static fields from config
if (CONFIG.fieldsMapping.staticFields && CONFIG.fieldsMapping.staticFields.length > 0) {
CONFIG.fieldsMapping.staticFields.forEach(fieldMap => {
const question = questionnaireForm.questions.find(q => q.name == fieldMap.questionName);
if (question && question.value !== null) {
props[fieldMap.questionField] = question.value;
}
});
}
// Set dynamic fields
Object.entries(groupData.fields).forEach(([fieldName, fieldValue]) => {
props[fieldName] = fieldValue; // Just copy the pre-extracted values
});
const record = MobileCRM.DynamicEntity.createNew(
CONFIG.childEntity,
null,
null,
props
);
records.push(record);
});
return records;
} catch (error) {
onError("Error building records: " + error);
}
}
/**
* Save all records asynchronously
*/
async function saveRecords(records) {
if (!records || records.length === 0) {
onError("No records to save.");
return Promise.resolve();
}
try {
const savePromises = [];
for (let i = 0; i < records.length; i++) {
savePromises.push(records[i].saveAsync());
}
try {
await Promise.all(savePromises);
MobileCRM.bridge.alert("Records created successfully: " + records.length);
} catch (error_1) {
onError("Error saving records: " + error_1);
throw error_1;
}
} catch (error) {
onError("Error saving records: " + error);
return Promise.reject(error);
}
}