Have an idea?

Visit Sawtooth Software Feedback to share your ideas on how we can improve our products.

How to transform existing CBC format into FreeFormat Div-style?

Hello everyone,

I have worked with FreeFormat CBC HTML-style questions, but currently need a Div-style format for CSS formatting. There is surprisingly little information about this in the manual (see e.g. https://www.sawtoothsoftware.com/help/lighthouse-studio/manual/hid_web_cbc_customcbc.html). To further complicate things, the div-style template from the Sawtooth Software Library has labels on the left + three choice options + none option, whereas my example has 2 choice options + dual response.

Hence my question, would someone be able to explain/show me how to transform my standard CBC layout into a Free-Format Div-style template?

I wan't sure how to attach a .txt file with the HTML source, so I inlined a screenshot of my standard CBC layout and included a link to the actual files (https://we.tl/t-Ck6CZ0IYHT).

<a href="https://ibb.co/ZGg9YxZ"><img src="https://i.ibb.co/jbwtWDp/CBC-layout-Lighthouse-studio.png" alt="CBC-layout-Lighthouse-studio" border="0"></a>
asked Feb 6 by MF Jonker (155 points)
If you want to use the free format question from the question library, I would start by running the code through an HTML beautifier.  There are a number of free beautifiers both online and in text editors.  This will hopefully make it easier to see what the HTML is doing and make modifications.

Alternatively, I've got some unverified Perl for automatically generating the HTML for a free format CBC.  This could be modified for your needs, but it requires some familiarity with Perl to work with.  If you can tell me what customizations you had in mind for your custom HTML, I may be able to modify my Perl code to accommodate you.
Hi Zachary,

HTML itself is not the problem.. it is also nicely explained in the manual (see e.g. https://www.sawtoothsoftware.com/help/lighthouse-studio/manual/hid_web_cbc_customcbc.html). Unfortunately, the div-style free-format CBC is not as well explained. What I am looking for is a div-style free-format version for a CBC with 2 options (without labels on the left) + dual response yes/no.  As mentioned, the div-style template in the library has labels +  3 choice options + a traditional none option.

1 Answer

+1 vote
 
Best answer
Please try the following:

In the CBC's "SSI_Comment" portion, disable verification.

Give the free format question two variables, both whole numbered radio buttons.  Name them "_response" and "_none."

Set the body of the free format question to this:

[% Begin Unverified Perl
# --------------------
# Instructions
# --------------------

# This question serves to help users create free format CBCs.
# It's core functionality is the same as another question in the Sawtooth Software Question Library.
# Whereas the other question has HTML hard coded into the question, this question programmatically generates the HTML using Perl.
# Users familiar with Perl may have an easier time using and modifying this question for their needs.

# It is recommended that you add this question to your questionnaire, adjust the settings and make any other additional changes to the code, and finally copy-and-paste the question throughout your CBC exercise.
# This question's name must consist of a base name appended with the CBC task related to this free format question.  For example, "mycbcRandom1" and "mycbcFixed2".
# The header of the CBC exercise must be given the standard HTML comment for free format CBCs:
#         The standard HTML comment can be found in the free format CBC documentation in Lighthouse Studio Help.
#         The comment should be set to not run verification.
# The error messages to be used by this free format question can be modified in this question's custom JavaScript verification.

# --------------------
# Settings
# --------------------

# General parameters
my $nameOfCbc = 'cbc';
my $numberOfAttributes = 4;
my $numberOfConcepts = 2;
my $numberOfTasks = 12;
my @positionsOfFixedTasks = (); # positions of fixed tasks throughout the tasks, one-based and ordered from lowest to greatest

# Response type
my $responseType = 1; # 1 - discrete choice, 2 - best worst, 3 - constant sum
my $requireResponse = 1; # 1 - require response, 0 - do not require response

my $discreteButtonText = 'Select';

my $bestWorstBestButtonText = 'Best';
my $bestWorstWorstButtonText = 'Worst';

my $constantSumTotal = 100;
my $constantSumLeftInputLabel = '';
my $constantSumRightInputLabel = '';
my $constantSumLeftTotalLabel = 'Total:';
my $constantSumRightTotalLabel = '';

# None option
my $noneOption = 2; # 0 - do not include, 1 - traditional, 2 - dual response
my $dualNoneRequireResponse = 1; # 1 - require response if dual response none included, 0 - not required

my $traditionalNonePosition = 1; # 1 - concept below task, 2 - last concept in task
#my $noneOptionText = 'NONE: I wouldn\'t choose any of these.';
my $noneOptionText = 'Given what you know about the market, would you really buy the XXXXXXXXX you chose above?';
my $dualNoneYesText = 'Yes';
my $dualNoneNoText = 'No';

# Formatting
my $mobile = 1; # 0 - maintain desktop layout, 1 - horizontal CBC from mobile screens, 2 - vertical CBC from mobile screens
my $attributeLabels = 0; # 0 - do not display attribute labels, 1 - display attribute labels left of concepts, 2 - display attribute labels inside concepts
my @conceptLabels = (); # concept labels, if any

# --------------------
# Generate page contents
# --------------------

my $cbcTaskNumber;
my $cbcTask;
findCbcTask();

my $customCbcCode;
$customCbcCode .= generateHtml();
$customCbcCode .= generateCss();
return $customCbcCode;

# Find CBC task name based off this question's name
sub findCbcTask {
    my $qname = QUESTIONNAME();
    $qname =~ m/((Random|Fixed)([0-9])+)$/; # Match all numeric characters that come at the end of the question name
    $cbcTaskNumber = $3;
    $cbcTask = $nameOfCbc . '_' . $1;
}

# Generate HTML
sub generateHtml {
    my $output;
    $output .= generateTaskNumberHtml();
    $output .= generateMobileCbcHtml();
    $output .= generateTaskNavigationHtml();
    my $responseTypeClass = findResponseTypeClass();
    $output .= generateAttributeLabelsHtml($responseTypeClass);
    $output .= generateConceptsHtml($responseTypeClass);
    $output .= generateTraditionalNoneConceptHtml($responseTypeClass);
    $output .= '</div>';
    $output .= generateTotalHtml($constantSumLeftTotalLabel, $constantSumRightTotalLabel);
    $output .= '</div>';
    $output .= generateDualNoneConceptHtml($responseTypeClass);
    $output .= generateHtmlForJavaScript();
    return $output;
}

# Generate HTML for task number
sub generateTaskNumberHtml {
    my $printableTaskNumber;
    if ($cbcTask =~ m/_Fixed/) {
        $printableTaskNumber = $positionsOfFixedTasks[$cbcTaskNumber - 1];
    }
    else {
        $printableTaskNumber = $cbcTaskNumber;
        foreach my $fixedTaskPosition (@positionsOfFixedTasks) {
            if ($printableTaskNumber >= $fixedTaskPosition) {
                $printableTaskNumber++;
            }
            else {
                last;
            }
        }
    }
    return '<div>(' . $printableTaskNumber . ' of ' . $numberOfTasks . ')</div>';
}

# Generate HTML for mobile navigation mode
sub generateMobileCbcHtml {
    my $mobileClass;
    if ($mobile == 1) {
        $mobileClass = 'mobile_horizontal';
    }
    elsif ($mobile == 2) {
        $mobileClass = 'mobile_vertical';
    }
    else {
        $mobileClass = 'no_mobile';
    }
    return '<div class="' . $mobileClass . ' ">';
}

# Find HTML class to use representing the CBC response type
sub findResponseTypeClass {
    if ($responseType == 2) {
        return 'best_worst';
    }
    elsif ($responseType == 3) {
        return 'constant_sum';
    }
    return 'discrete';
}

# Generate HTML for navigating between concepts
sub generateTaskNavigationHtml {
    my $output;
    $output .= '<div class="task_controls">';
    $output .= '<div class="task_nav">';
    $output .= '<div class="carousel_prev carousel_arrow"></div>';
    $output .= '<div class="task_nav_dots">';
    for (my $concept = 1; $concept <= $numberOfConcepts; $concept++) {
        $output .= '<div class="nav_dot dot_' . $concept . '"></div>';
    }
    $output .= '</div>';
    $output .= '<div class="carousel_next carousel_arrow"></div>';
    $output .= '</div></div>';
    return $output;
}

# Generate HTML for attribute labels
sub generateAttributeLabelsHtml {
    my ($responseTypeClass) = @_;
    my $output;
    $output .= '<div class="cbc_task ' . $responseTypeClass . ' owl-carousel hide_for_processing">';
    $output .= '<div class="attribute_label_column">';
    my $sharedAttributeLabelClasses = 'cbc_cell attribute_label_cell';
    
    # Concept labels
    if (scalar @conceptLabels) {
        $output .= '<div class="' . $sharedAttributeLabelClasses . ' top_corner_label concept_label_cell"></div>';
    }
    
    # Attribute labels
    for (my $attribute = 1; $attribute <= $numberOfAttributes; $attribute++) {
        my $firstAttributeClass = $attribute == 1 ? 'first_att' : '';
        $output .= '<div class="' . $sharedAttributeLabelClasses . ' att_' . $attribute . ' ' . $firstAttributeClass . '">';
        $output .= '<span class="att_label_text">';
        $output .= CBCATTRIBUTELABEL($cbcTask, $attribute);
        $output .= '</span></div>';
    }
    
    $output .= '<div class="cbc_cell concept_cell cbc_response_cell attribute_label_cell bottom_corner_label"></div>';
    $output .= '</div>';
    return $output;
}



(cont.)
answered Feb 6 by Zachary Platinum Sawtooth Software, Inc. (126,825 points)
selected Feb 7 by MF Jonker
# Generate HTML for concepts
sub generateConceptsHtml {
    my ($responseTypeClass) = @_;

    my $output;
    for (my $concept = 1; $concept <= $numberOfConcepts; $concept++) {
        my $firstConceptClass = $concept == 1 ? 'first_in_row' : '';
        $output .= '<div class="cbc_concept concept_' . $concept . ' ' . $responseTypeClass . ' ' . $firstConceptClass . '">';
        my $sharedConceptClasses = 'cbc_cell concept_cell';
        
        # Concept labels
        if (scalar @conceptLabels) {
            my $conceptLabel = $conceptLabels[$concept - 1];
            $output .= '<div class="' . $sharedConceptClasses . ' concept_label_cell"><span class="header_text">' . $conceptLabel . '</span></div>';
        }
        
        # Levels
        for (my $attribute = 1; $attribute <= $numberOfAttributes; $attribute++) {
            my $firstAttributeClass = $attribute == 1 ? 'first_att' : '';
            $output .= '<div class="' . $sharedConceptClasses . ' att_' . $attribute . ' ' . $firstAttributeClass . '">';
            $output .= '<div class="mobile_attribute_label"><span class="att_label_text">';
            $output .= CBCATTRIBUTELABEL($cbcTask, $attribute);
            $output .= '</span></div>';
            $output .= '<div class="level"><span class="level_text">';
            $output .= CBCDESIGNLEVELTEXT($cbcTask, $concept, $attribute);
            $output .= '</span></div>';
            $output .= '</div>';
        }
        
        # Response
        $output .= generateResponseHtml($cbcTask, $concept, $responseType, 0, $discreteButtonText, $bestWorstBestButtonText, $bestWorstWorstButtonText);
        
        $output .= '</div>';
    }
    return $output;
}

sub generateTraditionalNoneConceptHtml {
    my ($responseTypeClass) = @_;

    my $output;
    if ($noneOption == 1) {
        my $lowerNoneClass = '';
        if ($traditionalNonePosition == 1) {
            $lowerNoneClass = 'lower_none';
        }
        $output .= '<div class="cbc_concept concept_' . ($numberOfConcepts + 1) . ' ' . $responseTypeClass . ' none_concept ' . $lowerNoneClass . '">';
        $output .= '<div class="cbc_cell none_cell first_att"><span class="level_text">';
        $output .= $noneOptionText;
        $output .= '</span></div>';
        $output .= generateResponseHtml($cbcTask, $numberOfConcepts + 1, $responseType, 1, $discreteButtonText, $bestWorstBestButtonText, $bestWorstWorstButtonText);
        $output .= '</div>';
    }
    
    return $output;
}

sub generateDualNoneConceptHtml {
    my ($responseTypeClass) = @_;

    my $output;
    if ($noneOption == 2) {
        $output .= '<div class="cbc_concept concept_0 ' . $responseTypeClass . ' none_concept lower_none dual_response_none first_in_row" id="' . QUESTIONNAME() . '_none">';
        $output .= '<div class="cbc_cell none_cell first_att"><div class="dual_response_none_question">' . $noneOptionText . '</div></div>';
        $output .= '<div class="cbc_cell concept_cell cbc_response_cell">';
        $output .= '<div class="dual_response_none_button would_button"><div class="clickable input_cell HideElement">';
        $output .= RADIOSELECT(QUESTIONNAME() . '_none', 1);
        $output .= '</div>';
        $output .= '<div class="input_label show_when_selected">' . $dualNoneYesText . '</div>';
        $output .= '</div>';
        $output .= '<div class="dual_response_none_button would_not_button"><div class="clickable input_cell HideElement">';
        $output .= RADIOSELECT(QUESTIONNAME() . '_none', 2);
        $output .= '</div>';
        $output .= '<div class="input_label show_when_selected">' . $dualNoneNoText . '</div>';
        $output .= '</div>';
        $output .= '</div>';
        $output .= '</div>';
    }
    return $output;
}

# Generate HTML for a CBC response input
sub generateResponseHtml {
    my ($cbcTask, $concept, $responseType, $isNone, $discreteText, $bestWorstBestText, $bestWorstWorstText) = @_;
    my $output;
    my $noneClass = $isNone ? 'none' : '';
    $output .= '<div class="cbc_cell concept_cell cbc_response_cell concept_cell ' . $noneClass . '">';
    if ($responseType == 1) {
        $output .= generateResponseButtonHtml($cbcTask, $concept, $discreteText, 0, $isNone);
    }
    elsif ($responseType == 2) {
        $output .= generateResponseButtonHtml($cbcTask, $concept, $bestWorstBestText, 1, $isNone);
        $output .= generateResponseButtonHtml($cbcTask, $concept, $bestWorstWorstText, 2, $isNone);
    }
    else {
        $output .= generateResponseBoxHtml($concept, $constantSumLeftInputLabel, $constantSumRightInputLabel);
    }
    $output .= '</div>';
    return $output;
}

sub generateResponseButtonHtml {
    my ($cbcTask, $concept, $buttonText, $buttonType, $noneButton) = @_;

    my $variableName;
    my $buttonClass;
    if ($buttonType == 1) {
        $variableName = 'responsebest';
        $buttonClass = 'best_button';
    }
    elsif ($buttonType == 2) {
        $variableName = 'responseworst';
        $buttonClass = 'worst_button';
    }
    else {
        $variableName = 'response';
        $buttonClass = 'best_button';
    }

    my $conceptValue = $concept;
    if (!$noneButton) {
        $conceptValue = CBCDESIGNCONCEPTVALUE($cbcTask, $concept);
    }

    my $output;
    $output .= '<div class="task_select_button ' . $buttonClass . '">';
    $output .= '<div class="input_label">' . $buttonText . '</div>';
    $output .= '<div class="selected_image show_when_selected"></div>';
    $output .= '<div class="input_cell clickable HideElement">';
    $output .= RADIOSELECT(QUESTIONNAME() . '_' . $variableName, $conceptValue);
    $output .= '</div>';
    $output .= '</div>';
    return $output;
}

sub generateResponseBoxHtml {
    my ($concept, $constantSumLeftInputLabel, $constantSumRightInputLabel) = @_;
    my $output = '<span class="left_input_label">' . $constantSumLeftInputLabel . '</span>';
    my $inputId = QUESTIONNAME() . '_response' . $concept;
    $output .= '<input type="tel" class="numeric_input" size="3" name="' . $inputId . '" id="' . $inputId . '"/>';
    $output .= '<span class="right_input_label">' . $constantSumRightInputLabel . '</span>';
    return $output;
}

# Generate HTML for constant sum CBC total
sub generateTotalHtml {
    my ($constantSumLeftTotalLabel, $constantSumRightTotalLabel) = @_;
    my $output = '';
    if ($responseType == 3) {
        $output .= '<div class="cbc_totals_box">';
        $output .= '<span class="cbc_totals_label left">' . $constantSumLeftTotalLabel . '</span>';
        $output .= '<span id="' . $cbcTask . '_total_html" class="cbc_total">0</span>';
        $output .= '<input type="hidden" class="cbc_total" name="' . $cbcTask . '_total" id="' . $cbcTask . '_total" value="0"/>';
        $output .= '<span class="cbc_totals_label right">' . $constantSumRightTotalLabel . '</span>';
    }
    return $output;
}

# Generate HTML to pass information to JavaScript
sub generateHtmlForJavaScript {
    my $output;
    $output .= generateHiddenHtmlInput('customCbcHiddenResponseType', $responseType);
    $output .= generateHiddenHtmlInput('customCbcHiddenRequireResponse', $requireResponse);
    $output .= generateHiddenHtmlInput('customCbcHiddenTotal', $constantSumTotal);
    $output .= generateHiddenHtmlInput('customCbcHiddenNoneOption', $noneOption);
    $output .= generateHiddenHtmlInput('customCbcHiddenDualNoneRequireResponse', $dualNoneRequireResponse);
    return $output;
}

sub generateHiddenHtmlInput {
    my ($inputClass, $value) = @_;
    return '<input type="hidden" class="' . $inputClass . '" value="' . $value . '"/>';
}

# Generate CSS
sub generateCss {
    my $css = '<style>';
    my $widthLeft = 100;
    my $attributeLabelsWidth;
    my $conceptWidth;
    $css .= generateAttributeColumnCss(\$widthLeft, \$attributeLabelsWidth);
    adjustWidthCss(\$widthLeft, \$attributeLabelsWidth, \$conceptWidth);
    $css .= generateNonMobileCss($attributeLabelsWidth, $conceptWidth);
    $css .= generateInlineAttributeLabelsCss();
    $css .= '</style>';
    return $css;
}

sub generateAttributeColumnCss {
    my ($widthLeftRef, $attributeLabelsWidthRef) = @_;
    if ($attributeLabels == 1) {
        ${$attributeLabelsWidthRef} = 15;
        ${$widthLeftRef} -= 15;
        return '';
    }
    else {
        ${$attributeLabelsWidthRef} = 0;
        return '#' . QUESTIONNAME() . '_div .attribute_label_column { display: none; }';
    }
}



(cont.)
sub adjustWidthCss {
    my ($widthLeftRef, $attributeLabelsWidthRef, $conceptWidthRef) = @_;
    ${$widthLeftRef} -= 2 * $numberOfConcepts;
    ${$conceptWidthRef} = ${$widthLeftRef} / $numberOfConcepts;
    if (${$attributeLabelsWidthRef} > ${$conceptWidthRef}) {
        ${$widthLeftRef} += ${$attributeLabelsWidthRef};
        ${$conceptWidthRef} = ${$widthLeftRef} / ($numberOfConcepts + 1);
        ${$attributeLabelsWidthRef} = ${$conceptWidthRef};
    }
}

sub generateNonMobileCss {
    my ($attributeLabelsWidth, $conceptWidth) = @_;
    my $output;
    $output .= '#' . QUESTIONNAME() . '_div .attribute_label_column { width: ' . $attributeLabelsWidth . '%; }';
    $output .= '#' . QUESTIONNAME() . '_div .cbc_task .cbc_concept { width: ' . $conceptWidth . '%; }';
    $output .= '#' . QUESTIONNAME() . '_div .cbc_concept.lower_none { margin-left: ' . ($attributeLabelsWidth + 2) . '%; }';
    if ($mobile) {
        $output = '@media only screen and (min-width: 801px) { ' . $output . ' }';
    }
    return $output;
}

sub generateInlineAttributeLabelsCss {
    my $output = '';
    if ($attributeLabels == 2) {
        $output .= '#' . QUESTIONNAME() . '_div .mobile_attribute_label { display: block; }';
    }
    return $output;
}
End Unverified %]



<link rel="stylesheet" type="text/css" href="[% GraphicsPath() %]system/owl.carousel.min.css">
<script type="text/javascript" src="[% GraphicsPath() %]system/owl.carousel.min.js"></script>

<style>
#[% QuestionName() %]_div .cbc_task {
    display: flex;
}

#[% QuestionName() %]_div .cbc_concept {
    margin-left: 2%;
}

#[% QuestionName() %]_div .none_cell {
    align-items: center;
}
</style>

<script>
$(document).on('ssi_ready', function(){
    // Setup free format as CBC
    $('#[% QuestionName() %]_div').addClass('cbc');
    $('#[% QuestionName() %]_div .cbc_task').find('*').unbind('click');
    initializeCBC($('#[% QuestionName() %]_div'));
    
    // Constant sum total
    $('#[% QuestionName() %]_div .numeric_input').keyup(function(){
        updateConstantSumTotal('[% QuestionName() %]');
    });
    updateConstantSumTotal('[% QuestionName() %]');
})

function updateConstantSumTotal(questionName) {
    var total = 0;
    $('#' + questionName + '_div .numeric_input').each(function(){
        total += Number($(this).val()) || 0;
    });
    $('#' + questionName + '_div input.cbc_total').val(total);
    $('#' + questionName + '_div span.cbc_total').text(total);
}
</script>


I've updated some of the settings already, but there are still a few lines that need to be modified in the first code block.  Line 23 should be updated with the name of your CBC, line 24 with the number of attributes, and line 26 with the number of tasks.  Line 27 should be updated if there are fixed tasks in the exercise.

Finally, custom JavaScript verification for the free format:

// Load custom CBC settings
var responseType = Number($('#[% QuestionName() %]_div .customCbcHiddenResponseType').val());
var requireResponse = Number($('#[% QuestionName() %]_div .customCbcHiddenRequireResponse').val());
var total = Number($('#[% QuestionName() %]_div .customCbcHiddenTotal').val());
var none = Number($('#[% QuestionName() %]_div .customCbcHiddenNoneOption').val());
var dualNoneRequireResponse = Number($('#[% QuestionName() %]_div .customCbcHiddenDualNoneRequireResponse').val());

// Custom JavaScript verification error messages
var discreteRequired = 'An item must be selected.';
var bestRequired = 'An item must be selected as best.';
var worstRequired = 'An item must be selected as worst.';
var totalUnsatisfied = 'Total must be equal to ' + total + '.';
var dualNoneRequired = 'Whether you would purchase the selected item requires a response.';

// Verify response
var errors = [];

// Verify discrete and best-worst
if (requireResponse) {
    switch (responseType) {
        // Verify discrete
        case 1:
            if (!SSI_GetValue('[% QuestionName() %]_response')) {
                errors.push('<div>' + discreteRequired + '</div>');
            }
            break;
        
        // Verify best-worst
        case 2:
            if (!SSI_GetValue('[% QuestionName() %]_responsebest')) {
                errors.push('<div>' + bestRequired + '</div>');
            }
            if (!SSI_GetValue('[% QuestionName() %]_responseworst')) {
                errors.push('<div>' + worstRequired + '</div>');
            }
            break;
    }
}

// Verify constant sum
if (responseType == 3) {
    var answered = false;
    var sum = 0;
    $('#[% QuestionName() %]_div .numeric_input').each(function(){
        var resp = $(this).val().trim();
        if (resp) {
            answered = true;
        }
        sum += Number(resp) || 0;
    });
    if (sum != total && (requireResponse || answered)) {
        errors.push('<div>' + totalUnsatisfied + '</div>');
    }
}

// Verify dual-response none
if (none == 2 && dualNoneRequireResponse && !SSI_GetValue('[% QuestionName() %]_none')) {
    errors.push('<div>' + dualNoneRequired + '</div>');
}

strErrorMessage = errors.join('');


Please test the resulting CBC thoroughly to verify it works as you intend.
Zachary,

I am glad I asked this question on the forum; I hope it helps other users as much as it helped me.  Many thanks for your guidance!

Best, Marcel
...