Advanced Grid & Calendar Patterns
Forsta HX Platform - API Scripting Guide
Advanced Grid & Calendar Patterns
Real-World Production Patterns
These patterns demonstrate advanced techniques used in production Forsta surveys, including cascading show/hide logic, grid manipulation, and calendar date handling.
⚠️ Euro SaaS Platform Note
The patterns below use server-side expressions (syntax: ^...^) which are processed by the Forsta HX Platform. Ensure your Euro SaaS environment supports these expressions. Test thoroughly in your specific environment before deploying.
Pattern 1: Cascading Grid Show/Hide Logic (CQ5 Pattern)
// CASCADING SHOW/HIDE FOR RELATED GRID QUESTIONS
var sQ = 'CQ5'; // Base question prefix
var qA = sQ + 'a'; // CQ5a
var qB = sQ + 'b'; // CQ5b
var qC = sQ + 'c'; // CQ5c
// Get option codes from server-side expression
var arrR = ("^f('CQ5b').domainValues().toString()^").split(',');
// INITIAL SETUP: Hide specific options and disable them
$('#' + qA + '_1, #' + qA + '_998, #' + qC + '_998')
.hide().prop({disabled: true, checked: false})
.parent('td').css({backgroundColor: '#cccccc', pointerEvents: 'none'});
// Conditional initialization
if ($('#autoSelectB').length > 0) {
$('#' + qB + '_1').parent('td').css({pointerEvents: 'none'});
}
if ($('#hideB').length > 0) {
$('#' + qB + '_1').hide().prop({disabled: true, checked: false})
.parent('td').css({backgroundColor: '#cccccc', pointerEvents: 'none'});
}
// Show/Hide Question B based on Question A selections
function showHideB() {
var srt = ($('#autoSelectB').length > 0 || $('#hideB').length > 0) ? 1 : 0;
for (var i = srt; i < arrR.length - 2; i++) {
if ($('#' + qA + '_' + arrR[i]).prop('checked')) {
$('#' + qB + '_' + arrR[i]).hide().prop({disabled: true, checked: false})
.parent('td').css({backgroundColor: '#ffffff', pointerEvents: 'none'});
} else {
$('#' + qB + '_' + arrR[i]).show().prop({disabled: false})
.parent('td').css({pointerEvents: 'auto'});
}
}
}
// Show/Hide Question C based on A and B selections
function showHideC() {
var cnt = 0;
for (var i = 0; i < arrR.length - 2; i++) {
if ($('#' + qA + '_' + arrR[i]).prop('checked') ||
$('#' + qB + '_' + arrR[i]).prop('checked')) {
$('#' + qC + '_' + arrR[i]).show().prop({disabled: false})
.parent('td').css({pointerEvents: 'auto'});
cnt++;
} else {
$('#' + qC + '_' + arrR[i]).hide().prop({disabled: true, checked: false})
.parent('td').css({backgroundColor: '#ffffff', pointerEvents: 'none'});
}
}
// Toggle entire Question C visibility
if (cnt > 0) {
$('#qTxt' + qC).show();
$('th.scale #head' + qC).parent('th').show();
for (var i = 0; i < arrR.length; i++) {
$('#' + qC + '_' + arrR[i]).parent('td').show();
}
$('#' + qC + '_999').show().prop({disabled: false})
.parent('td').css({pointerEvents: 'auto'});
} else {
$('#qTxt' + qC).hide();
$('th.scale #head' + qC).parent('th').hide();
for (var i = 0; i < arrR.length; i++) {
$('#' + qC + '_' + arrR[i]).parent('td').hide();
}
$('#' + qC + '_999').hide().prop({disabled: true, checked: false})
.parent('td').css({backgroundColor: '#ffffff', pointerEvents: 'none'});
}
}
// Wrapper function
function doAll() { showHideB(); showHideC(); }
// Event binding with cell click support
$('input[id*="' + sQ + '"]').on('change', function() { doAll(); })
.parent('td').on('click', function() {
$(this).children('input').trigger('change');
});
doAll(); // Initialize
Pattern 2: Numeric Input with Don't Know Option
// NUMERIC INPUT WITH "DON'T KNOW" CHECKBOX
var sQ = 'CQ7b';
var num = sQ + 'Num'; // Numeric input
var dk = sQ + 'DK'; // Don't Know checkbox
var arrR = ("^f('CQ7bNum').domainValues().toString()^").split(',');
// Setup: Add labels and adjust cell borders
for (var i = 0; i < arrR.length; i++) {
$('#' + num + '_' + arrR[i]).parent('td')
.append($('.numTxt' + sQ).html()).css({borderRight: 'none'});
$('#' + dk + '_' + arrR[i]).parent('td')
.append($('.dkTxt' + sQ).html()).css({borderLeft: 'none'});
}
function dkTick() {
for (var i = 0; i < arrR.length; i++) {
if ($('#' + dk + '_' + arrR[i]).prop('checked')) {
$('#' + num + '_' + arrR[i]).prop({disabled: true, value: ''});
$('#' + dk + '_' + arrR[i]).parent('td').css({color: '#ffffff'});
} else {
$('#' + num + '_' + arrR[i]).prop({disabled: false});
$('#' + dk + '_' + arrR[i]).parent('td').css({color: '#4e4f53'});
}
}
}
function doAll() { dkTick(); }
$('input[id*="' + sQ + '"]').on('change', function() { doAll(); })
.parent('td').on('click', function() {
$(this).children('input').trigger('change');
});
doAll();
Pattern 3: Calendar Date Picker with Age Calculation
// CALENDAR DATE PICKER WITH DYNAMIC DAYS AND AGE CALCULATION
var arrQs = ['AQ1']; // Calendar question prefixes
var arrCoded = ['DK']; // Coded option suffixes
// Filter to existing questions
var arrShownQs = arrQs.filter(function(q) {
return $('input[id*="' + q + '"]').length > 0;
});
arrQs = arrShownQs;
// Build selector arrays
var arrInputSelectors = arrQs.map(function(q) {
return 'input[id*="' + q + '"]';
});
var arrSelectSelectors = arrQs.map(function(q) {
return 'select[id*="' + q + '"]';
});
/**
* isLeapYear - Determines if a year is a leap year
* @param {number} iYear - The year to check (e.g., 2024)
* @returns {boolean} - True if leap year, false otherwise
* @description Uses standard leap year rules:
* - Divisible by 4: potential leap year
* - Divisible by 100 but not 400: NOT a leap year
* - Divisible by 400: leap year
*/
function isLeapYear(iYear) {
if (iYear % 4 != 0) return false; // Not divisible by 4 = not leap
if (iYear % 100 == 0 && iYear % 400 != 0) return false; // Century exception
return true; // All other cases divisible by 4 are leap years
}
/**
* numDays - Returns the number of days in a given month
* @param {string} iMonth - Month number as string ('1'-'12')
* @param {number} iYear - Year (needed for February leap year check)
* @returns {number} - Number of days in the month (28-31)
* @description Returns correct day count accounting for:
* - 31-day months (Jan, Mar, May, Jul, Aug, Oct, Dec)
* - 30-day months (Apr, Jun, Sep, Nov)
* - February (28 or 29 depending on leap year)
*/
function numDays(iMonth, iYear) {
switch (iMonth) {
case '1': case '3': case '5': case '7':
case '8': case '10': case '12': return 31;
case '2': return isLeapYear(iYear) ? 29 : 28;
case '4': case '6': case '9': case '11': return 30;
default: return 31;
}
}
/**
* isCodedInput - Checks if an input ID contains a coded suffix (e.g., 'DK')
* @param {string} sID - The input element ID to check
* @returns {boolean} - True if ID contains any coded suffix from arrCoded
* @description Used to identify special inputs like "Don't Know" checkboxes
* that need special handling separate from main question inputs.
*/
function isCodedInput(sID) {
// Check if any coded suffix exists in the ID string
return arrCoded.some(function(code) {
return sID.indexOf(code) >= 0;
});
}
// Handle Year/Month/Day dropdown changes
for (var i = 0; i < arrSelectSelectors.length; i++) {
$(arrSelectSelectors[i]).on('change', function() {
var iY, iM, $day, idD;
if ($(this).prop('id').indexOf('Year') >= 0) {
iY = $(this).prop('value');
iM = $('#' + $(this).prop('id').replace('Year', 'Month')).prop('value');
idD = $(this).prop('id').replace('Year', 'Day');
$day = $('#' + idD);
} else if ($(this).prop('id').indexOf('Month') >= 0) {
iM = $(this).prop('value');
iY = $('#' + $(this).prop('id').replace('Month', 'Year')).prop('value');
idD = $(this).prop('id').replace('Month', 'Day');
$day = $('#' + idD);
} else if ($(this).prop('id').indexOf('Day') >= 0) {
iM = $('#' + $(this).prop('id').replace('Day', 'Month')).prop('value');
iY = $('#' + $(this).prop('id').replace('Day', 'Year')).prop('value');
$day = $(this);
idD = $day.prop('id');
}
if (iM && iY) {
$day.prop('disabled', false);
var iMax = numDays(iM, iY);
// Show/hide days 29-31 based on month
for (var iDayVal = 29; iDayVal <= 31; iDayVal++) {
var s = "#" + idD + " option[value='" + iDayVal + "']";
if (iDayVal > iMax) {
if (!$(s).eq(0).parent('.hideoption').length > 0) {
$(s).eq(0).wrap("<span class='hideoption'>");
}
if ($day.prop('value') == iDayVal) $day.prop('value', '');
} else {
$(s).eq(0).parent('.hideoption').children('option').unwrap();
}
}
} else {
$day.prop({value: '', disabled: true});
}
});
}
/**
* offsetDate - Creates a new date offset by a number of years
* @param {Date} date - The base date to offset from
* @param {number} offset - Number of years to add (negative to subtract)
* @returns {Date} - New Date object with year offset applied
* @description Creates a date by adding/subtracting years from the input date.
* Preserves the month and day of the original date.
*/
function offsetDate(date, offset) {
return new Date(date.getFullYear() + offset, date.getMonth(), date.getDate());
}
/**
* ageCalc - Calculates and displays age from date of birth inputs
* @description Reads year, month, day from calendar dropdowns, calculates
* the person's current age, and updates display elements.
* Ages 90+ are displayed as "90+". Hides age text if date incomplete.
*/
function ageCalc() {
var iYear = parseInt($('#' + arrQs[0] + 'Year_1').prop('value'));
var iMonth = parseInt($('#' + arrQs[0] + 'Month_1').prop('value'));
var iDay = parseInt($('#' + arrQs[0] + 'Day_1').prop('value'));
var today = new Date();
if (iYear > 0 && iMonth > 0 && iDay > 0) {
var dob = new Date(iMonth + '/' + iDay + '/' + iYear);
var calcAge = 0;
while (dob.getTime() <= offsetDate(today, -(calcAge + 1)).getTime()) {
calcAge++;
}
calcAge = Math.floor(calcAge);
var ageTxt = (calcAge >= 90) ? '90+' : calcAge;
$('#' + arrQs[0] + 'Age_1').prop({value: calcAge});
$('#calculatedAge').html(ageTxt);
$('#ageText').show();
} else {
$('#ageText').hide();
}
}
$('select[id*="' + arrQs[0] + '"]').on('change', ageCalc);
ageCalc();
💡 Best Practices from These Patterns
- Use wrapper functions: Create a
doAll()function that calls all update functions - Chain parent styling: Use
.parent('td').css({...})to style containing cells - Handle both click and change: Bind events to both input and parent cell for touch support
- Initialize on load: Always call your update function once after setting up handlers
- Use special codes consistently: Reserve 998, 999 for "Don't Know", "None", etc.
- Get domain values server-side: Use
^f('Qid').domainValues().toString()^ - Disable AND hide: When hiding options, also disable to prevent submission
- Clear checked state: Use
.prop({checked: false})when hiding