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