From 8f16d639b7bbc46507bf3a0891c1e0e4e71f415e Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Tue, 5 Feb 2013 17:21:02 -0500 Subject: [PATCH] CRUD on policy fields w/ some validation --- cms/djangoapps/contentstore/views.py | 22 +- .../models/settings/course_metadata.py | 16 +- .../client_templates/advanced_entry.html | 18 + cms/static/js/models/settings/advanced.js | 208 ++- .../models/settings/course_grading_policy.js | 212 +-- cms/static/js/template_loader.js | 2 +- .../js/views/settings/main_settings_view.js | 1183 +++++++++-------- cms/templates/settings.html | 107 +- 8 files changed, 955 insertions(+), 813 deletions(-) create mode 100644 cms/static/client_templates/advanced_entry.html diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 6e4dffe2f5..16f5f4d22c 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -1109,6 +1109,8 @@ def get_course_settings(request, org, course, name): return render_to_response('settings.html', { 'active_tab': 'settings', 'context_course': course_module, + 'advanced_blacklist' : json.dumps(CourseMetadata.FILTERED_LIST), + 'advanced_dict' : json.dumps(CourseMetadata.fetch(location)), 'course_details' : json.dumps(course_details, cls=CourseSettingsEncoder) }) @@ -1133,6 +1135,9 @@ def course_settings_updates(request, org, course, name, section): manager = CourseDetails elif section == 'grading': manager = CourseGradingModel + elif section == 'advanced': + # not implemented b/c it assumes prefetched and then everything thru course_edit_metadata + return else: return if request.method == 'GET': @@ -1194,14 +1199,10 @@ def course_edit_metadata(request, org, course, name): editable = CourseMetadata.fetch(location) - return render_to_response('course_info.html', { - 'active_tab': 'settings', - 'editable_metadata': editable, - 'url_base' : "/" + org + "/" + course + "/", - 'blacklist_keys' : CourseMetadata.FILTERED_LIST - }) + # for now defer to settings general until we split the divs out into separate pages + return get_course_settings(request, org, course, name) -@expect_json +## NB: expect_json failed on ["key", "key2"] and json payload @login_required @ensure_csrf_cookie def course_metadata_rest_access(request, org, course, name): @@ -1225,10 +1226,11 @@ def course_metadata_rest_access(request, org, course, name): if request.method == 'GET': return HttpResponse(json.dumps(CourseMetadata.fetch(location)), mimetype="application/json") - elif real_method == 'DELETE': # coming as POST need to pull from Request Header X-HTTP-Method-Override DELETE - return HttpResponse(json.dumps(CourseMetadata.delete_key(location, request.POST)), mimetype="application/json") + elif real_method == 'DELETE': + return HttpResponse(json.dumps(CourseMetadata.delete_key(location, json.loads(request.body))), mimetype="application/json") elif request.method == 'POST': - return HttpResponse(json.dumps(CourseMetadata.update_from_json(location, request.POST)), mimetype="application/json") + # NOTE: request.POST is messed up because expect_json cloned_request.POST.copy() is creating a defective entry w/ the whole payload as the key + return HttpResponse(json.dumps(CourseMetadata.update_from_json(location, json.loads(request.body))), mimetype="application/json") @login_required diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index 83fba72842..438a871f5a 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -7,8 +7,8 @@ class CourseMetadata(object): For CRUD operations on metadata fields which do not have specific editors on the other pages including any user generated ones. The objects have no predefined attrs but instead are obj encodings of the editable metadata. ''' - - FILTERED_LIST = ['start', 'end', 'enrollment_start', 'enrollment_end', 'tabs', 'graceperiod'] + # __new_advanced_key__ is used by client not server; so, could argue against it being here + FILTERED_LIST = ['start', 'end', 'enrollment_start', 'enrollment_end', 'tabs', 'graceperiod', '__new_advanced_key__'] @classmethod def fetch(cls, course_location): @@ -57,14 +57,10 @@ class CourseMetadata(object): ''' descriptor = get_modulestore(course_location).get_item(course_location) - if isinstance(payload, list): - for key in payload: - if key in descriptor.metadata: - del descriptor.metadata[key] - else: - if payload in descriptor.metadata: - del descriptor.metadata[payload] - + for key in payload['deleteKeys']: + if key in descriptor.metadata: + del descriptor.metadata[key] + get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) return cls.fetch(course_location) diff --git a/cms/static/client_templates/advanced_entry.html b/cms/static/client_templates/advanced_entry.html new file mode 100644 index 0000000000..b76cd72a4a --- /dev/null +++ b/cms/static/client_templates/advanced_entry.html @@ -0,0 +1,18 @@ +
  • +
    +
    + +
    + + Keys are case sensitive and cannot contain spaces or start with a number +
    +
    +
    + +
    + +
    +
    +
    + Delete +
  • \ No newline at end of file diff --git a/cms/static/js/models/settings/advanced.js b/cms/static/js/models/settings/advanced.js index 3c15116f47..d98453ecde 100644 --- a/cms/static/js/models/settings/advanced.js +++ b/cms/static/js/models/settings/advanced.js @@ -2,13 +2,211 @@ if (!CMS.Models['Settings']) CMS.Models.Settings = {}; CMS.Models.Settings.Advanced = Backbone.Model.extend({ defaults: { - + // the properties are whatever the user types in }, - + // which keys to send as the deleted keys on next save + deleteKeys : [], + blacklistKeys : [], // an array which the controller should populate directly for now [static not instance based] initialize: function() { console.log('in initialize'); - var editor = ace.edit('course-advanced-policy-1-value'); - editor.setTheme("ace/theme/monokai"); - editor.getSession().setMode("ace/mode/javascript"); + }, + validate: function(attrs) { + var errors = {}; + for (key in attrs) { + if (_.contains(this.blacklistKeys, key)) { + errors[key] = key + " is a reserved keyword or has another editor"; + } + } + if (!_.isEmpty(errors)) return errors; + } +}); + +if (!CMS.Views['Settings']) CMS.Views.Settings = {}; + +CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ + // Model class is CMS.Models.Settings.Advanced + events : { + 'click .delete-button' : "deleteEntry", + 'click .save-button' : "saveView", + 'click .cancel-button' : "revertView", + 'click .new-button' : "addEntry", + // update model on changes + 'change #course-advanced-policy-key' : "updateKey", + 'change #course-advanced-policy-value' : "updateValue" + // TODO enable/disable save (add disabled class) based on validation & dirty + // TODO enable/disable new button? + }, + initialize : function() { + var self = this; + // instantiates an editor template for each update in the collection + window.templateLoader.loadRemoteTemplate("advanced_entry", + "/static/client_templates/advanced_entry.html", + function (raw_template) { + self.template = _.template(raw_template); + self.render(); + } + ); + this.model.on('error', this.handleValidationError, this); + }, + render: function() { + // catch potential outside call before template loaded + if (!this.template) return this; + + var listEle$ = this.$el.find('.course-advanced-policy-list'); + listEle$.empty(); + // same object so manipulations to one keep the other up to date + this.fieldToSelectorMap = this.selectorToField = {}; + + // iterate through model and produce key : value editors for each property in model.get + var self = this; + _.each(this.model.attributes, + function(value, key) { + listEle$.append(self.template({ key : key, value : value})); + self.fieldToSelectorMap[key] = key; + }); + + // insert the empty one + this.addEntry(); + // Should this be on an event rather than render? +// var editor = ace.edit('course-advanced-policy-1-value'); +// editor.setTheme("ace/theme/monokai"); +// editor.getSession().setMode("ace/mode/javascript"); + + return this; + }, + deleteEntry : function(event) { + event.preventDefault(); + // find out which entry + var li$ = $(event.currentTarget).closest('li'); + // Not data b/c the validation view uses it for a selector + var key = $('.key', li$).attr('id'); + + delete this.fieldToSelectorMap[key]; + if (key !== '__new_advanced_key__') { + this.model.deleteKeys.push(key); + delete this.model[key]; + } + li$.remove(); + }, + saveView : function(event) { + // TODO one last verification scan: + // call validateKey on each to ensure proper format + // check for dupes + + this.model.save({ + success : function() { window.alert("Saved"); }, + error : CMS.ServerError + }); + // FIXME don't delete if the validation didn't succeed in the save call + // remove deleted attrs + if (!_.isEmpty(this.model.deleteKeys)) { + var self = this; + // hmmm, not sure how to do this via backbone since we're not destroying the model + $.ajax({ + url : this.model.url, + // json to and fro + contentType : "application/json", + dataType : "json", + // delete + type : 'DELETE', + // data + data : JSON.stringify({ deleteKeys : this.model.deleteKeys}) + }) + .fail(function(hdr, status, error) { CMS.ServerError(self.model, "Deleting keys:" + status); }) + .done(function(data, status, error) { + // clear deleteKeys on success + self.model.deleteKeys = []; + }); + } + }, + revertView : function(event) { + this.model.deleteKeys = []; + var self = this; + this.model.fetch({ + success : this.render, + error : CMS.ServerError + }); + }, + addEntry : function() { + var listEle$ = this.$el.find('.course-advanced-policy-list'); + listEle$.append(this.template({ key : "", value : ""})); + // disable the value entry until there's an acceptable key + listEle$.find('.course-advanced-policy-value').addClass('disabled'); + this.fieldToSelectorMap['__new_advanced_key__'] = '__new_advanced_key__'; + }, + updateKey : function(event) { + // old key: either the key as in the model or __new_advanced_key__. That is, it doesn't change as the val changes until val is accepted + var oldKey = $(event.currentTarget).closest('.key').attr('id'); + var newKey = $(event.currentTarget).val(); + console.log('update ', oldKey, newKey); // REMOVE ME + if (oldKey !== newKey) { + // may erase other errors but difficult to just remove these + this.clearValidationErrors(); + + if (!this.validateKey(oldKey, newKey)) return; + + if (this.model.has(newKey)) { + console.log('dupe key'); + var error = {}; + error[oldKey] = newKey + " has another entry"; + error[newKey] = "Other entry for " + newKey; + this.model.trigger("error", this.model, error); + return false; + } + + // explicitly call validate to determine whether to proceed (relying on triggered error means putting continuation in the success + // method which is uglier I think?) + var newEntryModel = {}; + // set the new key's value to the old one's + newEntryModel[newKey] = (oldKey === '__new_advanced_key__' ? '' : this.model.get(oldKey)); + + var validation = this.model.validate(newEntryModel); + if (validation) { + console.log('reserved key'); + this.model.trigger("error", this.model, validation); + // abandon update + return; + } + + // Now safe to actually do the update + this.model.set(newEntryModel); + + delete this.fieldToSelectorMap[oldKey]; + + if (oldKey !== '__new_advanced_key__') { + // mark the old key for deletion and delete from field maps + this.model.deleteKeys.push(oldKey); + this.model.unset(oldKey) ; + } + else { + // enable the value entry + this.$el.find('.course-advanced-policy-value').removeClass('disabled'); + } + + // update gui (sets all the ids etc) + $(event.currentTarget).closest('li').replaceWith(this.template({key : newKey, value : this.model.get(newKey) })); + + this.fieldToSelectorMap[newKey] = newKey; + } + }, + validateKey : function(oldKey, newKey) { + // model validation can't handle malformed keys nor notice if 2 fields have same key; so, need to add that chk here + // TODO ensure there's no spaces or illegal chars + if (_.isEmpty(newKey)) { + console.log('no key'); + var error = {}; + error[oldKey] = "Key cannot be an empty string"; + this.model.trigger("error", this.model, error); + return false; + } + else return true; + }, + updateValue : function(event) { + // much simpler than key munging. just update the value + var key = $(event.currentTarget).closest('.row').children('.key').attr('id'); + var value = $(event.currentTarget).val(); + console.log('updating ', key, value); + + this.model.set(key, value, {validate:true}); } }); diff --git a/cms/static/js/models/settings/course_grading_policy.js b/cms/static/js/models/settings/course_grading_policy.js index cce4e0207d..13957aac51 100644 --- a/cms/static/js/models/settings/course_grading_policy.js +++ b/cms/static/js/models/settings/course_grading_policy.js @@ -1,55 +1,55 @@ if (!CMS.Models['Settings']) CMS.Models.Settings = new Object(); CMS.Models.Settings.CourseGradingPolicy = Backbone.Model.extend({ - defaults : { - course_location : null, - graders : null, // CourseGraderCollection - grade_cutoffs : null, // CourseGradeCutoff model + defaults : { + course_location : null, + graders : null, // CourseGraderCollection + grade_cutoffs : null, // CourseGradeCutoff model grace_period : null // either null or { hours: n, minutes: m, ...} - }, - parse: function(attributes) { - if (attributes['course_location']) { - attributes.course_location = new CMS.Models.Location(attributes.course_location, {parse:true}); - } - if (attributes['graders']) { - var graderCollection; - if (this.has('graders')) { - graderCollection = this.get('graders'); - graderCollection.reset(attributes.graders); - } - else { - graderCollection = new CMS.Models.Settings.CourseGraderCollection(attributes.graders); - graderCollection.course_location = attributes['course_location'] || this.get('course_location'); - } - attributes.graders = graderCollection; - } - return attributes; - }, - url : function() { - var location = this.get('course_location'); - return '/' + location.get('org') + "/" + location.get('course') + '/settings/' + location.get('name') + '/section/grading'; - }, - gracePeriodToDate : function() { - var newDate = new Date(); - if (this.has('grace_period') && this.get('grace_period')['hours']) - newDate.setHours(this.get('grace_period')['hours']); - else newDate.setHours(0); - if (this.has('grace_period') && this.get('grace_period')['minutes']) - newDate.setMinutes(this.get('grace_period')['minutes']); - else newDate.setMinutes(0); - if (this.has('grace_period') && this.get('grace_period')['seconds']) - newDate.setSeconds(this.get('grace_period')['seconds']); - else newDate.setSeconds(0); - - return newDate; - }, - dateToGracePeriod : function(date) { - return {hours : date.getHours(), minutes : date.getMinutes(), seconds : date.getSeconds() }; - } + }, + parse: function(attributes) { + if (attributes['course_location']) { + attributes.course_location = new CMS.Models.Location(attributes.course_location, {parse:true}); + } + if (attributes['graders']) { + var graderCollection; + if (this.has('graders')) { + graderCollection = this.get('graders'); + graderCollection.reset(attributes.graders); + } + else { + graderCollection = new CMS.Models.Settings.CourseGraderCollection(attributes.graders); + graderCollection.course_location = attributes['course_location'] || this.get('course_location'); + } + attributes.graders = graderCollection; + } + return attributes; + }, + url : function() { + var location = this.get('course_location'); + return '/' + location.get('org') + "/" + location.get('course') + '/settings/' + location.get('name') + '/section/grading'; + }, + gracePeriodToDate : function() { + var newDate = new Date(); + if (this.has('grace_period') && this.get('grace_period')['hours']) + newDate.setHours(this.get('grace_period')['hours']); + else newDate.setHours(0); + if (this.has('grace_period') && this.get('grace_period')['minutes']) + newDate.setMinutes(this.get('grace_period')['minutes']); + else newDate.setMinutes(0); + if (this.has('grace_period') && this.get('grace_period')['seconds']) + newDate.setSeconds(this.get('grace_period')['seconds']); + else newDate.setSeconds(0); + + return newDate; + }, + dateToGracePeriod : function(date) { + return {hours : date.getHours(), minutes : date.getMinutes(), seconds : date.getSeconds() }; + } }); CMS.Models.Settings.CourseGrader = Backbone.Model.extend({ - defaults: { + defaults: { "type" : "", // must be unique w/in collection (ie. w/in course) "min_count" : 1, "drop_count" : 0, @@ -57,71 +57,71 @@ CMS.Models.Settings.CourseGrader = Backbone.Model.extend({ "weight" : 0 // int 0..100 }, parse : function(attrs) { - if (attrs['weight']) { - if (!_.isNumber(attrs.weight)) attrs.weight = parseInt(attrs.weight); - } - if (attrs['min_count']) { - if (!_.isNumber(attrs.min_count)) attrs.min_count = parseInt(attrs.min_count); - } - if (attrs['drop_count']) { - if (!_.isNumber(attrs.drop_count)) attrs.drop_count = parseInt(attrs.drop_count); - } - return attrs; + if (attrs['weight']) { + if (!_.isNumber(attrs.weight)) attrs.weight = parseInt(attrs.weight); + } + if (attrs['min_count']) { + if (!_.isNumber(attrs.min_count)) attrs.min_count = parseInt(attrs.min_count); + } + if (attrs['drop_count']) { + if (!_.isNumber(attrs.drop_count)) attrs.drop_count = parseInt(attrs.drop_count); + } + return attrs; }, validate : function(attrs) { - var errors = {}; - if (attrs['type']) { - if (_.isEmpty(attrs['type'])) { - errors.type = "The assignment type must have a name."; - } - else { - // FIXME somehow this.collection is unbound sometimes. I can't track down when - var existing = this.collection && this.collection.some(function(other) { return (other != this) && (other.get('type') == attrs['type']);}, this); - if (existing) { - errors.type = "There's already another assignment type with this name."; - } - } - } - if (attrs['weight']) { - if (!isFinite(attrs.weight) || /\D+/.test(attrs.weight)) { - errors.weight = "Please enter an integer between 0 and 100."; - } - else { - attrs.weight = parseInt(attrs.weight); // see if this ensures value saved is int - if (this.collection && attrs.weight > 0) { - // FIXME b/c saves don't update the models if validation fails, we should - // either revert the field value to the one in the model and make them make room - // or figure out a wholistic way to balance the vals across the whole -// if ((this.collection.sumWeights() + attrs.weight - this.get('weight')) > 100) -// errors.weight = "The weights cannot add to more than 100."; - } - }} - if (attrs['min_count']) { - if (!isFinite(attrs.min_count) || /\D+/.test(attrs.min_count)) { - errors.min_count = "Please enter an integer."; - } - else attrs.min_count = parseInt(attrs.min_count); - } - if (attrs['drop_count']) { - if (!isFinite(attrs.drop_count) || /\D+/.test(attrs.drop_count)) { - errors.drop_count = "Please enter an integer."; - } - else attrs.drop_count = parseInt(attrs.drop_count); - } - if (attrs['min_count'] && attrs['drop_count'] && attrs.drop_count > attrs.min_count) { - errors.drop_count = "Cannot drop more " + attrs.type + " than will assigned."; - } - if (!_.isEmpty(errors)) return errors; + var errors = {}; + if (attrs['type']) { + if (_.isEmpty(attrs['type'])) { + errors.type = "The assignment type must have a name."; + } + else { + // FIXME somehow this.collection is unbound sometimes. I can't track down when + var existing = this.collection && this.collection.some(function(other) { return (other != this) && (other.get('type') == attrs['type']);}, this); + if (existing) { + errors.type = "There's already another assignment type with this name."; + } + } + } + if (attrs['weight']) { + if (!isFinite(attrs.weight) || /\D+/.test(attrs.weight)) { + errors.weight = "Please enter an integer between 0 and 100."; + } + else { + attrs.weight = parseInt(attrs.weight); // see if this ensures value saved is int + if (this.collection && attrs.weight > 0) { + // FIXME b/c saves don't update the models if validation fails, we should + // either revert the field value to the one in the model and make them make room + // or figure out a wholistic way to balance the vals across the whole +// if ((this.collection.sumWeights() + attrs.weight - this.get('weight')) > 100) +// errors.weight = "The weights cannot add to more than 100."; + } + }} + if (attrs['min_count']) { + if (!isFinite(attrs.min_count) || /\D+/.test(attrs.min_count)) { + errors.min_count = "Please enter an integer."; + } + else attrs.min_count = parseInt(attrs.min_count); + } + if (attrs['drop_count']) { + if (!isFinite(attrs.drop_count) || /\D+/.test(attrs.drop_count)) { + errors.drop_count = "Please enter an integer."; + } + else attrs.drop_count = parseInt(attrs.drop_count); + } + if (attrs['min_count'] && attrs['drop_count'] && attrs.drop_count > attrs.min_count) { + errors.drop_count = "Cannot drop more " + attrs.type + " than will assigned."; + } + if (!_.isEmpty(errors)) return errors; } }); CMS.Models.Settings.CourseGraderCollection = Backbone.Collection.extend({ - model : CMS.Models.Settings.CourseGrader, - course_location : null, // must be set to a Location object - url : function() { - return '/' + this.course_location.get('org') + "/" + this.course_location.get('course') + '/grades/' + this.course_location.get('name') + '/'; - }, - sumWeights : function() { - return this.reduce(function(subtotal, grader) { return subtotal + grader.get('weight'); }, 0); - } + model : CMS.Models.Settings.CourseGrader, + course_location : null, // must be set to a Location object + url : function() { + return '/' + this.course_location.get('org') + "/" + this.course_location.get('course') + '/grades/' + this.course_location.get('name') + '/'; + }, + sumWeights : function() { + return this.reduce(function(subtotal, grader) { return subtotal + grader.get('weight'); }, 0); + } }); \ No newline at end of file diff --git a/cms/static/js/template_loader.js b/cms/static/js/template_loader.js index 89ecc98cc4..6108d96346 100644 --- a/cms/static/js/template_loader.js +++ b/cms/static/js/template_loader.js @@ -5,7 +5,7 @@ if (typeof window.templateLoader == 'function') return; var templateLoader = { - templateVersion: "0.0.12", + templateVersion: "0.0.13", templates: {}, loadRemoteTemplate: function(templateName, filename, callback) { if (!this.templates[templateName]) { diff --git a/cms/static/js/views/settings/main_settings_view.js b/cms/static/js/views/settings/main_settings_view.js index 826b385dff..15762eaeac 100644 --- a/cms/static/js/views/settings/main_settings_view.js +++ b/cms/static/js/views/settings/main_settings_view.js @@ -1,216 +1,231 @@ if (!CMS.Views['Settings']) CMS.Views.Settings = {}; -// TODO move to common place +//TODO move to common place CMS.Views.ValidatingView = Backbone.View.extend({ - // Intended as an abstract class which catches validation errors on the model and - // decorates the fields. Needs wiring per class, but this initialization shows how - // either have your init call this one or copy the contents - initialize : function() { - this.model.on('error', this.handleValidationError, this); - this.selectorToField = _.invert(this.fieldToSelectorMap); - }, - - errorTemplate : _.template('<%= message %>'), + // Intended as an abstract class which catches validation errors on the model and + // decorates the fields. Needs wiring per class, but this initialization shows how + // either have your init call this one or copy the contents + initialize : function() { + this.model.on('error', this.handleValidationError, this); + this.selectorToField = _.invert(this.fieldToSelectorMap); + }, - events : { - "blur input" : "clearValidationErrors", - "blur textarea" : "clearValidationErrors" - }, - fieldToSelectorMap : { - // Your subclass must populate this w/ all of the model keys and dom selectors - // which may be the subjects of validation errors - }, - _cacheValidationErrors : [], - handleValidationError : function(model, error) { - // error is object w/ fields and error strings - for (var field in error) { - var ele = this.$el.find('#' + this.fieldToSelectorMap[field]); - this._cacheValidationErrors.push(ele); - if ($(ele).is('div')) { - // put error on the contained inputs - $(ele).find('input, textarea').addClass('error'); - } - else $(ele).addClass('error'); - $(ele).parent().append(this.errorTemplate({message : error[field]})); - } - }, - - clearValidationErrors : function() { - // error is object w/ fields and error strings - while (this._cacheValidationErrors.length > 0) { - var ele = this._cacheValidationErrors.pop(); - if ($(ele).is('div')) { - // put error on the contained inputs - $(ele).find('input, textarea').removeClass('error'); - } - else $(ele).removeClass('error'); - $(ele).nextAll('.message-error').remove(); - } - }, - - saveIfChanged : function(event) { - // returns true if the value changed and was thus sent to server - var field = this.selectorToField[event.currentTarget.id]; - var currentVal = this.model.get(field); - var newVal = $(event.currentTarget).val(); - if (currentVal != newVal) { - this.clearValidationErrors(); - this.model.save(field, newVal, { error : CMS.ServerError}); - return true; - } - else return false; - } + errorTemplate : _.template('<%= message %>'), + + events : { + "change input" : "clearValidationErrors", + "change textarea" : "clearValidationErrors" + }, + fieldToSelectorMap : { + // Your subclass must populate this w/ all of the model keys and dom selectors + // which may be the subjects of validation errors + }, + _cacheValidationErrors : [], + handleValidationError : function(model, error) { + console.log('validation', model, error); + // error is object w/ fields and error strings + for (var field in error) { + var ele = this.$el.find('#' + this.fieldToSelectorMap[field]); + this._cacheValidationErrors.push(ele); + if ($(ele).is('div')) { + // put error on the contained inputs + $(ele).find('input, textarea').addClass('error'); + } + else $(ele).addClass('error'); + $(ele).parent().append(this.errorTemplate({message : error[field]})); + } + }, + + clearValidationErrors : function() { + // error is object w/ fields and error strings + while (this._cacheValidationErrors.length > 0) { + var ele = this._cacheValidationErrors.pop(); + if ($(ele).is('div')) { + // put error on the contained inputs + $(ele).find('input, textarea').removeClass('error'); + } + else $(ele).removeClass('error'); + $(ele).nextAll('.message-error').remove(); + } + }, + + saveIfChanged : function(event) { + // returns true if the value changed and was thus sent to server + var field = this.selectorToField[event.currentTarget.id]; + var currentVal = this.model.get(field); + var newVal = $(event.currentTarget).val(); + if (currentVal != newVal) { + this.clearValidationErrors(); + this.model.save(field, newVal, { error : CMS.ServerError}); + return true; + } + else return false; + } }); CMS.Views.Settings.Main = Backbone.View.extend({ - // Model class is CMS.Models.Settings.CourseSettings - // allow navigation between the tabs - events: { - 'click .settings-page-menu a': "showSettingsTab", - 'mouseover #timezone' : "updateTime" - }, - - currentTab: null, - subviews: {}, // indexed by tab name + // Model class is CMS.Models.Settings.CourseSettings + // allow navigation between the tabs + events: { + 'click .settings-page-menu a': "showSettingsTab", + 'mouseover #timezone' : "updateTime" + }, - initialize: function() { - // load templates - this.currentTab = this.$el.find('.settings-page-menu .is-shown').attr('data-section'); - // create the initial subview - this.subviews[this.currentTab] = this.createSubview(); - - // fill in fields - this.$el.find("#course-name").val(this.model.get('courseLocation').get('name')); - this.$el.find("#course-organization").val(this.model.get('courseLocation').get('org')); - this.$el.find("#course-number").val(this.model.get('courseLocation').get('course')); - this.$el.find('.set-date').datepicker({ 'dateFormat': 'm/d/yy' }); - this.$el.find(":input, textarea").focus(function() { - $("label[for='" + this.id + "']").addClass("is-focused"); - }).blur(function() { - $("label").removeClass("is-focused"); - }); - this.render(); - }, - - render: function() { - - // create any necessary subviews and put them onto the page - if (!this.model.has(this.currentTab)) { - // TODO disable screen until fetch completes? - var cachethis = this; - this.model.retrieve(this.currentTab, function() { - cachethis.subviews[cachethis.currentTab] = cachethis.createSubview(); - cachethis.subviews[cachethis.currentTab].render(); - }); - } - else this.subviews[this.currentTab].render(); - - var dateIntrospect = new Date(); - this.$el.find('#timezone').html("(" + dateIntrospect.getTimezone() + ")"); - - return this; - }, - - createSubview: function() { - switch (this.currentTab) { - case 'details': - return new CMS.Views.Settings.Details({ - el: this.$el.find('.settings-' + this.currentTab), - model: this.model.get(this.currentTab) - }); - case 'faculty': - break; - case 'grading': - return new CMS.Views.Settings.Grading({ - el: this.$el.find('.settings-' + this.currentTab), - model: this.model.get(this.currentTab) - }); - case 'problems': - break; - case 'discussions': - break; - } - }, - - updateTime : function(e) { - var now = new Date(); - var hours = now.getHours(); - var minutes = now.getMinutes(); - $(e.currentTarget).attr('title', (hours % 12 === 0 ? 12 : hours % 12) + ":" + (minutes < 10 ? "0" : "") + - now.getMinutes() + (hours < 12 ? "am" : "pm") + " (current local time)"); - }, - - showSettingsTab: function(e) { - this.currentTab = $(e.target).attr('data-section'); - $('.settings-page-section > section').hide(); - $('.settings-' + this.currentTab).show(); - $('.settings-page-menu .is-shown').removeClass('is-shown'); - $(e.target).addClass('is-shown'); - // fetch model for the tab if not loaded already - this.render(); - } + currentTab: null, + subviews: {}, // indexed by tab name + + initialize: function() { + // load templates + this.currentTab = this.$el.find('.settings-page-menu .is-shown').attr('data-section'); + // create the initial subview + this.subviews[this.currentTab] = this.createSubview(); + + // fill in fields + this.$el.find("#course-name").val(this.model.get('courseLocation').get('name')); + this.$el.find("#course-organization").val(this.model.get('courseLocation').get('org')); + this.$el.find("#course-number").val(this.model.get('courseLocation').get('course')); + this.$el.find('.set-date').datepicker({ 'dateFormat': 'm/d/yy' }); + this.$el.find(":input, textarea").focus(function() { + $("label[for='" + this.id + "']").addClass("is-focused"); + }).blur(function() { + $("label").removeClass("is-focused"); + }); + this.render(); + }, + + render: function() { + + // create any necessary subviews and put them onto the page + if (!this.model.has(this.currentTab)) { + // TODO disable screen until fetch completes? + var cachethis = this; + this.model.retrieve(this.currentTab, function() { + cachethis.subviews[cachethis.currentTab] = cachethis.createSubview(); + cachethis.subviews[cachethis.currentTab].render(); + }); + } + else { + // Advanced (at least) model gets created at bootstrap but the view does not + if (!this.subviews[this.currentTab]) { + this.subviews[this.currentTab] = this.createSubview(); + } + this.subviews[this.currentTab].render(); + } + + var dateIntrospect = new Date(); + this.$el.find('#timezone').html("(" + dateIntrospect.getTimezone() + ")"); + + return this; + }, + + createSubview: function() { + switch (this.currentTab) { + case 'details': + return new CMS.Views.Settings.Details({ + el: this.$el.find('.settings-' + this.currentTab), + model: this.model.get(this.currentTab) + }); + break; + case 'faculty': + break; + case 'grading': + return new CMS.Views.Settings.Grading({ + el: this.$el.find('.settings-' + this.currentTab), + model: this.model.get(this.currentTab) + }); + break; + case 'advanced': + return new CMS.Views.Settings.Advanced({ + el: this.$el.find('.settings-' + this.currentTab), + model: this.model.get(this.currentTab) + }); + break; + case 'problems': + break; + case 'discussions': + break; + } + }, + + updateTime : function(e) { + var now = new Date(); + var hours = now.getHours(); + var minutes = now.getMinutes(); + $(e.currentTarget).attr('title', (hours % 12 === 0 ? 12 : hours % 12) + ":" + (minutes < 10 ? "0" : "") + + now.getMinutes() + (hours < 12 ? "am" : "pm") + " (current local time)"); + }, + + showSettingsTab: function(e) { + this.currentTab = $(e.target).data('section'); + $('.settings-page-section > section').hide(); + $('.settings-' + this.currentTab).show(); + $('.settings-page-menu .is-shown').removeClass('is-shown'); + $(e.target).addClass('is-shown'); + // fetch model for the tab if not loaded already + this.render(); + } }); CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ - // Model class is CMS.Models.Settings.CourseDetails - events : { - "blur input" : "updateModel", - "blur textarea" : "updateModel", - 'click .remove-course-syllabus' : "removeSyllabus", - 'click .new-course-syllabus' : 'assetSyllabus', - 'click .remove-course-introduction-video' : "removeVideo", - 'focus #course-overview' : "codeMirrorize" - }, - initialize : function() { - // TODO move the html frag to a loaded asset - this.fileAnchorTemplate = _.template(' 📄<%= filename %>'); - this.model.on('error', this.handleValidationError, this); - this.selectorToField = _.invert(this.fieldToSelectorMap); - }, - - render: function() { - this.setupDatePicker('start_date'); - this.setupDatePicker('end_date'); - this.setupDatePicker('enrollment_start'); - this.setupDatePicker('enrollment_end'); - - if (this.model.has('syllabus')) { - this.$el.find(this.fieldToSelectorMap['syllabus']).html( - this.fileAnchorTemplate({ - fullpath : this.model.get('syllabus'), - filename: 'syllabus'})); - this.$el.find('.remove-course-syllabus').show(); - } - else { - this.$el.find('#' + this.fieldToSelectorMap['syllabus']).html(""); - this.$el.find('.remove-course-syllabus').hide(); - } - - this.$el.find('#' + this.fieldToSelectorMap['overview']).val(this.model.get('overview')); - this.codeMirrorize(null, $('#course-overview')[0]); - - this.$el.find('.current-course-introduction-video iframe').attr('src', this.model.videosourceSample()); - if (this.model.has('intro_video')) { - this.$el.find('.remove-course-introduction-video').show(); - this.$el.find('#' + this.fieldToSelectorMap['intro_video']).val(this.model.get('intro_video')); - } - else this.$el.find('.remove-course-introduction-video').hide(); - - this.$el.find('#' + this.fieldToSelectorMap['effort']).val(this.model.get('effort')); - - return this; - }, - fieldToSelectorMap : { - 'start_date' : "course-start", - 'end_date' : 'course-end', - 'enrollment_start' : 'enrollment-start', - 'enrollment_end' : 'enrollment-end', - 'syllabus' : '.current-course-syllabus .doc-filename', - 'overview' : 'course-overview', - 'intro_video' : 'course-introduction-video', - 'effort' : "course-effort" - }, + // Model class is CMS.Models.Settings.CourseDetails + events : { + "change input" : "updateModel", + "change textarea" : "updateModel", + 'click .remove-course-syllabus' : "removeSyllabus", + 'click .new-course-syllabus' : 'assetSyllabus', + 'click .remove-course-introduction-video' : "removeVideo", + 'focus #course-overview' : "codeMirrorize" + }, + initialize : function() { + // TODO move the html frag to a loaded asset + this.fileAnchorTemplate = _.template(' 📄<%= filename %>'); + this.model.on('error', this.handleValidationError, this); + this.selectorToField = _.invert(this.fieldToSelectorMap); + }, + + render: function() { + this.setupDatePicker('start_date'); + this.setupDatePicker('end_date'); + this.setupDatePicker('enrollment_start'); + this.setupDatePicker('enrollment_end'); + + if (this.model.has('syllabus')) { + this.$el.find(this.fieldToSelectorMap['syllabus']).html( + this.fileAnchorTemplate({ + fullpath : this.model.get('syllabus'), + filename: 'syllabus'})); + this.$el.find('.remove-course-syllabus').show(); + } + else { + this.$el.find('#' + this.fieldToSelectorMap['syllabus']).html(""); + this.$el.find('.remove-course-syllabus').hide(); + } + + this.$el.find('#' + this.fieldToSelectorMap['overview']).val(this.model.get('overview')); + this.codeMirrorize(null, $('#course-overview')[0]); + + this.$el.find('.current-course-introduction-video iframe').attr('src', this.model.videosourceSample()); + if (this.model.has('intro_video')) { + this.$el.find('.remove-course-introduction-video').show(); + this.$el.find('#' + this.fieldToSelectorMap['intro_video']).val(this.model.get('intro_video')); + } + else this.$el.find('.remove-course-introduction-video').hide(); + + this.$el.find('#' + this.fieldToSelectorMap['effort']).val(this.model.get('effort')); + + return this; + }, + fieldToSelectorMap : { + 'start_date' : "course-start", + 'end_date' : 'course-end', + 'enrollment_start' : 'enrollment-start', + 'enrollment_end' : 'enrollment-end', + 'syllabus' : '.current-course-syllabus .doc-filename', + 'overview' : 'course-overview', + 'intro_video' : 'course-introduction-video', + 'effort' : "course-effort" + }, setupDatePicker: function (fieldName) { var cacheModel = this.model; @@ -245,58 +260,58 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ datefield.datepicker('setDate', this.model.get(fieldName)); if (this.model.has(fieldName)) timefield.timepicker('setTime', this.model.get(fieldName)); }, - - updateModel: function(event) { - switch (event.currentTarget.id) { - case 'course-start-date': // handled via onSelect method - case 'course-end-date': - case 'course-enrollment-start-date': - case 'course-enrollment-end-date': - break; - case 'course-overview': - // handled via code mirror - break; + updateModel: function(event) { + switch (event.currentTarget.id) { + case 'course-start-date': // handled via onSelect method + case 'course-end-date': + case 'course-enrollment-start-date': + case 'course-enrollment-end-date': + break; - case 'course-effort': - this.saveIfChanged(event); - break; - case 'course-introduction-video': - this.clearValidationErrors(); - var previewsource = this.model.save_videosource($(event.currentTarget).val()); - this.$el.find(".current-course-introduction-video iframe").attr("src", previewsource); - if (this.model.has('intro_video')) { - this.$el.find('.remove-course-introduction-video').show(); - } - else { - this.$el.find('.remove-course-introduction-video').hide(); - } - break; - - default: - break; - } - - }, - - removeSyllabus: function() { - if (this.model.has('syllabus')) this.model.save({'syllabus': null}, - { error : CMS.ServerError}); - }, - - assetSyllabus : function() { - // TODO implement - }, - - removeVideo: function() { - if (this.model.has('intro_video')) { - this.model.save_videosource(null); - this.$el.find(".current-course-introduction-video iframe").attr("src", ""); - this.$el.find('#' + this.fieldToSelectorMap['intro_video']).val(""); - this.$el.find('.remove-course-introduction-video').hide(); - } - }, - codeMirrors : {}, + case 'course-overview': + // handled via code mirror + break; + + case 'course-effort': + this.saveIfChanged(event); + break; + case 'course-introduction-video': + this.clearValidationErrors(); + var previewsource = this.model.save_videosource($(event.currentTarget).val()); + this.$el.find(".current-course-introduction-video iframe").attr("src", previewsource); + if (this.model.has('intro_video')) { + this.$el.find('.remove-course-introduction-video').show(); + } + else { + this.$el.find('.remove-course-introduction-video').hide(); + } + break; + + default: + break; + } + + }, + + removeSyllabus: function() { + if (this.model.has('syllabus')) this.model.save({'syllabus': null}, + { error : CMS.ServerError}); + }, + + assetSyllabus : function() { + // TODO implement + }, + + removeVideo: function() { + if (this.model.has('intro_video')) { + this.model.save_videosource(null); + this.$el.find(".current-course-introduction-video iframe").attr("src", ""); + this.$el.find('#' + this.fieldToSelectorMap['intro_video']).val(""); + this.$el.find('.remove-course-introduction-video').hide(); + } + }, + codeMirrors : {}, codeMirrorize: function (e, forcedTarget) { var thisTarget; if (forcedTarget) { @@ -316,42 +331,42 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ cachethis.clearValidationErrors(); var newVal = mirror.getValue(); if (cachethis.model.get(field) != newVal) cachethis.model.save(field, newVal, - { error: CMS.ServerError}); + { error: CMS.ServerError}); } }); } } - + }); CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({ - // Model class is CMS.Models.Settings.CourseGradingPolicy - events : { - "blur input" : "updateModel", - "blur textarea" : "updateModel", - "blur span[contenteditable=true]" : "updateDesignation", - "click .settings-extra header" : "showSettingsExtras", - "click .new-grade-button" : "addNewGrade", - "click .remove-button" : "removeGrade", - "click .add-grading-data" : "addAssignmentType" - }, - initialize : function() { - // load template for grading view - var self = this; + // Model class is CMS.Models.Settings.CourseGradingPolicy + events : { + "change input" : "updateModel", + "change textarea" : "updateModel", + "change span[contenteditable=true]" : "updateDesignation", + "click .settings-extra header" : "showSettingsExtras", + "click .new-grade-button" : "addNewGrade", + "click .remove-button" : "removeGrade", + "click .add-grading-data" : "addAssignmentType" + }, + initialize : function() { + // load template for grading view + var self = this; this.gradeCutoffTemplate = _.template('
  • ' + - '<%= descriptor %>' + - '' + - '<% if (removable) {%>remove<% ;} %>' + - '
  • '); + '<%= descriptor %>' + + '' + + '<% if (removable) {%>remove<% ;} %>' + + ''); // 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.push({designation: cutoff, cutoff: Math.round(modelCutoffs[cutoff] * 100)}); } this.descendingCutoffs = _.sortBy(this.descendingCutoffs, - function (gradeEle) { return -gradeEle['cutoff']; }); + function (gradeEle) { return -gradeEle['cutoff']; }); // Instrument grace period this.$el.find('#course-grading-graceperiod').timepicker(); @@ -359,330 +374,330 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({ // instantiates an editor template for each update in the collection // Because this calls render, put it after everything which render may depend upon to prevent race condition. window.templateLoader.loadRemoteTemplate("course_grade_policy", - "/static/client_templates/course_grade_policy.html", - function (raw_template) { - self.template = _.template(raw_template); - self.render(); + "/static/client_templates/course_grade_policy.html", + function (raw_template) { + self.template = _.template(raw_template); + self.render(); } ); - this.model.on('error', this.handleValidationError, this); - this.model.get('graders').on('remove', this.render, this); - this.model.get('graders').on('reset', this.render, this); - this.model.get('graders').on('add', this.render, this); - this.selectorToField = _.invert(this.fieldToSelectorMap); - }, - - render: function() { - // prevent bootstrap race condition by event dispatch - if (!this.template) return; - - // 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'); - gradeCollection.each(function(gradeModel) { - $(gradelist).append(self.template({model : gradeModel })); - var newEle = gradelist.children().last(); - var newView = new CMS.Views.Settings.GraderView({el: newEle, - model : gradeModel, collection : gradeCollection }); - }); - - // render the grade cutoffs - this.renderCutoffBar(); - - var graceEle = this.$el.find('#course-grading-graceperiod'); - graceEle.timepicker({'timeFormat' : 'H:i'}); // init doesn't take setTime - if (this.model.has('grace_period')) graceEle.timepicker('setTime', this.model.gracePeriodToDate()); - // remove any existing listeners to keep them from piling on b/c render gets called frequently - graceEle.off('change', this.setGracePeriod); - graceEle.on('change', this, this.setGracePeriod); - - return this; - }, - addAssignmentType : function(e) { - e.preventDefault(); - this.model.get('graders').push({}); - }, - fieldToSelectorMap : { - 'grace_period' : 'course-grading-graceperiod' - }, - setGracePeriod : function(event) { - event.data.clearValidationErrors(); - var newVal = event.data.model.dateToGracePeriod($(event.currentTarget).timepicker('getTime')); - if (event.data.model.get('grace_period') != newVal) event.data.model.save('grace_period', newVal, - { error : CMS.ServerError}); - }, - updateModel : function(event) { - if (!this.selectorToField[event.currentTarget.id]) return; + this.model.on('error', this.handleValidationError, this); + this.model.get('graders').on('remove', this.render, this); + this.model.get('graders').on('reset', this.render, this); + this.model.get('graders').on('add', this.render, this); + this.selectorToField = _.invert(this.fieldToSelectorMap); + }, - switch (this.selectorToField[event.currentTarget.id]) { - case 'grace_period': // handled above - break; + render: function() { + // prevent bootstrap race condition by event dispatch + if (!this.template) return; - default: - this.saveIfChanged(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% + // 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'); + gradeCollection.each(function(gradeModel) { + $(gradelist).append(self.template({model : gradeModel })); + var newEle = gradelist.children().last(); + var newView = new CMS.Views.Settings.GraderView({el: newEle, + model : gradeModel, collection : gradeCollection }); + }); + + // render the grade cutoffs + this.renderCutoffBar(); + + var graceEle = this.$el.find('#course-grading-graceperiod'); + graceEle.timepicker({'timeFormat' : 'H:i'}); // init doesn't take setTime + if (this.model.has('grace_period')) graceEle.timepicker('setTime', this.model.gracePeriodToDate()); + // remove any existing listeners to keep them from piling on b/c render gets called frequently + graceEle.off('change', this.setGracePeriod); + graceEle.on('change', this, this.setGracePeriod); + + return this; + }, + addAssignmentType : function(e) { + e.preventDefault(); + this.model.get('graders').push({}); + }, + fieldToSelectorMap : { + 'grace_period' : 'course-grading-graceperiod' + }, + setGracePeriod : function(event) { + event.data.clearValidationErrors(); + var newVal = event.data.model.dateToGracePeriod($(event.currentTarget).timepicker('getTime')); + if (event.data.model.get('grace_period') != newVal) event.data.model.save('grace_period', newVal, + { error : CMS.ServerError}); + }, + updateModel : function(event) { + if (!this.selectorToField[event.currentTarget.id]) return; + + switch (this.selectorToField[event.currentTarget.id]) { + case 'grace_period': // handled above + break; + + default: + this.saveIfChanged(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'); - }, - + _.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() + }); - 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}); - }; - }, + this.renderGradeRanges(); + }, - 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(); - }; - }, - - saveCutoffs: function() { - this.model.save('grade_cutoffs', - _.reduce(this.descendingCutoffs, - function(object, cutoff) { - object[cutoff['designation']] = cutoff['cutoff'] / 100.0; - return object; - }, - {}), - { error : CMS.ServerError}); - }, - - 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(); - - if (this.descendingCutoffs.length === 1 && this.descendingCutoffs[0]['designation'] === this.GRADES[0]) { - this.descendingCutoffs[0]['designation'] = 'Pass'; - this.setTopGradeLabel(); - } - this.setFailLabel(); - this.renderGradeRanges(); - this.saveCutoffs(); - }, - - updateDesignation: function(e) { - var index = $(e.currentTarget).closest('li').index(); - this.descendingCutoffs[index]['designation'] = $(e.currentTarget).html(); - this.saveCutoffs(); - }, + showSettingsExtras : function(event) { + $(event.currentTarget).toggleClass('active'); + $(event.currentTarget).siblings.toggleClass('is-shown'); + }, - 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']); - } + + 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(); + }; + }, + + saveCutoffs: function() { + this.model.save('grade_cutoffs', + _.reduce(this.descendingCutoffs, + function(object, cutoff) { + object[cutoff['designation']] = cutoff['cutoff'] / 100.0; + return object; + }, + {}), + { error : CMS.ServerError}); + }, + + 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(); + + if (this.descendingCutoffs.length === 1 && this.descendingCutoffs[0]['designation'] === this.GRADES[0]) { + this.descendingCutoffs[0]['designation'] = 'Pass'; + this.setTopGradeLabel(); + } + this.setFailLabel(); + this.renderGradeRanges(); + 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']); + } }); CMS.Views.Settings.GraderView = CMS.Views.ValidatingView.extend({ - // Model class is CMS.Models.Settings.CourseGrader - events : { - "blur input" : "updateModel", - "blur textarea" : "updateModel", - "click .remove-grading-data" : "deleteModel" - }, - initialize : function() { - this.model.on('error', this.handleValidationError, this); - this.selectorToField = _.invert(this.fieldToSelectorMap); - this.render(); - }, - - render: function() { - return this; - }, - fieldToSelectorMap : { - 'type' : 'course-grading-assignment-name', - 'short_label' : 'course-grading-assignment-shortname', - 'min_count' : 'course-grading-assignment-totalassignments', - 'drop_count' : 'course-grading-assignment-droppable', - 'weight' : 'course-grading-assignment-gradeweight' - }, - updateModel : function(event) { - // HACK to fix model sometimes losing its pointer to the collection [I think I fixed this but leaving - // this in out of paranoia. If this error ever happens, the user will get a warning that they cannot - // give 2 assignments the same name.] - if (!this.model.collection) { - this.model.collection = this.collection; - } - - switch (event.currentTarget.id) { - case 'course-grading-assignment-totalassignments': - this.$el.find('#course-grading-assignment-droppable').attr('max', $(event.currentTarget).val()); - this.saveIfChanged(event); - break; - case 'course-grading-assignment-name': - var oldName = this.model.get('type'); - if (this.saveIfChanged(event) && !_.isEmpty(oldName)) { - // overload the error display logic - this._cacheValidationErrors.push(event.currentTarget); - $(event.currentTarget).parent().append( - this.errorTemplate({message : 'For grading to work, you must change all "' + oldName + - '" subsections to "' + this.model.get('type') + '".'})); - } - break; - default: - this.saveIfChanged(event); - break; - } - }, - deleteModel : function(e) { - this.model.destroy( - { error : CMS.ServerError}); - e.preventDefault(); - } - + // Model class is CMS.Models.Settings.CourseGrader + events : { + "change input" : "updateModel", + "change textarea" : "updateModel", + "click .remove-grading-data" : "deleteModel" + }, + initialize : function() { + this.model.on('error', this.handleValidationError, this); + this.selectorToField = _.invert(this.fieldToSelectorMap); + this.render(); + }, + + render: function() { + return this; + }, + fieldToSelectorMap : { + 'type' : 'course-grading-assignment-name', + 'short_label' : 'course-grading-assignment-shortname', + 'min_count' : 'course-grading-assignment-totalassignments', + 'drop_count' : 'course-grading-assignment-droppable', + 'weight' : 'course-grading-assignment-gradeweight' + }, + updateModel : function(event) { + // HACK to fix model sometimes losing its pointer to the collection [I think I fixed this but leaving + // this in out of paranoia. If this error ever happens, the user will get a warning that they cannot + // give 2 assignments the same name.] + if (!this.model.collection) { + this.model.collection = this.collection; + } + + switch (event.currentTarget.id) { + case 'course-grading-assignment-totalassignments': + this.$el.find('#course-grading-assignment-droppable').attr('max', $(event.currentTarget).val()); + this.saveIfChanged(event); + break; + case 'course-grading-assignment-name': + var oldName = this.model.get('type'); + if (this.saveIfChanged(event) && !_.isEmpty(oldName)) { + // overload the error display logic + this._cacheValidationErrors.push(event.currentTarget); + $(event.currentTarget).parent().append( + this.errorTemplate({message : 'For grading to work, you must change all "' + oldName + + '" subsections to "' + this.model.get('type') + '".'})); + } + break; + default: + this.saveIfChanged(event); + break; + } + }, + deleteModel : function(e) { + this.model.destroy( + { error : CMS.ServerError}); + e.preventDefault(); + } + }); \ No newline at end of file diff --git a/cms/templates/settings.html b/cms/templates/settings.html index f70f3bfa91..b2ac3aa772 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -1,4 +1,5 @@ <%inherit file="base.html" /> +<%! from django.core.urlresolvers import reverse %> <%block name="bodyclass">settings <%block name="title">Settings @@ -15,24 +16,28 @@ from contentstore import utils + - - +