gocodebox/lifterlms

View on GitHub
assets/js/llms-quiz.js

Summary

Maintainability
D
2 days
Test Coverage
;/* 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 );