From 8aa1e927fddf56fe0e8a431dca2faa3ab47b0fca Mon Sep 17 00:00:00 2001 From: oliver foster Date: Thu, 25 Feb 2016 17:13:47 +0000 Subject: [PATCH 1/4] enhancement-967: divided into model and view --- js/adapt-contrib-mcq.js | 347 ++-------------------------------------- js/mcqModel.js | 175 ++++++++++++++++++++ js/mcqView.js | 186 +++++++++++++++++++++ 3 files changed, 370 insertions(+), 338 deletions(-) create mode 100644 js/mcqModel.js create mode 100644 js/mcqView.js diff --git a/js/adapt-contrib-mcq.js b/js/adapt-contrib-mcq.js index 410f7a6..c916f4a 100644 --- a/js/adapt-contrib-mcq.js +++ b/js/adapt-contrib-mcq.js @@ -1,341 +1,12 @@ -define(function(require) { - var QuestionView = require('coreViews/questionView'); - var Adapt = require('coreJS/adapt'); - - var Mcq = QuestionView.extend({ - - events: { - 'focus .mcq-item input':'onItemFocus', - 'blur .mcq-item input':'onItemBlur', - 'change .mcq-item input':'onItemSelected', - 'keyup .mcq-item input':'onKeyPress' - }, - - resetQuestionOnRevisit: function() { - this.setAllItemsEnabled(true); - this.resetQuestion(); - }, - - setupQuestion: function() { - // if only one answer is selectable, we should display radio buttons not checkboxes - this.model.set("_isRadio", (this.model.get("_selectable") == 1) ); - - this.model.set('_selectedItems', []); - - this.setupQuestionItemIndexes(); - - this.setupRandomisation(); - - this.restoreUserAnswers(); - }, - - setupQuestionItemIndexes: function() { - var items = this.model.get("_items"); - for (var i = 0, l = items.length; i < l; i++) { - if (items[i]._index === undefined) items[i]._index = i; - } - }, - - setupRandomisation: function() { - if (this.model.get('_isRandom') && this.model.get('_isEnabled')) { - this.model.set("_items", _.shuffle(this.model.get("_items"))); - } - }, - - restoreUserAnswers: function() { - if (!this.model.get("_isSubmitted")) return; - - var selectedItems = []; - var items = this.model.get("_items"); - var userAnswer = this.model.get("_userAnswer"); - _.each(items, function(item, index) { - item._isSelected = userAnswer[item._index]; - if (item._isSelected) { - selectedItems.push(item) - } - }); - - this.model.set("_selectedItems", selectedItems); - - this.setQuestionAsSubmitted(); - this.markQuestion(); - this.setScore(); - this.showMarking(); - this.setupFeedback(); - }, - - disableQuestion: function() { - this.setAllItemsEnabled(false); - }, - - enableQuestion: function() { - this.setAllItemsEnabled(true); - }, - - setAllItemsEnabled: function(isEnabled) { - _.each(this.model.get('_items'), function(item, index){ - var $itemLabel = this.$('label').eq(index); - var $itemInput = this.$('input').eq(index); - - if (isEnabled) { - $itemLabel.removeClass('disabled'); - $itemInput.prop('disabled', false); - } else { - $itemLabel.addClass('disabled'); - $itemInput.prop('disabled', true); - } - }, this); - }, - - onQuestionRendered: function() { - this.setReadyStatus(); - }, - - onKeyPress: function(event) { - if (event.which === 13) { // keypress - this.onItemSelected(event); - } - }, - - onItemFocus: function(event) { - if(this.model.get('_isEnabled') && !this.model.get('_isSubmitted')){ - $("label[for='"+$(event.currentTarget).attr('id')+"']").addClass('highlighted'); - } - }, - - onItemBlur: function(event) { - $("label[for='"+$(event.currentTarget).attr('id')+"']").removeClass('highlighted'); - }, - - onItemSelected: function(event) { - if(this.model.get('_isEnabled') && !this.model.get('_isSubmitted')){ - var selectedItemObject = this.model.get('_items')[$(event.currentTarget).parent('.component-item').index()]; - this.toggleItemSelected(selectedItemObject, event); - } - }, - - toggleItemSelected:function(item, clickEvent) { - var selectedItems = this.model.get('_selectedItems'); - var itemIndex = _.indexOf(this.model.get('_items'), item), - $itemLabel = this.$('label').eq(itemIndex), - $itemInput = this.$('input').eq(itemIndex), - selected = !$itemLabel.hasClass('selected'); - - if(selected) { - if(this.model.get('_selectable') === 1){ - this.$('label').removeClass('selected'); - this.$('input').prop('checked', false); - this.deselectAllItems(); - selectedItems[0] = item; - } else if(selectedItems.length < this.model.get('_selectable')) { - selectedItems.push(item); - } else { - clickEvent.preventDefault(); - return; - } - $itemLabel.addClass('selected'); - $itemLabel.a11y_selected(true); - } else { - selectedItems.splice(_.indexOf(selectedItems, item), 1); - $itemLabel.removeClass('selected'); - $itemLabel.a11y_selected(false); - } - $itemInput.prop('checked', selected); - item._isSelected = selected; - this.model.set('_selectedItems', selectedItems); - }, - - // check if the user is allowed to submit the question - canSubmit: function() { - var count = 0; - - _.each(this.model.get('_items'), function(item) { - if (item._isSelected) { - count++; - } - }, this); - - return (count > 0) ? true : false; - - }, - - // Blank method to add functionality for when the user cannot submit - // Could be used for a popup or explanation dialog/hint - onCannotSubmit: function() {}, - - // This is important for returning or showing the users answer - // This should preserve the state of the users answers - storeUserAnswer: function() { - var userAnswer = []; - - var items = this.model.get('_items').slice(0); - items.sort(function(a, b) { - return a._index - b._index; - }); - - _.each(items, function(item, index) { - userAnswer.push(item._isSelected); - }, this); - this.model.set('_userAnswer', userAnswer); - }, - - isCorrect: function() { - - var numberOfRequiredAnswers = 0; - var numberOfCorrectAnswers = 0; - var numberOfIncorrectAnswers = 0; - - _.each(this.model.get('_items'), function(item, index) { - - var itemSelected = (item._isSelected || false); - - if (item._shouldBeSelected) { - numberOfRequiredAnswers ++; - - if (itemSelected) { - numberOfCorrectAnswers ++; - - item._isCorrect = true; - - this.model.set('_isAtLeastOneCorrectSelection', true); - } - - } else if (!item._shouldBeSelected && itemSelected) { - numberOfIncorrectAnswers ++; - } - - }, this); - - this.model.set('_numberOfCorrectAnswers', numberOfCorrectAnswers); - this.model.set('_numberOfRequiredAnswers', numberOfRequiredAnswers); - - // Check if correct answers matches correct items and there are no incorrect selections - var answeredCorrectly = (numberOfCorrectAnswers === numberOfRequiredAnswers) && (numberOfIncorrectAnswers === 0); - return answeredCorrectly; - }, - - // Sets the score based upon the questionWeight - // Can be overwritten if the question needs to set the score in a different way - setScore: function() { - var questionWeight = this.model.get("_questionWeight"); - var answeredCorrectly = this.model.get('_isCorrect'); - var score = answeredCorrectly ? questionWeight : 0; - this.model.set('_score', score); - }, - - setupFeedback: function() { - - if (this.model.get('_isCorrect')) { - this.setupCorrectFeedback(); - } else if (this.isPartlyCorrect()) { - this.setupPartlyCorrectFeedback(); - } else { - // apply individual item feedback - if((this.model.get('_selectable') === 1) && this.model.get('_selectedItems')[0].feedback) { - this.setupIndividualFeedback(this.model.get('_selectedItems')[0]); - return; - } else { - this.setupIncorrectFeedback(); - } - } - }, - - setupIndividualFeedback: function(selectedItem) { - this.model.set({ - feedbackTitle: this.model.get('title'), - feedbackMessage: selectedItem.feedback - }); - }, - - // This is important and should give the user feedback on how they answered the question - // Normally done through ticks and crosses by adding classes - showMarking: function() { - _.each(this.model.get('_items'), function(item, i) { - var $item = this.$('.component-item').eq(i); - $item.removeClass('correct incorrect').addClass(item._isCorrect ? 'correct' : 'incorrect'); - }, this); - }, - - isPartlyCorrect: function() { - return this.model.get('_isAtLeastOneCorrectSelection'); - }, - - resetUserAnswer: function() { - this.model.set({_userAnswer: []}); - }, - - // Used by the question view to reset the look and feel of the component. - resetQuestion: function() { - - this.deselectAllItems(); - this.resetItems(); - }, - - deselectAllItems: function() { - this.$el.a11y_selected(false); - _.each(this.model.get('_items'), function(item) { - item._isSelected = false; - }, this); - }, - - resetItems: function() { - this.$('.component-item label').removeClass('selected'); - this.$('.component-item').removeClass('correct incorrect'); - this.$('input').prop('checked', false); - this.model.set({ - _selectedItems: [], - _isAtLeastOneCorrectSelection: false - }); - }, - - showCorrectAnswer: function() { - _.each(this.model.get('_items'), function(item, index) { - this.setOptionSelected(index, item._shouldBeSelected); - }, this); - }, - - setOptionSelected:function(index, selected) { - var $itemLabel = this.$('label').eq(index); - var $itemInput = this.$('input').eq(index); - if (selected) { - $itemLabel.addClass('selected'); - $itemInput.prop('checked', true); - } else { - $itemLabel.removeClass('selected'); - $itemInput.prop('checked', false); - } - }, - - hideCorrectAnswer: function() { - _.each(this.model.get('_items'), function(item, index) { - this.setOptionSelected(index, this.model.get('_userAnswer')[item._index]); - }, this); - }, - - /** - * used by adapt-contrib-spoor to get the user's answers in the format required by the cmi.interactions.n.student_response data field - * returns the user's answers as a string in the format "1,5,2" - */ - getResponse:function() { - var selected = _.where(this.model.get('_items'), {'_isSelected':true}); - var selectedIndexes = _.pluck(selected, '_index'); - // indexes are 0-based, we need them to be 1-based for cmi.interactions - for (var i = 0, count = selectedIndexes.length; i < count; i++) { - selectedIndexes[i]++; - } - return selectedIndexes.join(','); - }, - - /** - * used by adapt-contrib-spoor to get the type of this question in the format required by the cmi.interactions.n.type data field - */ - getResponseType:function() { - return "choice"; - } - +define([ + 'coreJS/adapt', + './mcqView', + './mcqModel' +], function(Adapt, McqView, McqModel) { + + return Adapt.register("mcq", { + view: McqView, + model: McqModel }); - Adapt.register("mcq", Mcq); - - return Mcq; }); diff --git a/js/mcqModel.js b/js/mcqModel.js new file mode 100644 index 0000000..2c6080f --- /dev/null +++ b/js/mcqModel.js @@ -0,0 +1,175 @@ +define([ + 'coreModels/questionModel' +], function(QuestionModel) { + + var McqModel = QuestionModel.extend({ + + init: function() { + QuestionModel.prototype.init.call(this); + + this.set("_isRadio", (this.get("_selectable") == 1) ); + + this.set('_selectedItems', []); + + this.setupQuestionItemIndexes(); + }, + + setupQuestionItemIndexes: function() { + var items = this.get("_items"); + for (var i = 0, l = items.length; i < l; i++) { + if (items[i]._index === undefined) items[i]._index = i; + } + }, + + restoreUserAnswers: function() { + if (!this.get("_isSubmitted")) return; + + var selectedItems = []; + var items = this.get("_items"); + var userAnswer = this.get("_userAnswer"); + _.each(items, function(item, index) { + item._isSelected = userAnswer[item._index]; + if (item._isSelected) { + selectedItems.push(item) + } + }); + + this.set("_selectedItems", selectedItems); + + this.setQuestionAsSubmitted(); + this.markQuestion(); + this.setScore(); + //this.showMarking(); + this.setupFeedback(); + }, + + setupRandomisation: function() { + if (this.get('_isRandom') && this.get('_isEnabled')) { + this.set("_items", _.shuffle(this.get("_items"))); + } + }, + + // check if the user is allowed to submit the question + canSubmit: function() { + var count = 0; + + _.each(this.get('_items'), function(item) { + if (item._isSelected) { + count++; + } + }, this); + + return (count > 0) ? true : false; + + }, + + // This is important for returning or showing the users answer + // This should preserve the state of the users answers + storeUserAnswer: function() { + var userAnswer = []; + + var items = this.get('_items').slice(0); + items.sort(function(a, b) { + return a._index - b._index; + }); + + _.each(items, function(item, index) { + userAnswer.push(item._isSelected); + }, this); + this.set('_userAnswer', userAnswer); + }, + + isCorrect: function() { + + var numberOfRequiredAnswers = 0; + var numberOfCorrectAnswers = 0; + var numberOfIncorrectAnswers = 0; + + _.each(this.get('_items'), function(item, index) { + + var itemSelected = (item._isSelected || false); + + if (item._shouldBeSelected) { + numberOfRequiredAnswers ++; + + if (itemSelected) { + numberOfCorrectAnswers ++; + + item._isCorrect = true; + + this.set('_isAtLeastOneCorrectSelection', true); + } + + } else if (!item._shouldBeSelected && itemSelected) { + numberOfIncorrectAnswers ++; + } + + }, this); + + this.set('_numberOfCorrectAnswers', numberOfCorrectAnswers); + this.set('_numberOfRequiredAnswers', numberOfRequiredAnswers); + + // Check if correct answers matches correct items and there are no incorrect selections + var answeredCorrectly = (numberOfCorrectAnswers === numberOfRequiredAnswers) && (numberOfIncorrectAnswers === 0); + return answeredCorrectly; + }, + + // Sets the score based upon the questionWeight + // Can be overwritten if the question needs to set the score in a different way + setScore: function() { + var questionWeight = this.get("_questionWeight"); + var answeredCorrectly = this.get('_isCorrect'); + var score = answeredCorrectly ? questionWeight : 0; + this.set('_score', score); + }, + + setupFeedback: function() { + + if (this.get('_isCorrect')) { + this.setupCorrectFeedback(); + } else if (this.isPartlyCorrect()) { + this.setupPartlyCorrectFeedback(); + } else { + // apply individual item feedback + if((this.get('_selectable') === 1) && this.get('_selectedItems')[0].feedback) { + this.setupIndividualFeedback(this.get('_selectedItems')[0]); + return; + } else { + this.setupIncorrectFeedback(); + } + } + }, + + setupIndividualFeedback: function(selectedItem) { + this.set({ + feedbackTitle: this.get('title'), + feedbackMessage: selectedItem.feedback + }); + }, + + isPartlyCorrect: function() { + return this.get('_isAtLeastOneCorrectSelection'); + }, + + resetUserAnswer: function() { + this.set({_userAnswer: []}); + }, + + deselectAllItems: function() { + _.each(this.get('_items'), function(item) { + item._isSelected = false; + }, this); + }, + + resetItems: function() { + this.set({ + _selectedItems: [], + _isAtLeastOneCorrectSelection: false + }); + } + + }); + + return McqModel; + +}); diff --git a/js/mcqView.js b/js/mcqView.js new file mode 100644 index 0000000..37db631 --- /dev/null +++ b/js/mcqView.js @@ -0,0 +1,186 @@ +define([ + 'coreViews/questionView' +], function(QuestionView) { + + var McqView = QuestionView.extend({ + + events: { + 'focus .mcq-item input':'onItemFocus', + 'blur .mcq-item input':'onItemBlur', + 'change .mcq-item input':'onItemSelected', + 'keyup .mcq-item input':'onKeyPress' + }, + + resetQuestionOnRevisit: function() { + this.setAllItemsEnabled(true); + this.resetQuestion(); + }, + + setupQuestion: function() { + this.model.setupRandomisation(); + }, + + disableQuestion: function() { + this.setAllItemsEnabled(false); + }, + + enableQuestion: function() { + this.setAllItemsEnabled(true); + }, + + setAllItemsEnabled: function(isEnabled) { + _.each(this.model.get('_items'), function(item, index){ + var $itemLabel = this.$('label').eq(index); + var $itemInput = this.$('input').eq(index); + + if (isEnabled) { + $itemLabel.removeClass('disabled'); + $itemInput.prop('disabled', false); + } else { + $itemLabel.addClass('disabled'); + $itemInput.prop('disabled', true); + } + }, this); + }, + + onQuestionRendered: function() { + this.setReadyStatus(); + if (!this.model.get("_isSubmitted")) return; + this.showMarking(); + }, + + onKeyPress: function(event) { + if (event.which === 13) { // keypress + this.onItemSelected(event); + } + }, + + onItemFocus: function(event) { + if(this.model.get('_isEnabled') && !this.model.get('_isSubmitted')){ + $("label[for='"+$(event.currentTarget).attr('id')+"']").addClass('highlighted'); + } + }, + + onItemBlur: function(event) { + $("label[for='"+$(event.currentTarget).attr('id')+"']").removeClass('highlighted'); + }, + + onItemSelected: function(event) { + if(this.model.get('_isEnabled') && !this.model.get('_isSubmitted')){ + var selectedItemObject = this.model.get('_items')[$(event.currentTarget).parent('.component-item').index()]; + this.toggleItemSelected(selectedItemObject, event); + } + }, + + toggleItemSelected:function(item, clickEvent) { + var selectedItems = this.model.get('_selectedItems'); + var itemIndex = _.indexOf(this.model.get('_items'), item), + $itemLabel = this.$('label').eq(itemIndex), + $itemInput = this.$('input').eq(itemIndex), + selected = !$itemLabel.hasClass('selected'); + + if(selected) { + if(this.model.get('_selectable') === 1){ + this.$('label').removeClass('selected'); + this.$('input').prop('checked', false); + this.deselectAllItems(); + selectedItems[0] = item; + } else if(selectedItems.length < this.model.get('_selectable')) { + selectedItems.push(item); + } else { + clickEvent.preventDefault(); + return; + } + $itemLabel.addClass('selected'); + $itemLabel.a11y_selected(true); + } else { + selectedItems.splice(_.indexOf(selectedItems, item), 1); + $itemLabel.removeClass('selected'); + $itemLabel.a11y_selected(false); + } + $itemInput.prop('checked', selected); + item._isSelected = selected; + this.model.set('_selectedItems', selectedItems); + }, + + // Blank method to add functionality for when the user cannot submit + // Could be used for a popup or explanation dialog/hint + onCannotSubmit: function() {}, + + // This is important and should give the user feedback on how they answered the question + // Normally done through ticks and crosses by adding classes + showMarking: function() { + _.each(this.model.get('_items'), function(item, i) { + var $item = this.$('.component-item').eq(i); + $item.removeClass('correct incorrect').addClass(item._isCorrect ? 'correct' : 'incorrect'); + }, this); + }, + + // Used by the question view to reset the look and feel of the component. + resetQuestion: function() { + this.deselectAllItems(); + this.resetItems(); + }, + + deselectAllItems: function() { + this.$el.a11y_selected(false); + this.model.deselectAllItems(); + }, + + resetItems: function() { + this.$('.component-item label').removeClass('selected'); + this.$('.component-item').removeClass('correct incorrect'); + this.$('input').prop('checked', false); + this.model.resetItems(); + }, + + showCorrectAnswer: function() { + _.each(this.model.get('_items'), function(item, index) { + this.setOptionSelected(index, item._shouldBeSelected); + }, this); + }, + + setOptionSelected:function(index, selected) { + var $itemLabel = this.$('label').eq(index); + var $itemInput = this.$('input').eq(index); + if (selected) { + $itemLabel.addClass('selected'); + $itemInput.prop('checked', true); + } else { + $itemLabel.removeClass('selected'); + $itemInput.prop('checked', false); + } + }, + + hideCorrectAnswer: function() { + _.each(this.model.get('_items'), function(item, index) { + this.setOptionSelected(index, this.model.get('_userAnswer')[item._index]); + }, this); + }, + + /** + * used by adapt-contrib-spoor to get the user's answers in the format required by the cmi.interactions.n.student_response data field + * returns the user's answers as a string in the format "1,5,2" + */ + getResponse:function() { + var selected = _.where(this.model.get('_items'), {'_isSelected':true}); + var selectedIndexes = _.pluck(selected, '_index'); + // indexes are 0-based, we need them to be 1-based for cmi.interactions + for (var i = 0, count = selectedIndexes.length; i < count; i++) { + selectedIndexes[i]++; + } + return selectedIndexes.join(','); + }, + + /** + * used by adapt-contrib-spoor to get the type of this question in the format required by the cmi.interactions.n.type data field + */ + getResponseType:function() { + return "choice"; + } + + }); + + return McqView; + +}); From 060c74e7bce978493f7a1d698ef0cc4f5db01730 Mon Sep 17 00:00:00 2001 From: moloko Date: Fri, 27 Jan 2017 12:24:03 +0000 Subject: [PATCH 2/4] update to showMarking to match changes that were made in 9a68e0c305f8eb4e3324826f7213f3f5a87b87ce --- js/mcqView.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/js/mcqView.js b/js/mcqView.js index 37db631..9724caa 100644 --- a/js/mcqView.js +++ b/js/mcqView.js @@ -110,6 +110,8 @@ define([ // This is important and should give the user feedback on how they answered the question // Normally done through ticks and crosses by adding classes showMarking: function() { + if (!this.model.get('_canShowMarking')) return; + _.each(this.model.get('_items'), function(item, i) { var $item = this.$('.component-item').eq(i); $item.removeClass('correct incorrect').addClass(item._isCorrect ? 'correct' : 'incorrect'); From cb8cf95773b87919320758d7d8e5e8b916950ade Mon Sep 17 00:00:00 2001 From: moloko Date: Fri, 27 Jan 2017 12:25:21 +0000 Subject: [PATCH 3/4] update setupQuestionItemIndexes to match changes that were made in a008e81ac28e9ceef897424d0978c2e239d1069f --- js/mcqModel.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/js/mcqModel.js b/js/mcqModel.js index 2c6080f..c71c03e 100644 --- a/js/mcqModel.js +++ b/js/mcqModel.js @@ -16,8 +16,10 @@ define([ setupQuestionItemIndexes: function() { var items = this.get("_items"); - for (var i = 0, l = items.length; i < l; i++) { - if (items[i]._index === undefined) items[i]._index = i; + if (items && items.length > 0) { + for (var i = 0, l = items.length; i < l; i++) { + if (items[i]._index === undefined) items[i]._index = i; + } } }, From 63e7d595f8637351f1ced165ef33d65f35883bc1 Mon Sep 17 00:00:00 2001 From: moloko Date: Fri, 27 Jan 2017 12:27:13 +0000 Subject: [PATCH 4/4] switch to long-form references; add missing semi-colon --- js/adapt-contrib-mcq.js | 2 +- js/mcqModel.js | 4 ++-- js/mcqView.js | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/js/adapt-contrib-mcq.js b/js/adapt-contrib-mcq.js index c916f4a..788238f 100644 --- a/js/adapt-contrib-mcq.js +++ b/js/adapt-contrib-mcq.js @@ -1,5 +1,5 @@ define([ - 'coreJS/adapt', + 'core/js/adapt', './mcqView', './mcqModel' ], function(Adapt, McqView, McqModel) { diff --git a/js/mcqModel.js b/js/mcqModel.js index c71c03e..63c0ccf 100644 --- a/js/mcqModel.js +++ b/js/mcqModel.js @@ -1,5 +1,5 @@ define([ - 'coreModels/questionModel' + 'core/js/models/questionModel' ], function(QuestionModel) { var McqModel = QuestionModel.extend({ @@ -32,7 +32,7 @@ define([ _.each(items, function(item, index) { item._isSelected = userAnswer[item._index]; if (item._isSelected) { - selectedItems.push(item) + selectedItems.push(item); } }); diff --git a/js/mcqView.js b/js/mcqView.js index 9724caa..2283f8a 100644 --- a/js/mcqView.js +++ b/js/mcqView.js @@ -1,5 +1,5 @@ define([ - 'coreViews/questionView' + 'core/js/views/questionView' ], function(QuestionView) { var McqView = QuestionView.extend({ @@ -111,7 +111,7 @@ define([ // Normally done through ticks and crosses by adding classes showMarking: function() { if (!this.model.get('_canShowMarking')) return; - + _.each(this.model.get('_items'), function(item, i) { var $item = this.$('.component-item').eq(i); $item.removeClass('correct incorrect').addClass(item._isCorrect ? 'correct' : 'incorrect');