assets/js/llms-quiz.js
;/* global LLMS, $ */
/* jshint strict: true */
/**
* Front End Quiz Class
*
* @type {Object}
* @since 1.0.0
* @version 3.24.3
*/( function( $ ) {
var quiz = {
/**
* Selector of all the available button elements
*
* @type obj
*/
$buttons: null,
/**
* Main Question Container Element
*
* @type obj
*/
$container: null,
/**
* Main Quiz container UI element
*
* @type obj
*/
$ui: null,
/**
* Attempt key for the current quiz
*
* @type {[type]}
*/
attempt_key: null,
/**
* Question ID of the current question
*
* @type {Number}
*/
current_question: 0,
/**
* Total number of questions in the current quiz
*
* @type {Number}
*/
total_questions: 0,
/**
* Object of quiz question HTML
*
* @type {Object}
*/
questions: {},
/**
* Validator functions for question types
* Third party custom question types can register validators for use when answering questions
*
* @type {Object}
*/
validators: {},
/**
* Records current status of a quiz session
* If a user attempts to navigate away from a quiz
* while taking the quiz they'll be warned that their progress
* will not be saved if this status is not null
*
* @type boolean
*/
status: null,
/**
* Bind DOM events
*
* @return void
* @since 1.0.0
* @version 3.16.6
*/
bind: function() {
var self = this;
// start quiz
$( '#llms_start_quiz' ).on( 'click', function( e ) {
e.preventDefault();
self.start_quiz();
} );
// draw quiz grade circular chart
$( '.llms-donut' ).each( function() {
LLMS.Donut( $( this ) );
} );
// redirect to attempt on attempt selection change
$( '#llms-quiz-attempt-select' ).on( 'change', function() {
var val = $( this ).val();
if ( val ) {
window.location.href = val;
}
} );
// warn when quiz is running and user tries to leave the page
$( window ).on( 'beforeunload', function() {
if ( self.status ) {
return LLMS.l10n.translate( 'Are you sure you wish to quit this quiz attempt?' );
}
} );
// complete the quiz attempt when user leaves if the quiz is running
$( window ).on( 'unload', function() {
if ( self.status ) {
self.complete_quiz();
}
} );
$( document ).on( 'llms-post-append-question', self.post_append_question );
// register validators
this.register_validator( 'content', this.validate );
this.register_validator( 'choice', this.validate_choice );
this.register_validator( 'picture_choice', this.validate_choice );
this.register_validator( 'true_false', this.validate_choice );
},
/**
* Add an error message to the UI
*
* @param string msg error message string
* @return void
* @since 3.16.0
* @version 3.16.0
*/
add_error: function( msg ) {
var self = this;
self.$container.find( '.llms-error' ).remove();
var $err = $( '<p class="llms-error">' + msg + '<a href="#"><i class="fa fa-times-circle" aria-hidden="true"></i></a></p>' );
$err.on( 'click', 'a', function( e ) {
e.preventDefault();
$err.fadeOut( '200' );
setTimeout( function() {
$err.remove();
}, 210 );
} );
self.$container.append( $err );
},
/**
* Answer a Question
*
* @param obj $btn jQuery object for the "Next Lesson" button
* @return void
* @since 1.0.0
* @version 3.16.6
*/
answer_question: function( $btn ) {
var self = this,
$question = this.$container.find( '.llms-question-wrapper' ),
type = $question.attr( 'data-type' ),
valid;
if ( ! this.validators[ type ] ) {
console.log( 'No validator registered for question type ' + type );
return;
}
valid = this.validators[ type ]( $question );
if ( ! valid || true !== valid.valid || ! valid.answer ) {
return self.add_error( valid.valid );
}
LLMS.Ajax.call( {
data: {
action: 'quiz_answer_question',
answer: valid.answer,
attempt_key: self.attempt_key,
question_id: $question.attr( 'data-id' ),
question_type: $question.attr( 'data-type' ),
},
beforeSend: function() {
var msg = $btn.hasClass( 'llms-button-quiz-complete' ) ? LLMS.l10n.translate( 'Grading Quiz...' ) : LLMS.l10n.translate( 'Loading Question...' );
self.toggle_loader( 'show', msg );
self.update_progress_bar( 'increment' );
},
success: function( r ) {
self.toggle_loader( 'hide' );
if ( r.data && r.data.html ) {
// load html from the cached questions if it exists already
if ( r.data.question_id && self.questions[ 'q-' + r.data.question_id ] ) {
self.load_question( self.questions[ 'q-' + r.data.question_id ] );
// load html from server if the question's never been seen before
} else {
self.load_question( r.data.html );
}
} else if ( r.data && r.data.redirect ) {
self.redirect( r.data.redirect );
} else if ( r.message ) {
self.$container.append( '<p>' + r.message + '</p>' );
} else {
var msg = LLMS.l10n.translate( 'An unknown error occurred. Please try again.' );
self.$container.append( '<p>' + msg + '</p>' );
}
},
error: function ( jqXHR, status, error ) {
self.reload_question();
self.add_error( LLMS.l10n.translate( 'An unknown error occurred. Please try again.' ) );
console.log( error );
}
} );
},
/**
* Complete the quiz
* Called when timed quizzes reach time limit
* & during unload events to record the attempt as abandoned
*
* @return void
* @since 1.0.0
* @version 3.9.0
*/
complete_quiz: function() {
var self = this;
LLMS.Ajax.call( {
data: {
action: 'quiz_end',
attempt_key: self.attempt_key,
},
beforeSend: function() {
self.toggle_loader( 'show', 'Grading Quiz...' );
},
success: function( r ) {
self.toggle_loader( 'hide' );
if ( r.data && r.data.redirect ) {
self.redirect( r.data.redirect );
} else if ( r.message ) {
self.$container.append( '<p>' + r.message + '</p>' );
} else {
var msg = LLMS.l10n.translate( 'An unknown error occurred. Please try again.' );
self.$container.append( '<p>' + msg + '</p>' );
}
}
} );
},
/**
* Retrieve the index of a question by question id
*
* @param int qid WP Post ID of the question
* @return int
* @since 3.16.0
* @version 3.16.0
*/
get_question_index: function( qid ) {
return Object.keys( this.questions ).indexOf( 'q-' + qid );
},
/**
* Redirect on quiz completion / timeout
*
* @param string url redirect url
* @return void
* @since 3.9.0
* @version 3.16.0
*/
redirect: function( url ) {
this.toggle_loader( 'show', 'Grading Quiz...' );
this.status = null;
window.location.href = url;
},
reload_question: function() {
var self = this;
self.toggle_loader( 'show', LLMS.l10n.translate( 'Loading Question...' ) );
self.update_progress_bar( 'reload' );
setTimeout( function() {
self.toggle_loader( 'hide' );
self.load_question( self.questions[ 'q-' + self.current_question ] );
}, 100 );
},
/**
* Return to the previous question
*
* @return void
* @since 1.0.0
* @version 3.16.6
*/
previous_question: function() {
var self = this;
self.toggle_loader( 'show', LLMS.l10n.translate( 'Loading Question...' ) );
self.update_progress_bar( 'decrement' );
var ids = Object.keys( self.questions ),
curr = ids.indexOf( 'q-' + self.current_question ),
prev_id = ids[0];
if ( curr >= 1 ) {
prev_id = ids[ curr - 1 ];
}
setTimeout( function() {
self.toggle_loader( 'hide' );
self.load_question( self.questions[ prev_id ] );
}, 100 );
},
/**
* Register question type validator functions
*
* @param string type question type id
* @param function func callback function to validate the question with
* @return void
* @since 3.16.0
* @version 3.16.0
*/
register_validator: function( type, func ) {
this.validators[ type ] = func;
},
/**
* Start a Quiz via AJAX call
*
* @return void
* @since 1.0.0
* @version 3.24.3
*/
start_quiz: function () {
var self = this;
this.load_ui_elements();
this.$ui = $( '#llms-quiz-ui' );
this.$buttons = $( '#llms-quiz-nav button' );
this.$container = $( '#llms-quiz-question-wrapper' );
// bind submission event for answering questions
$( '#llms-next-question, #llms-complete-quiz' ).on( 'click', function( e ) {
e.preventDefault();
self.answer_question( $( this ) );
} );
// bind submission event for navigating backwards
$( '#llms-prev-question' ).on( 'click', function( e ) {
e.preventDefault();
self.previous_question();
} );
LLMS.Ajax.call( {
data: {
action: 'quiz_start',
attempt_key: $( '#llms-attempt-key' ).val(),
lesson_id : $( '#llms-lesson-id' ).val(),
quiz_id : $( '#llms-quiz-id' ).val(),
},
beforeSend: function() {
self.status = true;
$( '#llms-quiz-wrapper, #quiz-start-button' ).remove();
$( 'html, body' ).stop().animate( {scrollTop: 0 }, 500 );
self.toggle_loader( 'show', LLMS.l10n.translate( 'Loading Quiz...' ) );
},
error: function( r, s, t ) {
console.log( r, s, t );
},
success: function( r ) {
self.toggle_loader( 'hide' );
if ( r.data && r.data.html ) {
// start the quiz timer when a time limit is set
if ( r.data.time_limit ) {
self.start_quiz_timer( r.data.time_limit );
}
self.attempt_key = r.data.attempt_key;
self.total_questions = r.data.total;
self.load_question( r.data.html );
} else if ( r.message ) {
self.$container.append( '<p>' + r.message + '</p>' );
} else {
var msg = LLMS.l10n.translate( 'An unknown error occurred. Please try again.' );
self.$container.append( '<p>' + msg + '</p>' );
}
}
} );
/**
* Use JS mouse events instead of CSS :hover because iOS is really smart
*
* @see: https://css-tricks.com/annoying-mobile-double-tap-link-issue/
*/
if ( ! LLMS.is_touch_device() ) {
this.$ui.on( 'mouseenter', 'li.llms-choice label', function() {
$( this ).addClass( 'hovered' );
} );
this.$ui.on( 'mouseleave', 'li.llms-choice label', function() {
$( this ).removeClass( 'hovered' );
} );
}
},
/**
* Start Quiz Timer
* Gets minutes from hidden field
* Not used as actual quiz timer. Quiz is timed on the server from the quiz class
* Calculates minutes to milliseconds and then converts to hours / minutes
* When time limit reaches 0 calls complete_quiz() to complete quiz.
*
* @return Calls get_count_down at a set interval of 1 second
* @since 1.0.0
* @version 3.16.0
*/
start_quiz_timer: function( total_minutes ) {
// create and append the UI for the countdown clock
var $el = $( '<div class="llms-quiz-timer" id="llms-quiz-timer" />' ),
msg = LLMS.l10n.translate( 'Time Remaining' );
$el.append( '<i class="fa fa-clock-o" aria-hidden="true"></i><span class="screen-reader-text">' + msg + '</span>' );
$el.append( '<div id="llms-tiles" class="llms-tiles"></div>' );
$( '#llms-quiz-header' ).append( $el );
// start the timer
var self = this,
target_date = new Date().getTime() + ( ( total_minutes * 60 ) * 1000 ), // set the countdown date
time_limit = ( ( total_minutes * 60 ) * 1000 ),
countdown = document.getElementById( 'llms-tiles' ), // get tag element
days, hours, minutes, seconds; // variables for time units
// set actual timer
setTimeout( function() {
self.complete_quiz();
}, time_limit + 1000 );
this.getCountdown(
total_minutes,
target_date,
time_limit,
days,
hours,
minutes,
seconds,
countdown
);
// call get_count_down every 1 second
setInterval( function () {
self.getCountdown(
total_minutes,
target_date,
time_limit,
days,
hours,
minutes,
seconds,
countdown
);
}, 1000 );
},
/**
* Trigger events
*
* @param string event event to trigger
* @return void
* @since 3.16.0
* @version 3.16.0
*/
trigger: function( event ) {
var self = this;
// trigger question submission for the current question
if ( 'answer_question' === event ) {
if ( this.get_question_index( self.current_question ) === self.total_questions ) {
$( '#llms-complete-quiz' ).trigger( 'click' );
} else {
$( '#llms-next-question' ).trigger( 'click' );
}
}
},
/**
* Load the HTML of a question into the DOM and the question cache
*
* @param string html string of html
* @return void
* @since 3.9.0
* @version 3.16.6
*/
load_question: function( html ) {
var $html = $( html ),
qid = $html.attr( 'data-id' );
// cache the question HTML for faster rewinds
if ( ! this.questions[ 'q-' + qid ] ) {
this.questions[ 'q-' + qid ] = $html;
}
this.update_progress( qid );
this.current_question = qid;
$( document ).trigger( 'llms-pre-append-question', $html );
this.$container.append( $html );
$( document ).trigger( 'llms-post-append-question', $html );
},
/**
* Constructs the quiz UI & adds the elements into the DOM
*
* @return void
* @since 3.16.0
* @version 3.16.9
*/
load_ui_elements: function() {
var $html = $( '<div class="llms-quiz-ui" id="llms-quiz-ui" />' ),
$header = $( '<header class="llms-quiz-header" id="llms-quiz-header" />' )
$footer = $( '<footer class="llms-quiz-nav" id="llms-quiz-nav" />' );
$footer.append( '<button class="button large llms-button-action" id="llms-next-question" name="llms_next_question" type="submit">' + LLMS.l10n.translate( 'Next Question' ) + '</button>' );
$footer.append( '<button class="button large llms-button-action llms-button-quiz-complete" id="llms-complete-quiz" name="llms_complete_quiz" type="submit" style="display:none;">' + LLMS.l10n.translate( 'Complete Quiz' ) + '</button>' );
$footer.append( '<button class="button llms-button-secondary" id="llms-prev-question" name="llms_prev_question" type="submit" style="display:none;">' + LLMS.l10n.translate( 'Previous Question' ) + '</button>' );
$header.append( '<div class="llms-progress"><div class="progress-bar-complete"></div></div>' );
$footer.append( '<div class="llms-quiz-counter" id="llms-quiz-counter"><span class="llms-current"></span><span class="llms-sep">/</span><span class="llms-total"></span></div>' )
$html.append( $header )
.append( '<div class="llms-quiz-question-wrapper" id="llms-quiz-question-wrapper" />' )
.append( $footer );
$( '#llms-quiz-wrapper' ).after( $html );
},
/**
* Perform actions on question HTML after it's been appended to the DOM
*
* @param obj event js event object
* @param obj html js HTML object
* @return void
* @since 3.16.6
* @version 3.16.6
*/
post_append_question: function( event, html ) {
var $html = $( html );
if ( $html.find( 'audio' ).length ) {
wp.mediaelement.initialize();
}
},
/**
* Show or hide the "loading" spinner with an option message
*
* @param string display show|hide
* @param string msg text to display when showing
* @return void
* @since 3.9.0
* @version 3.16.6
*/
toggle_loader: function( display, msg ) {
if ( 'show' === display ) {
msg = msg || LLMS.l10n.translate( 'Loading...' );
this.$buttons.attr( 'disabled', 'disabled' );
this.$container.empty();
LLMS.Spinner.start( this.$container );
this.$container.append( '<div class="llms-quiz-loading">' + LLMS.l10n.translate( msg ) + '</div>' );
} else {
LLMS.Spinner.stop( this.$container );
this.$buttons.removeAttr( 'disabled' );
this.$container.find( '.llms-quiz-loading' ).remove();
}
},
/**
* Update the progress bar and toggle button availability based on question the question being shown
*
* @param {[type]} qid [description]
* @return {[type]}
* @since 3.16.0
* @version 3.16.0
*/
update_progress: function( qid ) {
var index = this.get_question_index( qid ),
progress;
if ( -1 === index ) {
return;
}
index++;
$( '#llms-quiz-counter .llms-current' ).text( index );
if ( index === 1 ) {
$( '#llms-quiz-counter .llms-total' ).text( this.total_questions );
$( '#llms-quiz-counter' ).show();
}
// handle prev question
if ( index >= 2 ) {
$( '#llms-prev-question' ).show();
} else {
$( '#llms-prev-question' ).hide();
}
if ( index === this.total_questions ) {
$( '#llms-next-question' ).hide();
$( '#llms-complete-quiz' ).show();
} else {
$( '#llms-next-question' ).show();
$( '#llms-complete-quiz' ).hide();
}
},
/**
* Increase progress bar ui element
*
* @param string dir update direction [increment|decrement|reload]
* @return void
* @since 3.16.0
* @version 3.16.0
*/
update_progress_bar: function( dir ) {
var index = this.get_question_index( this.current_question );
switch ( dir ) {
case 'increment':
index++;
break;
case 'decrement':
index--;
break;
case 'reload':
break;
}
progress = ( index / this.total_questions ) * 100;
this.$ui.find( '.progress-bar-complete' ).css( 'width', progress + '%' );
},
/**
* Get Count Down
* Called every second to update the on screen countdown timer
* Changes color to yellow at 1/2 of total time
* Changes color to red at 1/4 of total time
*
* @param {[int]} minutes [description]
* @param {[date]} target_date [description]
* @param {[int]} time_limit [description]
* @param {[int]} days [description]
* @param {[int]} hours [description]
* @param {[int]} minutes [description]
* @param {[int]} seconds [description]
* @param {[int]} countdown [description]
* @return Displays updates hours, minutes on quiz timer
* @since 1.0.0
* @version 1.0.0
*/
getCountdown: function( total_minutes, target_date, time_limit, days, hours, minutes, seconds, countdown ){
// find the amount of "seconds" between now and target
var current_date = new Date().getTime(),
seconds_left = ( target_date - current_date ) / 1000;
if ( seconds_left >= 0 ) {
if ( ( seconds_left * 1000 ) < ( time_limit / 2 ) ) {
$( '#llms-quiz-timer' ).addClass( 'color-half' );
}
if ( ( seconds_left * 1000 ) < ( time_limit / 4 ) ) {
$( '#llms-quiz-timer' ).removeClass( 'color-half' );
$( '#llms-quiz-timer' ).addClass( 'color-empty' );
}
days = this.pad( parseInt( seconds_left / 86400 ) );
seconds_left = seconds_left % 86400;
hours = this.pad( parseInt( seconds_left / 3600 ) );
seconds_left = seconds_left % 3600;
minutes = this.pad( parseInt( seconds_left / 60 ) );
seconds = this.pad( parseInt( seconds_left % 60 ) );
// format countdown string + set tag value
countdown.innerHTML = '<span class="hours">' + hours + '</span>:<span class="minutes">' + minutes + '</span>:<span class="seconds">' + seconds + '</span>';
}
},
/**
* Pad Number
* pads number with 0 if single digit.
*
* @param {[int]} n [number]
* @return {[string]} [padded number]
* @since 1.0.0
* @version 1.0.0
*/
pad: function(n) {
return (n < 10 ? '0' : '') + n;
},
/**
* Basic validation method which performs no validation and returns a validation object
* in the format required by the application
*
* @param obj $question jQuery selector of the question
* @return obj
* @since 3.16.0
* @version 3.16.0
*/
validate: function( $question ) {
return {
answer: [],
valid: true,
};
},
/**
* Validates a choice question to ensure there's at least one checked input
*
* @param obj $question jQuery selector of the question
* @return obj
* @since 3.16.0
* @version 3.16.0
*/
validate_choice: function( $question ) {
var ret = window.llms.quizzes.validate( $question ),
checked = $question.find( 'input:checked' );
if ( ! checked.length ) {
ret.valid = LLMS.l10n.translate( 'You must select an answer to continue.' );
} else {
checked.each( function() {
ret.answer.push( $( this ).val() );
} );
}
return ret;
},
};
quiz.bind();
window.llms = window.llms || {};
window.llms.quizzes = quiz;
} )( jQuery );