diff --git a/.changelogs/feature_quiz-resume-4.yml b/.changelogs/feature_quiz-resume-4.yml new file mode 100644 index 0000000000..a86a9cfe7c --- /dev/null +++ b/.changelogs/feature_quiz-resume-4.yml @@ -0,0 +1,4 @@ +significance: patch +type: fixed +entry: Fixed reference in `LLMS_Ajax_Handler::quiz_start()` to + `LLMS_Quiz_Attempt::get_status()` method removed since LifterLMS 4.0.0. diff --git a/.changelogs/feature_quiz-resume-akash-3.yml b/.changelogs/feature_quiz-resume-akash-3.yml new file mode 100644 index 0000000000..f979f75b7c --- /dev/null +++ b/.changelogs/feature_quiz-resume-akash-3.yml @@ -0,0 +1,3 @@ +significance: minor +type: changed +entry: Added support for image upload in Result Clarifications box for quizzes. diff --git a/.changelogs/feature_quiz-resume-akash-5.yml b/.changelogs/feature_quiz-resume-akash-5.yml new file mode 100644 index 0000000000..5efe2f8866 --- /dev/null +++ b/.changelogs/feature_quiz-resume-akash-5.yml @@ -0,0 +1,4 @@ +significance: minor +type: dev +entry: Added filter `llms_quiz_attempt_resume_time_period` for updating quiz resume + allowed time period. diff --git a/.changelogs/feature_quiz-resume.yml b/.changelogs/feature_quiz-resume.yml new file mode 100644 index 0000000000..0e4979123b --- /dev/null +++ b/.changelogs/feature_quiz-resume.yml @@ -0,0 +1,3 @@ +significance: minor +type: added +entry: "Added new feature: Quiz Resume." diff --git a/assets/js/builder/Models/Quiz.js b/assets/js/builder/Models/Quiz.js index 4251c5848c..71be58af24 100644 --- a/assets/js/builder/Models/Quiz.js +++ b/assets/js/builder/Models/Quiz.js @@ -52,6 +52,7 @@ define( [ * @since 3.16.0 * @since 7.4.0 Added filter for filtering defaults. * @since 7.5.0 Replaced unused `random_answers` property with `random_questions`. + * @since [version] Added filter for filtering defaults and `can_be_resumed` property. * * @return {Object} */ @@ -75,6 +76,7 @@ define( [ random_questions: 'no', time_limit: 30, show_correct_answer: 'no', + can_be_resumed: 'no', disable_retake: 'no', questions: [], diff --git a/assets/js/builder/Schemas/Quiz.js b/assets/js/builder/Schemas/Quiz.js index 31d13a8483..3c0b53b0d2 100644 --- a/assets/js/builder/Schemas/Quiz.js +++ b/assets/js/builder/Schemas/Quiz.js @@ -4,7 +4,9 @@ * @since 3.17.6 * @since 7.4.0 Added upsell for Question Bank and condition in `random_questions` schema. * @since 7.6.2 Added `disable_retake` schema. - * @version 7.6.2 + * @since [version] Added `can_be_resumed` option. + * @version [version] + */ define( [], function() { @@ -56,6 +58,17 @@ define( [], function() { type: 'switch-number', }, ], [ + + { + attribute: 'can_be_resumed', + id: 'resume', + label: LLMS.l10n.translate( 'Can be resumed' ), + tip: LLMS.l10n.translate( 'Allow a new attempt on this quiz to be resumed' ), + type: 'switch', + condition: function() { + return 'yes' === this.get( 'limit_time' ) ? false : true; + } + }, { attribute: 'show_correct_answer', id: 'show-correct-answer', diff --git a/assets/js/builder/Views/Question.js b/assets/js/builder/Views/Question.js index a91ee52382..c911397d99 100644 --- a/assets/js/builder/Views/Question.js +++ b/assets/js/builder/Views/Question.js @@ -1,7 +1,8 @@ /** - * Single Question View - * @since 3.16.0 - * @version 3.27.0 + * Single Question View. + * + * @since 3.16.0 + * @version [version] */ define( [ 'Views/_Detachable', @@ -82,10 +83,12 @@ define( [ }, /** - * Compiles the template and renders the view - * @return self (for chaining) - * @since 3.16.0 - * @version 3.16.0 + * Compiles the template and renders the view. + * + * @since 3.16.0 + * @since [version] Added support for image upload in tinyMCE editor. + * + * @return self (for chaining) */ render: function() { @@ -124,7 +127,7 @@ define( [ if ( this.model.get( 'clarifications_enabled' ) ) { this.init_editor( 'question-clarifications--' + this.model.get( 'id' ), { - mediaButtons: false, + mediaButtons: true, tinymce: { toolbar1: 'bold,italic,strikethrough,bullist,numlist,alignleft,aligncenter,alignright', toolbar2: '', diff --git a/assets/js/llms-quiz.js b/assets/js/llms-quiz.js index 3c46bc1324..3398afb37c 100644 --- a/assets/js/llms-quiz.js +++ b/assets/js/llms-quiz.js @@ -2,11 +2,12 @@ /* jshint strict: true */ /** - * Front End Quiz Class + * Front End Quiz Class. * - * @type {Object} - * @since 1.0.0 - * @version 3.24.3 + * @type {Object} + * + * @since 1.0.0 + * @version [version] */( function( $ ) { var quiz = { @@ -14,93 +15,111 @@ /** * Selector of all the available button elements * - * @type obj + * @type {Object} */ $buttons: null, /** - * Main Question Container Element + * Main Question Container Element. * - * @type obj + * @type {Object} */ $container: null, /** - * Main Quiz container UI element + * Main Quiz container UI element. * - * @type obj + * @type {Object} */ $ui: null, /** - * Attempt key for the current quiz + * Attempt key for the current quiz. * - * @type {[type]} + * @type {[type]} */ attempt_key: null, /** - * Question ID of the current question + * Question ID of the current question. * - * @type {Number} + * @type {Number} */ current_question: 0, /** - * Total number of questions in the current quiz + * Total number of questions in the current quiz. * - * @type {Number} + * @type {Number} */ total_questions: 0, /** - * Object of quiz question HTML + * Object of quiz question HTML. * - * @type {Object} + * @type {Object} */ questions: {}, /** - * Validator functions for question types - * Third party custom question types can register validators for use when answering questions + * Validator functions for question type. + * Third party custom question types can register validators for use when answering questions. * - * @type {Object} + * @type {Object} */ validators: {}, /** - * Records current status of a quiz session + * 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 + * will not be saved if this status is not null. * - * @type boolean + * @type {Bool} */ status: null, /** - * Bind DOM events + * Records if the quiz can be resumed. + */ + resumable: null, + + /** + * Flag if the user is exiting the quiz. + */ + exiting_quiz: false, + + /** + * Bind DOM events. * - * @return void - * @since 1.0.0 - * @version 3.16.6 + * @since 1.0.0 + * @since 3.16.6 Unknown. + * @since [version] Add quiz resume and hide leave warning if quiz is resumable. + * + * @return {Void} */ bind: function() { var self = this; - // start quiz + // Start quiz. $( '#llms_start_quiz' ).on( 'click', function( e ) { e.preventDefault(); self.start_quiz(); } ); - // draw quiz grade circular chart + // Resume quiz. + $( '#llms_resume_quiz' ).on( 'click', function( e ) { + e.preventDefault(); + self.resume_quiz(); + } ); + + // Draw quiz grade circular chart. $( '.llms-donut' ).each( function() { LLMS.Donut( $( this ) ); } ); - // redirect to attempt on attempt selection change + // Redirect to attempt on attempt selection change. $( '#llms-quiz-attempt-select' ).on( 'change', function() { var val = $( this ).val(); if ( val ) { @@ -108,23 +127,25 @@ } } ); - // warn when quiz is running and user tries to leave the page + // Warn when quiz is running and user tries to leave the page when quiz is not resumable. $( window ).on( 'beforeunload', function() { - if ( self.status ) { + if ( self.status && ! self.exiting_quiz ) { return LLMS.l10n.translate( 'Are you sure you wish to quit this quiz attempt?' ); } + + return; } ); - // complete the quiz attempt when user leaves if the quiz is running + // Complete the quiz attempt when user leaves if the quiz is running. $( window ).on( 'unload', function() { - if ( self.status ) { + if ( self.status && ! self.resumable ) { self.complete_quiz(); } } ); $( document ).on( 'llms-post-append-question', self.post_append_question ); - // register validators + // Register validators. this.register_validator( 'content', this.validate ); this.register_validator( 'choice', this.validate_choice ); this.register_validator( 'picture_choice', this.validate_choice ); @@ -157,6 +178,45 @@ }, + save_question: function( options ) { + 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 ); + + var requestData = { + action: 'quiz_answer_question', + answer: valid.answer, + attempt_key: self.attempt_key, + question_id: $question.attr( 'data-id' ), + question_type: $question.attr( 'data-type' ), + }; + + if ( options && options.exit_quiz ) { + requestData.via_exit_quiz = true; + } + + if ( options && options.previous_question ) { + requestData.via_previous_question = true; + } + + LLMS.Ajax.call( { + data: requestData, + success: function( r ) { + if (options && typeof options.callback === 'function') { + options.callback(); + } + }, + }); + }, + /** * Answer a Question * @@ -334,32 +394,75 @@ }, /** - * Return to the previous question + * Return to the previous question. * - * @return void - * @since 1.0.0 - * @version 3.16.6 + * @since 1.0.0 + * @since 3.16.6 Unknown. + * @since [version] Retrieve question HTML from the server when not cached. + * + * @return {Void} */ previous_question: function() { var self = this; - self.toggle_loader( 'show', LLMS.l10n.translate( 'Loading Question...' ) ); - self.update_progress_bar( 'decrement' ); + this.save_question( { + previous_question: true, + callback: function() { - var ids = Object.keys( self.questions ), - curr = ids.indexOf( 'q-' + self.current_question ), - prev_id = ids[0]; + self.toggle_loader( 'show', LLMS.l10n.translate( 'Loading Question...' ) ); + self.update_progress_bar( 'decrement' ); - if ( curr >= 1 ) { - prev_id = ids[ curr - 1 ]; - } + var ids = Object.keys( self.questions ), + curr = ids.indexOf( 'q-' + self.current_question ), + prev_id = ids[0]; - setTimeout( function() { - self.toggle_loader( 'hide' ); - self.load_question( self.questions[ prev_id ] ); - }, 100 ); + if ( curr >= 1 ) { + prev_id = ids[ curr - 1 ]; + } + + // Retrieve previous question HTML from the server. + if ( ! self.questions[ prev_id ] ) { + LLMS.Ajax.call( { + data: { + action : 'quiz_get_question', + attempt_key: self.attempt_key, + question_id: prev_id.substring(2), // Remove 'q-'. + }, + success: function( r ) { + + self.toggle_loader( 'hide' ); + if ( r.data && r.data.html ) { + + 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( '

' + r.message + '

' ); + + } else { + + var msg = LLMS.l10n.translate( 'An unknown error occurred. Please try again.' ); + self.$container.append( '

' + msg + '

' ); + + } + + } + + } ); + + } else { + setTimeout( function() { + self.toggle_loader( 'hide' ); + self.load_question( self.questions[ prev_id ] ); + }, 100 ); + } + } + }); }, /** @@ -378,44 +481,96 @@ }, /** - * Start a Quiz via AJAX call + * Start a Quiz. * - * @return void - * @since 1.0.0 - * @version 3.24.3 + * @since 1.0.0 + * @since 3.24.3 Unknown. + * @since [version] Abstracted the function in `init_quiz`. + * + * @return {Void} */ start_quiz: function () { + this.init_quiz( 'quiz_start' ); + }, + + /** + * Resume a Quiz. + * + * @since [version] + * + * @return {Void} + */ + resume_quiz: function () { + + this.init_quiz( 'quiz_resume' ); + }, + + /** + * Initiate 'Start' or 'Resume' action on a Quiz via AJAX call. + * + * @since [version] + * + * @return {Void} + */ + init_quiz: function ( action ) { + var self = this; + if( 'quiz_resume' === action ) { + // Disable resume button. + $( '#llms_resume_quiz' ).attr( 'disabled', 'disabled' ); + } + 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 + // 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 + // Bind submission event for navigating backwards. $( '#llms-prev-question' ).on( 'click', function( e ) { e.preventDefault(); self.previous_question(); } ); - LLMS.Ajax.call( { - data: { + // Bind exit event for quiz. + $( '#llms-quiz-nav' ).on( 'click', '#llms-exit-quiz', function( e ) { + e.preventDefault(); + self.save_question( { + exit_quiz: true, + callback: function() { + self.exiting_quiz = true; + window.location.reload(); + } + }); + } ); + + if ( 'quiz_resume' === action ) { + data = { + action: 'quiz_resume', + attempt_key: $( '#llms-attempt-key' ).val(), + }; + } else { + data = { action: 'quiz_start', attempt_key: $( '#llms-attempt-key' ).val(), lesson_id : $( '#llms-lesson-id' ).val(), quiz_id : $( '#llms-quiz-id' ).val(), - }, + }; + } + + LLMS.Ajax.call( { + data: data, beforeSend: function() { self.status = true; - $( '#llms-quiz-wrapper, #quiz-start-button' ).remove(); + $( '#llms-quiz-wrapper, #quiz-start-button, #quiz-resume-button' ).remove(); $( 'html, body' ).stop().animate( {scrollTop: 0 }, 500 ); self.toggle_loader( 'show', LLMS.l10n.translate( 'Loading Quiz...' ) ); @@ -429,16 +584,27 @@ if ( r.data && r.data.html ) { - // start the quiz timer when a time limit is set - if ( r.data.time_limit ) { + self.attempt_key = r.data.attempt_key; + self.total_questions = r.data.total; + self.resumable = r.data.can_be_resumed; + + if( 'quiz_resume' === action ) { + r.data.question_ids.forEach( id => self.questions[`q-${id}`] = '' ); + } else 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; + // Adding Exit Button in Layout if quiz is resumable. + if ( self.resumable ) { + $( '#llms-quiz-nav' ).append( '' ); + } self.load_question( r.data.html ); + if ( 'quiz_resume' === action ) { + self.update_progress_bar( 'reload' ); + } + } else if ( r.message ) { self.$container.append( '

' + r.message + '

' ); @@ -469,7 +635,6 @@ } ); } - }, /** @@ -600,6 +765,11 @@ */ load_ui_elements: function() { + // Removing the quiz UI elements if they already exist. + if ( $( '#llms-quiz-ui').length > 0 ) { + $( '#llms-quiz-ui' ).remove(); + } + var $html = $( '
' ), $header = $( '
' ) $footer = $( '