Custom questions

Overview

In this article we'll create a couple of custom question experiences using external js libraries (jQueryUI, bootstrap), and describe a way to group them together generically.
We use a naming convention to distinguish between public and private methods and properties, use of private methods and properties is not supported. Methods and properties names starting with “_” are private and may be changed at any time without warning which could lead to unexpected behaviour or could prevent your survey from working.

Disabling default rendering

In most cases, in order to create a custom question, you will need to disable default rendering. Open your survey in Professional Authoring, then in Questionary Tree, in the Question Skins section, insert new question skin. Set 'disable default rendering' in this question skin's properties and save changes. After that, the default markup will be disabled and you can render your own question. Make sure the skin you created is selected in the question settings.

Where to put code

External JS libraries must be connected in the survey theme: Custom JavaScript -> External JavaScript URL.
External CSS libraries must be connected in the survey theme: Custom CSS -> External Style Sheet URL.
Custom styles must be added to Custom CSS section.
JavaScript code can be added both in the survey theme (Custom JavaScript section) and in the question theme (JavaScript section).

Single slider

Overview

In that section we'll implement basic custom slider control using jQueryUI. Default markup will be disabled, and instead we will create a jQueryUI slider bound to the question model. This example can be used with single questions.

Implementation

Set 'disable default rendering' on the question skin - default markup won't be used in that example so it can be disabled.

Add the jQuery and jQueryUI js files to the question theme as external js files.

https://code.jquery.com/jquery-3.2.1.min.js, 
https://code.jquery.com/ui/1.12.1/jquery-ui.min.js  

Add the jQueryUI css file to the question theme as an external css file.

https://code.jquery.com/ui/1.12.1/themes/base/jquery-ui.css

Code to add to the JavaScript section of the question theme:

class SliderSingleQuestionView {
    constructor(currentQuestion) {
        this.question = currentQuestion;
        this.init();
    }

    init() {
        this.render();
        this.question.changeEvent.on(this.onQuestionChange.bind(this));
    }

    render() {
        $('<div class="cf-question__text">' + this.question.text + '</div>').appendTo('#' + this.question.id);
        $('<div class="cf-question__instruction">' + this.question.instruction + '</div>').appendTo('#' + this.question.id);
        this.renderLabels();
        this.renderSlider();
    }

    renderLabels() {
        const labels = $('<div class="slider-single__labels"></div>');
        labels
            .css('width', '100%')
            .css('max-width', '300px')
            .css('display', 'flex')
            .css('justify-content', 'space-between')
            .css('margin-top', '30px');

        this.question.answers.forEach(answer => {
            $('<div class="cf-single-answer__text">' + answer.text + '</div>').appendTo(labels);
        });
        $('#' + this.question.id).append(labels);
    }

    renderSlider() {
        const sliderContainer = $('<div class="single-slider" style="max-width: 300px; margin-top: 20px;"></div>');
        sliderContainer.appendTo('#' + this.question.id);

        sliderContainer.slider({
            value: this.question.value,
            min: 1,
            max: this.question.answers.length,
            change: this.onSliderChange.bind(this)
        });
    }

    onSliderChange(event, data) {
        this.question.setValue(data.value);
    }

    onQuestionChange(model) {
        $('.single-slider').slider("value", this.question.value);
    }
}

new SliderSingleQuestionView(currentQuestion);

Grid slider

Overview

It is also possible to adjust part of the default question markup. Here we will adjust the grid question to display sliders instead of radio buttons. This example can be used with grid questions.

Implementation

Add the jQuery and jQueryUI js files to the question theme as external js files.

https://code.jquery.com/jquery-1.12.4.js, 
https://code.jquery.com/ui/1.12.1/jquery-ui.js

Add the jQueryUI css file to the question theme as an external css file.

https://code.jquery.com/ui/1.12.1/themes/base/jquery-ui.css

Code to add to the JavaScript section of the question theme:

class SliderSimpleGridQuestionView {
    constructor(currentQuestion) {
        this.question = currentQuestion;
        this.init();
    }

    init() {
        this.hideScales();
        this.renderLabelPanel();
        this.render();
        this.question.changeEvent.on(this.onQuestionChange.bind(this));
    }

    getSliderId(answerCode) {
        return 'slider_' + this.question.id + '_' + answerCode;
    }

    hideScales() {
        $('td:not(.cf-table-layout__cell--empty)').hide();
        $('.cf-table-layout__cell--column-head').hide();
    }

    renderLabelPanel() {
        const labelPanel = $('<th colspan="' + this.question.scales.length +'"></th>');
        const labels = $('<div class="slider-grid__row-control"></div>').appendTo(labelPanel);
        labels.css('display', 'flex').css('justify-content', 'space-between').css('gap', '30px');
        labelPanel.appendTo('.cf-table-layout__row--head');

        this.question.scales.forEach(scale => {
            $('<div>' + scale.text + '</div>').appendTo(labels);
        });
    }

    render() {
        this.question.answers.forEach(answer => {
            const slider = $('<td colspan="'+ this.question.scales.length +'">' +
                '<div class="slider-grid__slider" id="' + this.getSliderId(answer.code) + '"></div></td>');
            const rowId = this.question.id + '_' + answer.code;
            slider.appendTo('#desktop_' + rowId);

            $('#' + this.getSliderId(answer.code)).slider({
                value: 0,
                min: 0,
                max: this.question.scales.length - 1,
                step: 1,
                change: this.onSliderChange.bind(this, answer.code)
            });
        });

        $('#' + this.question.id).find('.cf-grid').css('overflow', 'initial');
        $('#' + this.question.id + ' .cf-grid-answer').css('margin-bottom', '15px');
        $('#' + this.question.id + ' .cf-grid-answer__text').css('margin-top', '0');
        $('#' + this.question.id + ' .cf-grid-answer__control').css('align-self', 'center').css('max-width', '600px').css('flex-grow', '1');
    }

    onSliderChange(answerCode, event, data) {
        const answerValue = this.question.scales[data.value].code;
        this.question.setValue(answerCode, answerValue);
    }

    onQuestionChange(model) {
        model.changes.values.forEach(answerCode => {
            let sliderId = this.getSliderId(answerCode);
            let value = this.question.scales.findIndex(scale => scale.code == this.question.values[answerCode]);
            $('#' + sliderId).slider("value", value);
        });
    }
}

new SliderSimpleGridQuestionView(currentQuestion);

Grid slider re-rendered

Overview

The previous example could also be implemented in a different way - by disabling the default markup and creating it manually. This example can be used with grid questions.

Implementation

Set 'Disable default rendering' in the question skin's properties.
Add the jQuery and jQueryUI js files to the question theme as external js files.

https://code.jquery.com/jquery-1.12.4.js, https://code.jquery.com/ui/1.12.1/jquery-ui.js

Add the jQueryUI css file to the question theme as an external css file.

https://code.jquery.com/ui/1.12.1/themes/base/jquery-ui.css

Code to add to the JavaScript section of the question theme:

class SliderGridQuestionView {
    constructor(currentQuestion) {
        this.question = currentQuestion;
        this.init();
    }

    init(){
        this.render();
        this.initValues();
        this.subscribeToQuestion();
    }

    getSliderId(answerCode) {
        return 'slider_' + this.question.id + '_' + answerCode;
    }

    render() {
        $('<div class="cf-question__text">' + this.question.text + '</div>').appendTo('#' + this.question.id);
        $('<div class="cf-question__instruction">' + this.question.instruction + '</div>').appendTo('#' + this.question.id);
        this.renderContent();
    }

    initValues(){
        if(Object.keys(this.question.values).length === 0) {
            return;
        }

        Object.keys(this.question.values).forEach( answerCode => {
            const sliderValue = this.question.scales.findIndex(scale => scale.code == this.question.values[answerCode]);
            $('#' + this.getSliderId(answerCode)).slider("value", sliderValue);
        });
    }

    subscribeToQuestion(){
        this.question.changeEvent.on(this.onQuestionChange.bind(this));
        this.question.validationCompleteEvent.on(this.onQuestionValidationComplete.bind(this));
    }

    renderContent(){
        $('<div class="cf-question__content">'+
            '<div class="cf-grid cf-grid-layout"></div>'+
          '</div>').appendTo('#' + this.question.id);
        this.renderLabelPanel();
        this.renderRows();

        $('.grid').css('overflow','initial');
    }

    renderLabelPanel(){
        const labelPanel=$('<div class="cf-grid-answer cf-grid-answer--fake-for-panel cf-grid-layout__row">' +
            '<div class="cf-grid-answer__text cf-grid-layout__row-text"></div>' +
            '<div class="cf-grid-answer__control cf-grid-layout__row-control  slider-grid__row-control">' +
            '</div>' +
            '</div>');
        labelPanel.appendTo('#' + this.question.id + ' .cf-grid-layout');

        const labels = $('.slider-grid__row-control');
        this.question.scales.forEach(scale => {
            $('<div class="cf-grid-answer__label">' + scale.text + '</div>').appendTo(labels);
        });
    }

    renderRows(){
        this.question.answers.forEach(answer => {
            const answerId = this.question.id + '_' + answer.code;

            const row = $('<div class="cf-grid-answer cf-grid-layout__row" id="' + answerId + '">' +
                '<div class="cf-grid-layout__row-text">' +
                '<div class="cf-grid-answer__text" id="' + answerId + '_text">' + answer.text + '</div>' +
                '</div>' +
                '<div class="cf-grid-answer__control cf-grid-layout__row-control"></div>' +
                '</div>');
            row.appendTo('#' + this.question.id + ' .cf-grid-layout');

            const sliderId = this.getSliderId(answer.code);
            const slider = $('<div class="slider-grid__slider" id="' + sliderId + '"></div>');
            slider.appendTo('#' + answerId + ' .cf-grid-answer__control');

            $( '#' + sliderId ).slider({
                value: 0,
                min: 0,
                max: this.question.scales.length- 1,
                step: 1,
                change: this.onSliderChange.bind(this, answer.code)
            });
        });

        $('#' + this.question.id + ' .cf-grid-answer').css('margin-bottom', '15px');
        $('#' + this.question.id + ' .cf-grid-answer__text').css('margin-top', '0');
        $('#' + this.question.id + ' .cf-grid-answer__control').css('align-self', 'center').css('max-width', '600px').css('flex-grow', '1');
        $('#' + this.question.id + ' .slider-grid__row-control').css('display', 'flex').css('justify-content', 'space-between').css('width', '100%');
    }

    renderErrors(){
        $('<div class="cf-question__error cf-error-block cf-error-block--bottom">' +
            '<ul class="cf-error-list"></ul>' +
            '</div>').insertAfter('#' + this.question.id + ' .cf-question__instruction');
    }

    onSliderChange(answerCode, event, data){
        const answerValue = this.question.scales[data.value].code;
        this.question.setValue(answerCode, answerValue);
    }

    onQuestionChange(model) {
        model.changes.values.forEach(answerCode => {
            let sliderValue = this.question.scales.findIndex(scale => scale.code == this.question.values[answerCode]);
            $('#' + this.getSliderId(answerCode)).slider("value", sliderValue);
        });
    }

    onQuestionValidationComplete(validationResult) {
        $('#' + this.question.id).removeClass("cf-question--error");
        $('#' + this.question.id + ' .cf-error-block').remove();

        const errors = [];

        validationResult.answerValidationResults.forEach(answerValRes => {
            answerValRes.errors.forEach(error => {
                errors.push(('<li class="cf-error-list__item">' + error.message + '</li>'));
            });
        });

        validationResult.errors.forEach(error => {
            errors.push(('<li class="cf-error-list__item">' + error.message + '</li>'));
        });

        if (errors.length === 0) {
            return;
        }

        this.renderErrors();
        errors.forEach(error => {
            $('#' + this.question.id + ' .cf-error-list').append(error);
        });
        $('#' + this.question.id).addClass("cf-question--error");
    }
}

new SliderGridQuestionView(currentQuestion);

Multi accordion with bootstrap

Overview

That section demonstrates how to create a multi question with expandable groups. This example can be used with multi questions including answer groups.

Implementation

Set 'Disable default rendering' in the question skin's properties. Add the jQuery and Bootstrap js files to the question theme as external js files.

https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.2/css/bootstrap.min.css

Add the Bootstrap css file to the question theme as an external css file.

https://code.jquery.com/jquery-3.2.1.slim.min.js,
https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.3/umd/popper.min.js,
https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.2/js/bootstrap.min.js

Code to add to the JavaScript section of the question theme:

class AccordionMultiQuestionView {
    constructor(currentQuestion) {
        this.question = currentQuestion;
        this.init();
    }

    init() {
        this.render();
        this.initValues();
        this.subscribeToQuestion();
    }

    render() {
        const questionId = '#' + this.question.id;
        $('<div class="cf-question__text">' + this.question.text + '</div>').appendTo(questionId);
        $('<div class="cf-question__instruction">' + this.question.instruction + '</div>').appendTo(questionId);
        $('<div class="cf-question__content"></div>').appendTo(questionId);
        this.renderAccordion();
    }

    initValues() {
        this.question.values.forEach(value => {
            const id = this.question.id + '_' + value;
            const answerClassName = 'cf-checkbox-answer--selected';
            const checkboxClassName = 'cf-checkbox--selected';
            $('#' + id).addClass(answerClassName);
            $('#' + id + '_control').addClass(checkboxClassName);
        });

        if (Object.keys(this.question.otherValues).length > 0) {
            return;
        }
        for (let index in this.question.otherValues) {
            const id = this.question.id + '_' + index + '_other';
            $('#' + id)[0].value = this.question.otherValues[index];
        }
    }

    subscribeToQuestion() {
        this.question.changeEvent.on(this.onQuestionChange.bind(this));
        this.question.validationCompleteEvent.on(this.onQuestionValidationComplete.bind(this));
    }

    renderAccordion() {
        const accordion = $('<div class="accordion cf-list"></div>');
        $('#' + this.question.id + ' .cf-question__content').append(accordion);

        let container = null;
        this.question.answers.forEach(answer => {
            const newAnswer = this.createAnswer(answer);
            if (answer.group != null) {
                container = null;
                this.getGroupNode(answer.group).find('.card-body').append(newAnswer);
            }
            else {
                if (!container) {
                    container = $('<div class="my-2 margin-left"></div>');
                    container.appendTo(accordion);
                }
                newAnswer.appendTo(container);
            }
        })
    }

    getGroupNode(group) {
        const groupId = this.question.id + '_' + group.code;
        let groupNode = $('#' + groupId);
        if (groupNode.length === 0) {
            groupNode = this.createGroup(group);
        }

        return groupNode;
    }

    createGroup(group) {
        const groupId = this.question.id + '_' + group.code;
        const groupElement = $('<div class="card my-2"></div>');
        const header = $('<div class="card-header">' +
            '<div class="container-fluid" data-toggle="collapse" href="#' + groupId + '">' +
            group.title +
            '</div>' +
            '</div>');
        const body = $('<div id="' + groupId + '" class="collapse">' +
            '<div class="card-body"></div>' +
            '</div>');

        header.find('.container-fluid').on('click', function () {
            const state = !$(this.parentElement).hasClass('card-header--closed');
            $(this.parentElement).toggleClass('card-header--closed', state);
        });

        groupElement.append(header, body);
        $('#' + this.question.id + ' .accordion').append(groupElement);

        return groupElement;
    }

    createAnswer(answer) {
        const answerId = this.question.id + '_' + answer.code;
        const answerContainer = $('<div class="cf-list__item"></div>');
        const newAnswer = $('<div class="cf-checkbox-answer" id="' + answerId + '"></div>').appendTo(answerContainer);
        const answerControl = $('<div class="cf-checkbox-answer__control"></div>').appendTo(newAnswer);

        if (!answer.isExclusive)
            $('<div class="cf-checkbox" id="' + answerId + '_control' + '"></div>').appendTo(answerControl);
        else
            $('<div class="cf-radio" id="' + answerId + '_control' + '"></div>').appendTo(answerControl);

        const answerContent = $('<div class="cf-checkbox-answer__content"></div>').appendTo(newAnswer);

        if (answer.isOther) {
            const inputId = this.question.id + '_' + answer.code + '_other';
            const input = $('<input id="' + inputId + '" type="text" class="cf-checkbox-answer__other cf-text-box " value="" placeholder="'+ answer.text +'">');
            answerContent.append(input);

            input.on('input', this.onAnswerInput.bind(this, answer.code, inputId));
            input.on('click', e => e.stopPropagation());
        }
        else
            $('<div class="cf-checkbox-answer__text" id="' + answerId + '_text">' + answer.text + '</div>').appendTo(answerContent);

        newAnswer.on('click', this.onAnswerClick.bind(this, answer.code));

        return answerContainer;
    }

    renderErrors(errors) {
        $('#' + this.question.id).removeClass("cf-question--error");
        $('#' + this.question.id + ' .cf-error-block').remove();

        if (errors.length === 0) {
            return;
        }

        const errorList = $('<div class="cf-question__error cf-error-block cf-error-block--bottom">' +
            '<ul class="cf-error-list"></ul>' +
            '</div>');
        errorList.insertAfter('#' + this.question.id + ' .cf-question__instruction');

        errors.forEach(error => {
            $('<li class="cf-error-list__item">' + error + '</li>').appendTo(errorList);
        });

        $('#' + this.question.id).addClass("cf-question--error");
    }

    onAnswerInput(answerCode, inputId) {
        this.question.setValue(answerCode, 'true');
        const value = $('#' + inputId)[0].value;
        this.question.setOtherValue(answerCode, value);
    }

    onAnswerClick(answerCode) {
        const value = this.question.values.indexOf(answerCode) <= -1;
        this.question.setValue(answerCode, value);
    }

    onQuestionChange(model) {
        if (model.changes.values) {
            model.changes.values.forEach(value => {
                let id = this.question.id + '_' + value;
                let answerClassName = 'cf-checkbox-answer--selected';
                let checkboxClassName = 'cf-checkbox--selected';
                if (this.question.values.indexOf(value) > -1) {
                    $('#' + id).addClass(answerClassName);
                    $('#' + id + '_control').addClass(checkboxClassName);
                }
                else {
                    $('#' + id).removeClass(answerClassName);
                    $('#' + id + '_control').removeClass(checkboxClassName);
                }
            });
        }

        if (model.changes.OtherValues) {
            model.changes.OtherValues.forEach(value => {
                let id = this.question.id + '_' + value;
                let answerClassName = 'cf-checkbox-answer--selected';
                let checkboxClassName = 'cf-checkbox--selected';
                if (this.question.values.indexOf(value) > -1) {
                    $('#' + id).addClass(answerClassName);
                    $('#' + id + '_control').addClass(checkboxClassName);
                }
                else {
                    $('#' + id).removeClass(answerClassName);
                    $('#' + id + '_control').removeClass(checkboxClassName);
                }
            });
        }
    }

    onQuestionValidationComplete(validationResult) {
        let errors = [];

        validationResult.answerValidationResults.forEach(answerValRes => {
            answerValRes.errors.forEach(error => {
                errors.push(error.message);
            });
        });

        validationResult.errors.forEach(error => {
            errors.push(error.message);
        });

        this.renderErrors(errors);

    }
}

new AccordionMultiQuestionView(currentQuestion);

Styles to add to the Custom CSS section of the survey theme:

margin-left {
    margin-left: 1.3rem!important;
}

.card-header{
    line-height: 1.8em;
    min-height: 1.8em;
    background-size: 1.8em;
    background-repeat: no-repeat;
    background-position: 5px center;
    cursor: pointer;
    background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2232px%22%20height%3D%2232px%22%20viewBox%3D%220%200%20306%20306%22%3E%3Cpolygon%20points%3D%22270.3%2C58.65%20153%2C175.95%2035.7%2C58.65%200%2C94.35%20153%2C247.35%20306%2C94.35%22%20fill%3D%22%23bbbbbb%22%2F%3E%3C%2Fsvg%3E");
}

.card-header--closed{
  line-height: 1.8em;
    min-height: 1.8em;
    background-size: 1.8em;
    background-repeat: no-repeat;
    background-position: 5px center;
    cursor: pointer;
    background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2232px%22%20height%3D%2232px%22%20viewBox%3D%220%200%20306%20306%22%3E%3Cpolygon%20points%3D%2294.35%2C0%2058.65%2C35.7%20175.95%2C153%2058.65%2C270.3%2094.35%2C306%20247.35%2C153%22%20fill%3D%22%23bbbbbb%22%2F%3E%3C%2Fsvg%3E");
}

Custom factory example

Once you've created some custom question experiences, you might want to pack it together. One way to do this is to use a factory class to instantiate the proper custom question view.

Question views are taken from the previous examples.
Don't forget to set 'Disable default rendering' in the question skin's properties.
Code to add to the JavaScript section of the survey theme:

// <code of the SliderSingleQuestionView />
// <code of the AccordionMultiQuestionView />
// <code of the SliderGridQuestionView />

class QuestionViewFactory {
    create (question) {
        if (question.type === 'Single') {
            return new SliderSingleQuestionView(question);
        }

        if (question.type === 'Multi') {
            return new AccordionMultiQuestionView(question);
        }

        if (question.type === 'Grid') {
            return new SliderGridQuestionView(question);
        }
    }
}

const factory = new QuestionViewFactory();

Code to add to the JavaScript section of the question theme:

factory.create(currentQuestion);