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.
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:
$(function() {
var container = $('#' + currentQuestion.id);
var sliderContainer = $('<div class="single-slider" style="max-width: 300px; margin: 20px;"></div>');
container.append(sliderContainer);
sliderContainer.slider({
value: currentQuestion.value,
min: 1,
max: currentQuestion.answers.length,
change: function( event, ui ) {
currentQuestion.setValue(ui.value);
}
});
currentQuestion.changeEvent.on(() => {
sliderContainer.slider("value", currentQuestion.value);
console.log('Single slider(' + currentQuestion.id + ') changed: value ' + currentQuestion.value + ', other ' + currentQuestion.otherValue);
});
});
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:
class SliderSimpleGridQuestionView {
constructor(currentQuestion) {
this.question = currentQuestion;
this.init();
}
init(){
this.hideScales();
this.render();
this.question.changeEvent.on(this.onQuestionChange.bind(this));
}
getSliderId(answerCode) {
return 'slider_' + this.question.id + '_' + answerCode;
}
hideScales() {
$('#' + this.question.id + ' .cf-grid-answer__scale').hide();
}
render(){
this.question.answers.forEach(answer => {
const slider = $('<div class="slider-grid__slider" id="' + this.getSliderId(answer.code) + '"></div>');
const rowId = this.question.id + '_' + answer.code;
slider.appendTo('#' + rowId + ' .cf-grid-layout__row-control');
$( '#' + 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:
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 = $('<div class="cf-grid-answer__labels"></div>');
this.question.scales.forEach(scale => {
$('<div class="cf-grid-answer__label">' + scale.text + '</div>').appendTo(labels);
});
$('#' + this.question.id + ' .cf-grid-answer--fake-for-panel .cf-grid-layout__row-control').append(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');
}
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.
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:
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 className = 'cf-multi-answer--selected';
$('#' + id).addClass(className);
});
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 newAnswer = $('<div class="cf-list__item cf-multi-answer" id="' + answerId + '"></div>');
if(answer.isExclusive){
newAnswer.addClass("cf-multi-answer--exclusive");
}
if(answer.isOther){
const inputId =this.question.id + '_' + answer.code + '_other';
const input = $('<input id="' + inputId + '" type="text" class="cf-multi-answer__other-input cf-text-box" value="" placeholder="Other">');
newAnswer.append(input);
input.on('input', this.onAnswerInput.bind(this, answer.code, inputId));
input.on('click', e => e.stopPropagation());
}
else{
$('<div class="cf-multi-answer__text" id="' + answerId + '_text">'+ answer.text + '</div>').appendTo(newAnswer);
}
newAnswer.on('click', this.onAnswerClick.bind(this, answer.code));
return newAnswer;
}
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 className = 'cf-multi-answer--selected';
if (this.question.values.indexOf(value) > -1) {
$('#' + id).addClass(className);
}
else {
$('#' + id).removeClass(className);
}
});
}
if(model.changes.OtherValues) {
model.changes.OtherValues.forEach(value => {
let id = this.question.id + '_' + value;
let className = 'cf-multi-answer--selected';
if (this.question.values.indexOf(value) > -1) {
$('#' + id).addClass(className);
}
else {
$('#' + id).removeClass(className);
}
});
}
}
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 question 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:
class QuestionViewFactory {
create(question) {
if (question.type == 'Multi') {
return new AccordionMultiQuestionView(question);
}
if (question.type == 'Grid') {
return new SliderGridQuestionView(question);
}
}
}
let factory = new QuestionViewFactory();
factory.create(currentQuestion);