1/** 2 * Class that represents an HTML Quiz. Provides methods for checking answers, generating a score and providing visual feedback. 3 * 4 * See https://alpsquid.github.io/quizlib for usage 5 * 6 * @class Quiz 7 * @constructor 8 * @param {String} quizContainer ID of the quiz container element. 9 * @param {Array} answers Array of correct answers using the input value. e.g. ['a', '7', ['a', 'b']]. 10 * Can use nested arrays for multi-answers such as checkbox questions 11 * @example 12 * new Quiz('quiz-div', ['a', '7', ['c', 'd'], 'squids', ['a', 'b']]); 13 */ 14var Quiz = function(quizContainer, answers) { 15 /** 16 * Enum containing classes used by QuizLib as follows: 17 * - **QUESTION**: 'quizlib-question' 18 * - used to identify a question element 19 * - **QUESTION_TITLE**: 'quizlib-question-title' 20 * - used to identify the question title element 21 * - **QUESTION_WARNING**: 'quizlib-question-answers' 22 * - used to identify the element containing question answers 23 * - **QUESTION_ANSWERS**: 'quizlib-question-warning' 24 * - used by the 'unanswered question warning' element. Removed by {{#crossLink "Quiz/clearHighlights:method"}}{{/crossLink}} 25 * - **CORRECT**: 'quizlib-correct' 26 * - added to question titles to highlight correctly answered questions. 27 * Use freely to take advantage of {{#crossLink "Quiz/highlightResults:method"}}{{/crossLink}} and {{#crossLink "Quiz/clearHighlights:method"}}{{/crossLink}} 28 * - **INCORRECT**: 'quizlib-incorrect' 29 * - added to question titles to highlight incorrectly answered questions. 30 * Use freely to take advantage of {{#crossLink "Quiz/highlightResults:method"}}{{/crossLink}} and {{#crossLink "Quiz/clearHighlights:method"}}{{/crossLink}} 31 * - **TEMP**: 'quizlib-temp' 32 * - Add to any elements you want to be removed by {{#crossLink "Quiz/clearHighlights:method"}}{{/crossLink}} (called by {{#crossLink "Quiz/checkAnswers:method"}}{{/crossLink}}). 33 * For example, adding an element with the correct answer in your {{#crossLink "Quiz/clearHighlights:method"}}{{/crossLink}} callback and have it be removed automatically 34 * 35 * @property Classes 36 * @type Object 37 * @default See above 38 * @final 39 */ 40 this.Classes = Object.freeze({ 41 QUESTION: "quizlib-question", 42 QUESTION_TITLE: "quizlib-question-title", 43 QUESTION_ANSWERS: "quizlib-question-answers", 44 QUESTION_WARNING: "quizlib-question-warning", 45 CORRECT: "quizlib-correct", 46 INCORRECT: "quizlib-incorrect", 47 TEMP: "quizlib-temp" 48 }); 49 50 /** 51 * Warning displayed on unanswered questions 52 * 53 * @property unansweredQuestionText 54 * @type String 55 * @default 'Unanswered Question!' 56 */ 57 this.unansweredQuestionText = 'Unanswered Question!'; 58 59 // Quiz container element 60 this.container = document.getElementById(quizContainer); 61 this.questions = []; 62 /** 63 * Quiz Result object containing quiz score information. See {{#crossLink "QuizResult"}}{{/crossLink}} 64 * 65 * @property result 66 * @type QuizResult 67 */ 68 this.result = new QuizResult(); 69 /** 70 * User defined answers taken from constructor 71 * 72 * @property answers 73 * @type Array 74 */ 75 this.answers = answers; 76 77 // Get all the questions and add element to the questions array 78 for (var i=0; i < this.container.children.length; i++) { 79 if (this.container.children[i].classList.contains(this.Classes.QUESTION)) { 80 this.questions.push(this.container.children[i]); 81 } 82 } 83 84 if (this.answers.length != this.questions.length) { 85 throw new Error("Number of answers does not match number of questions!"); 86 } 87}; 88 89/** 90 * Checks quiz answers against provided answers. Calls {{#crossLink "Quiz/clearHighlights:method"}}{{/crossLink}} for each question. 91 * 92 * @method checkAnswers 93 * @param {Boolean} [flagUnanswered=true] Whether to ignore unanswered questions. If false, unanswered questions will not be flagged 94 * @return {Boolean} True or if *flagUnanswered* is true: True if all questions have been answered. Otherwise false and unanswered questions are highlighted. 95 */ 96Quiz.prototype.checkAnswers = function(flagUnanswered) { 97 if (flagUnanswered === undefined) flagUnanswered = true; 98 var unansweredQs = []; 99 var questionResults = []; 100 for (var i=0; i < this.questions.length; i++) { 101 var question = this.questions[i]; 102 var answer = this.answers[i]; 103 var userAnswer = []; 104 105 this.clearHighlights(question); 106 107 // Get answers 108 var answerInputs = question.getElementsByClassName(this.Classes.QUESTION_ANSWERS)[0].getElementsByTagName('input'); 109 var input; 110 for (var k=0; k < answerInputs.length; k++) { 111 input = answerInputs[k]; 112 if (input.type === "checkbox" || input.type === "radio") { 113 if (input.checked) userAnswer.push(input.value); 114 } else if (input.value !== '') { 115 userAnswer.push(input.value); 116 } 117 } 118 // Remove single answer from array to match provided answer format 119 if (userAnswer.length == 1 && !Array.isArray(answer)) { 120 userAnswer = userAnswer[0]; 121 } else if (userAnswer.length === 0) { 122 unansweredQs.push(question); 123 } 124 125 questionResults.push(Utils.compare(userAnswer, answer)); 126 } 127 128 if (unansweredQs.length === 0 || !flagUnanswered) { 129 this.result.setResults(questionResults); 130 return true; 131 } 132 else { 133 // Highlight unanswered questions if set 134 for (i=0; i < unansweredQs.length; i++) { 135 var warning = document.createElement('span'); 136 warning.appendChild(document.createTextNode(this.unansweredQuestionText)); 137 warning.className = this.Classes.QUESTION_WARNING; 138 unansweredQs[i].getElementsByClassName(this.Classes.QUESTION_TITLE)[0].appendChild(warning); 139 } 140 } 141 return false; 142}; 143 144/** 145 * Clears highlighting for a question element (correct and incorrect classes), including unanswered question warnings and elements using the Classes.TEMP class 146 * 147 * @method clearHighlights 148 * @param {HTMLDocument} question Question element to clear 149 */ 150Quiz.prototype.clearHighlights = function(question) { 151 // Remove question warning if it exists 152 var questionWarnings = question.getElementsByClassName(this.Classes.QUESTION_WARNING); 153 while (questionWarnings.length > 0) { 154 questionWarnings[0].parentNode.removeChild(questionWarnings[0]); 155 } 156 157 // Remove highlighted elements 158 var highlightedQuestions = [question.getElementsByClassName(this.Classes.CORRECT), question.getElementsByClassName(this.Classes.INCORRECT)]; 159 var highlightedElement; 160 for (i=0; i < highlightedQuestions.length; i++) { 161 while (highlightedQuestions[i].length > 0) { 162 highlightedElement = highlightedQuestions[i][0]; 163 highlightedElement.classList.remove(this.Classes.CORRECT); 164 highlightedElement.classList.remove(this.Classes.INCORRECT); 165 } 166 } 167 168 // Remove temp elements 169 var tempElements = question.getElementsByClassName(this.Classes.TEMP); 170 while (tempElements.length > 0) { 171 tempElements[0].parentNode.removeChild(tempElements[0]); 172 } 173}; 174 175/** 176 * Highlights correctly and incorrectly answered questions by: 177 * - Adding the class 'quizlib-correct' to correctly answered question titles 178 * - Adding the class 'quizlib-incorrect' to incorrectly answered question titles 179 * 180 * @method highlightResults 181 * @param {Function} [questionCallback] Optional callback for each question with the following arguments: 182 * 1. Element: the question element 183 * 2. Number: question number 184 * 3. Boolean: true if correct, false if incorrect. 185 * 186 * This allows you to further customise the handling of answered questions (and decouples the library from a specific HTML structure), for example highlighting the correct answer(s) on incorrect questions. 187 * Use the Classes.TEMP ('quizlib-temp') class on added elements that you want removing when {{#crossLink "Quiz/checkAnswers:method"}}{{/crossLink}} is called 188 * 189 * @example 190 * ``` 191 * // Method Call 192 * quiz.highlightResults(handleAnswers); 193 * 194 * // handleAnswers callback 195 * function handleAnswers(questionElement, questionNo, correctFlag) { 196 * ... 197 * } 198 * ``` 199 */ 200Quiz.prototype.highlightResults = function(questionCallback) { 201 var question; 202 for (var i=0; i < this.questions.length; i++) { 203 question = this.questions[i]; 204 if (this.result.results[i]) { 205 question.getElementsByClassName(this.Classes.QUESTION_TITLE)[0].classList.add(this.Classes.CORRECT); 206 } 207 else { 208 question.getElementsByClassName(this.Classes.QUESTION_TITLE)[0].classList.add(this.Classes.INCORRECT); 209 } 210 if (questionCallback !== undefined) questionCallback(question, i, this.result.results[i]); 211 } 212}; 213 214 215/** 216 * Quiz Result class that holds score information 217 * 218 * @class QuizResult 219 * @constructor 220 */ 221var QuizResult = function() { 222 /** 223 * Array of booleans where the index is the question number and the value is whether the question was answered correctly. Updated by {{#crossLink "QuizResult/setResults:method"}}{{/crossLink}} 224 * @property results 225 * @type Array 226 */ 227 this.results = []; 228 /** 229 * Total number of questions. Updated by {{#crossLink "QuizResult/setResults:method"}}{{/crossLink}} 230 * @property totalQuestions 231 * @type Number 232 */ 233 this.totalQuestions = 0; 234 /** 235 * Number of questions answered correctly. Updated by {{#crossLink "QuizResult/setResults:method"}}{{/crossLink}} 236 * @property score 237 * @type Number 238 */ 239 this.score = 0; 240 /** 241 * Percentage score between 0 and 1. Updated by {{#crossLink "QuizResult/setResults:method"}}{{/crossLink}} 242 * @property scorePercent 243 * @type Number 244 */ 245 this.scorePercent = 0; 246 /** 247 * Formatted score percent that's more useful to humans (1 - 100). Percent is rounded down. Updated by {{#crossLink "QuizResult/setResults:method"}}{{/crossLink}} 248 * @property scorePercentFormatted 249 * @type Number 250 */ 251 this.scorePercentFormatted = 0; 252}; 253 254/** 255 * Calculates score information from an array of question results and updates properties 256 * 257 * @method setResults 258 * @param {Array} questionResults Array of question results where the index is the question number and the value is whether the question was answered correctly. e.g. [true, true, false] 259 */ 260QuizResult.prototype.setResults = function(questionResults) { 261 this.results = questionResults; 262 this.totalQuestions = this.results.length; 263 this.score = 0; 264 for (var i=0; i < this.results.length; i++) { 265 if (this.results[i]) this.score++; 266 } 267 this.scorePercent = this.score / this.totalQuestions; 268 this.scorePercentFormatted = Math.floor(this.scorePercent * 100); 269}; 270 271 272/** 273 * Utils class that provides useful methods 274 * 275 * @class Utils 276 */ 277var Utils = function() {}; 278/** 279 * Compare two objects without coercion. If objects are arrays, their contents will be compared, including order. 280 * 281 * @method compare 282 * @param {Object} obj1 main object 283 * @param {Object} obj2 object to compare obj1 against 284 * @return {boolean} True if objects are equal 285 */ 286Utils.compare = function(obj1, obj2) { 287 if (obj1.length != obj2.length) return false; 288 289 if (Array.isArray(obj1) && Array.isArray(obj2)) { 290 for (var i=0; i < obj1.length; i++) { 291 if (obj1[i] !== obj2[i]) return false; 292 } 293 return true; 294 } 295 return obj1 === obj2; 296}; 297