Files
edx-platform/cms/static/js/views/settings/grading.js
2015-06-22 18:36:54 +05:00

392 lines
16 KiB
JavaScript

define(["js/views/validation", "underscore", "jquery", "jquery.ui", "js/views/settings/grader"],
function(ValidatingView, _, $, ui, GraderView) {
var GradingView = ValidatingView.extend({
// Model class is CMS.Models.Settings.CourseGradingPolicy
events : {
"input input" : "updateModel",
"input textarea" : "updateModel",
// Leaving change in as fallback for older browsers
"change input" : "updateModel",
"change textarea" : "updateModel",
"input span[contenteditable=true]" : "updateDesignation",
"click .settings-extra header" : "showSettingsExtras",
"click .new-grade-button" : "addNewGrade",
"click .remove-button" : "removeGrade",
"click .add-grading-data" : "addAssignmentType",
// would love to move to a general superclass, but event hashes don't inherit in backbone :-(
'focus :input' : "inputFocus",
'blur :input' : "inputUnfocus"
},
initialize : function() {
// load template for grading view
var self = this;
this.template = _.template($("#course_grade_policy-tpl").text());
this.gradeCutoffTemplate = _.template('<li class="grade-specific-bar" style="width:<%= width %>%"><span class="letter-grade" contenteditable="true">' +
'<%= descriptor %>' +
'</span><span class="range"></span>' +
'<% if (removable) {%><a href="#" class="remove-button">remove</a><% ;} %>' +
'</li>');
this.setupCutoffs();
this.listenTo(this.model, 'invalid', this.handleValidationError);
this.listenTo(this.model, 'change', this.showNotificationBar);
this.model.get('graders').on('reset', this.render, this);
this.model.get('graders').on('add', this.render, this);
this.selectorToField = _.invert(this.fieldToSelectorMap);
this.render();
},
render: function() {
this.clearValidationErrors();
this.renderGracePeriod();
this.renderMinimumGradeCredit();
// Create and render the grading type subs
var self = this;
var gradelist = this.$el.find('.course-grading-assignment-list');
// Undo the double invocation error. At some point, fix the double invocation
$(gradelist).empty();
var gradeCollection = this.model.get('graders');
// We need to bind these events here (rather than in
// initialize), or else we can only press the delete button
// once due to the graders collection changing when we cancel
// our changes.
_.each(['change', 'remove', 'add'],
function (event) {
gradeCollection.on(event, function() {
this.showNotificationBar();
// Since the change event gets fired every time
// we type in an input field, we don't need to
// (and really shouldn't) rerender the whole view.
if(event !== 'change') {
this.render();
}
}, this);
},
this);
gradeCollection.each(function(gradeModel) {
$(gradelist).append(self.template({model : gradeModel }));
var newEle = gradelist.children().last();
var newView = new GraderView({el: newEle,
model : gradeModel, collection : gradeCollection });
// Listen in order to rerender when the 'cancel' button is
// pressed
self.listenTo(newView, 'revert', _.bind(self.render, self));
});
// render the grade cutoffs
this.renderCutoffBar();
return this;
},
addAssignmentType : function(e) {
e.preventDefault();
this.model.get('graders').push({});
},
fieldToSelectorMap : {
'grace_period' : 'course-grading-graceperiod',
'minimum_grade_credit' : 'course-minimum_grade_credit'
},
renderGracePeriod: function() {
var format = function(time) {
return time >= 10 ? time.toString() : '0' + time;
};
var grace_period = this.model.get('grace_period');
this.$el.find('#course-grading-graceperiod').val(
format(grace_period.hours) + ':' + format(grace_period.minutes)
);
},
renderMinimumGradeCredit: function() {
var minimum_grade_credit = this.model.get('minimum_grade_credit');
this.$el.find('#course-minimum_grade_credit').val(
Math.round(parseFloat(minimum_grade_credit) * 100) + '%'
);
},
setGracePeriod : function(event) {
this.clearValidationErrors();
var newVal = this.model.parseGracePeriod($(event.currentTarget).val());
this.model.set('grace_period', newVal, {validate: true});
},
setMinimumGradeCredit : function(event) {
this.clearValidationErrors();
// get field value in float
var newVal = this.model.parseMinimumGradeCredit($(event.currentTarget).val()) / 100;
this.model.set('minimum_grade_credit', newVal, {validate: true});
},
updateModel : function(event) {
if (!this.selectorToField[event.currentTarget.id]) return;
switch (this.selectorToField[event.currentTarget.id]) {
case 'grace_period':
this.setGracePeriod(event);
break;
case 'minimum_grade_credit':
this.setMinimumGradeCredit(event);
break;
default:
this.setField(event);
break;
}
},
// Grade sliders attributes and methods
// Grade bars are li's ordered A -> F with A taking whole width, B overlaying it with its paint, ...
// The actual cutoff for each grade is the width % of the next lower grade; so, the hack here
// is to lay down a whole width bar claiming it's A and then lay down bars for each actual grade
// starting w/ A but posting the label in the preceding li and setting the label of the last to "Fail" or "F"
// A does not have a drag bar (cannot change its upper limit)
// Need to insert new bars in right place.
GRADES : ['A', 'B', 'C', 'D'], // defaults for new grade designators
descendingCutoffs : [], // array of { designation : , cutoff : }
gradeBarWidth : null, // cache of value since it won't change (more certain)
renderCutoffBar: function() {
var gradeBar =this.$el.find('.grade-bar');
this.gradeBarWidth = gradeBar.width();
var gradelist = gradeBar.children('.grades');
// HACK fixing a duplicate call issue by undoing previous call effect. Need to figure out why called 2x
gradelist.empty();
var nextWidth = 100; // first width is 100%
// Can probably be simplified to one variable now.
var removable = false;
var draggable = false; // first and last are not removable, first is not draggable
_.each(this.descendingCutoffs,
function(cutoff, index) {
var newBar = this.gradeCutoffTemplate({
descriptor : cutoff['designation'] ,
width : nextWidth,
removable : removable });
gradelist.append(newBar);
if (draggable) {
newBar = gradelist.children().last(); // get the dom object not the unparsed string
newBar.resizable({
handles: "e",
containment : "parent",
start : this.startMoveClosure(),
resize : this.moveBarClosure(),
stop : this.stopDragClosure()
});
}
// prepare for next
nextWidth = cutoff['cutoff'];
removable = true; // first is not removable, all others are
draggable = true;
},
this);
// add fail which is not in data
var failBar = $(this.gradeCutoffTemplate({
descriptor : this.failLabel(),
width : nextWidth,
removable : false
}));
failBar.find("span[contenteditable=true]").attr("contenteditable", false);
gradelist.append(failBar);
gradelist.children().last().resizable({
handles: "e",
containment : "parent",
start : this.startMoveClosure(),
resize : this.moveBarClosure(),
stop : this.stopDragClosure()
});
this.renderGradeRanges();
},
showSettingsExtras : function(event) {
$(event.currentTarget).toggleClass('active');
$(event.currentTarget).siblings.toggleClass('is-shown');
},
startMoveClosure : function() {
// set min/max widths
var cachethis = this;
var widthPerPoint = cachethis.gradeBarWidth / 100;
return function(event, ui) {
var barIndex = ui.element.index();
// min and max represent limits not labels (note, can's make smaller than 3 points wide)
var min = (barIndex < cachethis.descendingCutoffs.length ? cachethis.descendingCutoffs[barIndex]['cutoff'] + 3 : 3);
// minus 2 b/c minus 1 is the element we're effecting. It's max is just shy of the next one above it
var max = (barIndex >= 2 ? cachethis.descendingCutoffs[barIndex - 2]['cutoff'] - 3 : 97);
ui.element.resizable("option",{minWidth : min * widthPerPoint, maxWidth : max * widthPerPoint});
};
},
moveBarClosure : function() {
// 0th ele doesn't have a bar; so, will never invoke this
var cachethis = this;
return function(event, ui) {
var barIndex = ui.element.index();
// min and max represent limits not labels (note, can's make smaller than 3 points wide)
var min = (barIndex < cachethis.descendingCutoffs.length ? cachethis.descendingCutoffs[barIndex]['cutoff'] + 3 : 3);
// minus 2 b/c minus 1 is the element we're effecting. It's max is just shy of the next one above it
var max = (barIndex >= 2 ? cachethis.descendingCutoffs[barIndex - 2]['cutoff'] - 3 : 100);
var percentage = Math.min(Math.max(ui.size.width / cachethis.gradeBarWidth * 100, min), max);
cachethis.descendingCutoffs[barIndex - 1]['cutoff'] = Math.round(percentage);
cachethis.renderGradeRanges();
};
},
renderGradeRanges: function() {
// the labels showing the range e.g., 71-80
var cutoffs = this.descendingCutoffs;
this.$el.find('.range').each(function(i) {
var min = (i < cutoffs.length ? cutoffs[i]['cutoff'] : 0);
var max = (i > 0 ? cutoffs[i - 1]['cutoff'] : 100);
$(this).text(min + '-' + max);
});
},
stopDragClosure: function() {
var cachethis = this;
return function(event, ui) {
// for some reason the resize is setting height to 0
cachethis.saveCutoffs();
};
},
renderGradeLabels: function(){
// When a grade is removed, keep the remaining grades consistent.
var _this = this;
if (_this.descendingCutoffs.length === 1 && _this.descendingCutoffs[0]['designation'] === _this.GRADES[0]) {
_this.descendingCutoffs[0]['designation'] = 'Pass';
_this.setTopGradeLabel();
} else {
_.each(_this.descendingCutoffs, function(cutoff, index) {
cutoff['designation'] = _this.GRADES[index];
});
_this.updateDomGradeLabels();
}
},
updateDomGradeLabels: function(){
// Update the DOM elements (Grades)
var _this = this;
var gradeElements = this.$el.find('.grades .letter-grade[contenteditable=true]');
_.each(gradeElements, function(element, index) {
if (index !== 0 ) $(element).text(_this.GRADES[index])
});
},
saveCutoffs: function() {
this.model.set('grade_cutoffs',
_.reduce(this.descendingCutoffs,
function(object, cutoff) {
object[cutoff['designation']] = cutoff['cutoff'] / 100.0;
return object;
},
{}),
{validate: true});
},
addNewGrade: function(e) {
e.preventDefault();
var gradeLength = this.descendingCutoffs.length; // cutoffs doesn't include fail/f so this is only the passing grades
if(gradeLength > 3) {
// TODO shouldn't we disable the button
return;
}
var failBarWidth = this.descendingCutoffs[gradeLength - 1]['cutoff'];
// going to split the grade above the insertion point in half leaving fail in same place
var nextGradeTop = (gradeLength > 1 ? this.descendingCutoffs[gradeLength - 2]['cutoff'] : 100);
var targetWidth = failBarWidth + ((nextGradeTop - failBarWidth) / 2);
this.descendingCutoffs.push({designation: this.GRADES[gradeLength], cutoff: failBarWidth});
this.descendingCutoffs[gradeLength - 1]['cutoff'] = Math.round(targetWidth);
var $newGradeBar = this.gradeCutoffTemplate({ descriptor : this.GRADES[gradeLength],
width : targetWidth, removable : true });
var gradeDom = this.$el.find('.grades');
gradeDom.children().last().before($newGradeBar);
var newEle = gradeDom.children()[gradeLength];
$(newEle).resizable({
handles: "e",
containment : "parent",
start : this.startMoveClosure(),
resize : this.moveBarClosure(),
stop : this.stopDragClosure()
});
// Munge existing grade labels?
// If going from Pass/Fail to 3 levels, change to Pass to A
if (gradeLength === 1 && this.descendingCutoffs[0]['designation'] === 'Pass') {
this.descendingCutoffs[0]['designation'] = this.GRADES[0];
this.setTopGradeLabel();
}
this.setFailLabel();
this.renderGradeRanges();
this.saveCutoffs();
},
removeGrade: function(e) {
e.preventDefault();
var domElement = $(e.currentTarget).closest('li');
var index = domElement.index();
// copy the boundary up to the next higher grade then remove
this.descendingCutoffs[index - 1]['cutoff'] = this.descendingCutoffs[index]['cutoff'];
this.descendingCutoffs.splice(index, 1);
domElement.remove();
this.setFailLabel();
this.renderGradeRanges();
this.renderGradeLabels();
this.saveCutoffs();
},
updateDesignation: function(e) {
var index = $(e.currentTarget).closest('li').index();
this.descendingCutoffs[index]['designation'] = $(e.currentTarget).html();
this.saveCutoffs();
},
failLabel: function() {
if (this.descendingCutoffs.length === 1) return 'Fail';
else return 'F';
},
setFailLabel: function() {
this.$el.find('.grades .letter-grade').last().html(this.failLabel());
},
setTopGradeLabel: function() {
this.$el.find('.grades .letter-grade').first().html(this.descendingCutoffs[0]['designation']);
},
setupCutoffs: function() {
// Instrument grading scale
// convert cutoffs to inversely ordered list
var modelCutoffs = this.model.get('grade_cutoffs');
for (var cutoff in modelCutoffs) {
this.descendingCutoffs.push({designation: cutoff, cutoff: Math.round(modelCutoffs[cutoff] * 100)});
}
this.descendingCutoffs = _.sortBy(this.descendingCutoffs,
function (gradeEle) { return -gradeEle['cutoff']; });
},
revertView: function() {
var self = this;
this.model.fetch({
success: function() {
self.descendingCutoffs = [];
self.setupCutoffs();
self.render();
self.renderCutoffBar();
},
reset: true,
silent: true});
},
showNotificationBar: function() {
// We always call showNotificationBar with the same args, just
// delegate to superclass
ValidatingView.prototype.showNotificationBar.call(this,
this.save_message,
_.bind(this.saveView, this),
_.bind(this.revertView, this));
}
});
return GradingView;
}); // end define()