Files
edx-platform/cms/static/js/views/course_info_update.js
Eric Fischer 2735fdc2d4 Course Updates date validation
TNL-4115. Previously, course updates (which are intended to be posted with
dates, for sorting in the LMS) could be authored in studio with a valid
date, nothing, or a random string as the "date" for the update. As there
is no validation for this in studio, everything succeeded with no warning.

However, the LMS has problems parsing some of these values, and barfs when
loaded by learners.

The fix does two big things:
- gracefully handles invalid dates in LMS. These updates are now treated as
having a date of today, for sorting purposes.
- turns on validation in studio. Now, it is not only impossible to enter
invalid dates in studio, but notifications will draw the course author's
eye if any invalid updates were previously saved.

Test additions for this commit:

Adds:
- unit test for LMS parsing
- Jasmine test to confirm invalid dates cannot be set by the user
    -also adds event to setAndValidate instead of using a global object
- fix for lettuce test
    -It is no longer valid to enter the string "January 1, 2013" as this test
    had been doing. Keyed-in entries must use MM/DD/YY format.
2016-04-08 11:37:40 -04:00

325 lines
13 KiB
JavaScript

define(["js/views/validation", "codemirror", "js/models/course_update",
"common/js/components/views/feedback_prompt", "common/js/components/views/feedback_notification",
"js/views/course_info_helper", "js/utils/modal", "js/utils/date_utils"],
function(ValidatingView, CodeMirror, CourseUpdateModel, PromptView, NotificationView,
CourseInfoHelper, ModalUtils, DateUtils) {
'use strict';
var CourseInfoUpdateView = ValidatingView.extend({
// collection is CourseUpdateCollection
events: {
"click .new-update-button" : "onNew",
"click #course-update-view .save-button" : "onSave",
"click #course-update-view .cancel-button" : "onCancel",
"click .post-actions > .edit-button" : "onEdit",
"click .post-actions > .delete-button" : "onDelete"
},
initialize: function() {
this.template = this.loadTemplate('course_info_update');
this.render();
// when the client refetches the updates as a whole, re-render them
this.listenTo(this.collection, 'reset', this.render);
this.listenTo(this.collection, 'invalid', this.handleValidationError);
},
render: function () {
// iterate over updates and create views for each using the template
var updateEle = this.$el.find("#course-update-list");
// remove and then add all children
$(updateEle).empty();
var self = this;
this.collection.each(function (update, index) {
try {
CourseInfoHelper.changeContentToPreview(
update, 'content', self.options['base_asset_url']);
// push notification is always disabled for existing updates
var newEle = self.template({ updateModel : update, push_notification_enabled : false });
$(updateEle).append(newEle);
DateUtils.setupDatePicker("date", self, index);
update.isValid();
} catch (e) {
// ignore
}
});
this.$el.find(".new-update-form").hide();
return this;
},
collectionSelector: function(uid) {
return "course-update-list li[name=" + uid + "]";
},
setAndValidate: function(attr, value, event) {
if (attr === 'date') {
// If the value to be set was typed, validate that entry rather than the current datepicker value
if (this.dateEntry(event).length > 0) {
value = DateUtils.parseDateFromString(this.dateEntry(event).val());
if (value && isNaN(value.getTime())) {
value = "";
}
}
value = $.datepicker.formatDate("MM d, yy", value);
}
var targetModel = this.collection.get(this.$currentPost.attr('name'));
var prevValue = targetModel.get(attr);
if (prevValue !== value) {
targetModel.set(attr, value);
this.validateModel(targetModel);
}
},
handleValidationError : function(model, error) {
var ele = this.$el.find('#course-update-list li[name=\"'+model.cid+'\"');
$(ele).find('.message-error').remove();
for (var field in error) {
if (error.hasOwnProperty(field)) {
$(ele).find('#update-date-'+model.cid).parent().append(
this.errorTemplate({message : error[field]})
);
$(ele).find('.date-display').parent().append(this.errorTemplate({message : error[field]}));
}
}
$(ele).find('.save-button').addClass('is-disabled');
},
validateModel: function(model) {
if (model.isValid()) {
var ele = this.$el.find('#course-update-list li[name=\"' + model.cid + '\"');
$(ele).find('.message-error').remove();
$(ele).find('.save-button').removeClass('is-disabled');
}
},
onNew: function(event) {
event.preventDefault();
var self = this;
// create new obj, insert into collection, and render this one ele overriding the hidden attr
var newModel = new CourseUpdateModel();
this.collection.add(newModel, {at : 0});
var $newForm = $(
this.template({
updateModel : newModel,
push_notification_enabled : this.options.push_notification_enabled
})
);
var updateEle = this.$el.find("#course-update-list");
$(updateEle).prepend($newForm);
var $textArea = $newForm.find(".new-update-content").first();
this.$codeMirror = CodeMirror.fromTextArea($textArea.get(0), {
mode: "text/html",
lineNumbers: true,
lineWrapping: true
});
$newForm.addClass('editing');
this.$currentPost = $newForm.closest('li');
// Variable stored for unit test.
this.$modalCover = ModalUtils.showModalCover(false, function() {
// Binding empty function to prevent default hideModal.
});
DateUtils.setupDatePicker("date", this, 0);
},
onSave: function(event) {
event.preventDefault();
var targetModel = this.eventModel(event);
targetModel.set({
// translate short-form date (for input) into long form date (for display)
date : $.datepicker.formatDate("MM d, yy", new Date(this.dateEntry(event).val())),
content : this.$codeMirror.getValue(),
push_notification_selected : this.push_notification_selected(event)
});
// push change to display, hide the editor, submit the change
var saving = new NotificationView.Mini({
title: gettext('Saving')
});
saving.show();
var ele = this.modelDom(event);
targetModel.save({}, {
success: function() {
saving.hide();
},
error: function() {
ele.remove();
}
});
this.closeEditor(false);
analytics.track('Saved Course Update', {
'course': course_location_analytics,
'date': this.dateEntry(event).val(),
'push_notification_selected': this.push_notification_selected(event)
});
},
onCancel: function(event) {
event.preventDefault();
// Since we're cancelling, the model should be using it's previous attributes
var targetModel = this.eventModel(event);
targetModel.set(targetModel.previousAttributes());
this.validateModel(targetModel);
// Hide the editor
$(this.editor(event)).hide();
// targetModel will be lacking an id if it was newly created
this.closeEditor(!targetModel.id);
},
onEdit: function(event) {
event.preventDefault();
var self = this;
this.$currentPost = $(event.target).closest('li');
this.$currentPost.addClass('editing');
$(this.editor(event)).show();
var $textArea = this.$currentPost.find(".new-update-content").first();
var targetModel = this.eventModel(event);
// translate long-form date (for viewing) into short-form date (for input)
if (targetModel.get('date') && targetModel.isValid()) {
$(this.dateEntry(event)).val($.datepicker.formatDate("mm/dd/yy", new Date(targetModel.get('date'))));
}
else {
$(this.dateEntry(event)).val("MM/DD/YY");
}
this.$codeMirror = CourseInfoHelper.editWithCodeMirror(
targetModel, 'content', self.options['base_asset_url'], $textArea.get(0));
// Variable stored for unit test.
this.$modalCover = ModalUtils.showModalCover(false,
function() {
self.closeEditor(false);
}
);
// Ensure validity is marked appropriately
targetModel.isValid();
},
onDelete: function(event) {
event.preventDefault();
var self = this;
var targetModel = this.eventModel(event);
var confirm = new PromptView.Warning({
title: gettext('Are you sure you want to delete this update?'),
message: gettext('This action cannot be undone.'),
actions: {
primary: {
text: gettext('OK'),
click: function () {
analytics.track('Deleted Course Update', {
'course': course_location_analytics,
'date': self.dateEntry(event).val()
});
self.modelDom(event).remove();
var deleting = new NotificationView.Mini({
title: gettext('Deleting')
});
deleting.show();
targetModel.destroy({
success: function (model, response) {
self.collection.fetch({
success: function() {
self.render();
deleting.hide();
},
reset: true
});
}
});
confirm.hide();
}
},
secondary: {
text: gettext('Cancel'),
click: function() {
confirm.hide();
}
}
}
});
confirm.show();
},
closeEditor: function(removePost) {
var targetModel = this.collection.get(this.$currentPost.attr('name'));
// If the model was never created (user created a new update, then pressed Cancel),
// we wish to remove it from the DOM.
if(removePost) {
this.$currentPost.remove();
}
else {
// close the modal and insert the appropriate data
this.$currentPost.removeClass('editing');
this.$currentPost.find('.date-display').html(targetModel.get('date'));
this.$currentPost.find('.date').val(targetModel.get('date'));
var content = CourseInfoHelper.changeContentToPreview(
targetModel, 'content', this.options['base_asset_url']);
try {
// just in case the content causes an error (embedded js errors)
this.$currentPost.find('.update-contents').html(content);
this.$currentPost.find('.new-update-content').val(content);
} catch (e) {
// ignore but handle rest of page
}
this.$currentPost.find('form').hide();
this.$currentPost.find('.CodeMirror').remove();
// hide the push notification checkbox for subsequent edits to the Post
var push_notification_ele = this.$currentPost.find(".new-update-push-notification");
if (push_notification_ele) {
push_notification_ele.hide();
}
}
ModalUtils.hideModalCover(this.$modalCover);
this.$codeMirror = null;
},
// Dereferencing from events to screen elements
eventModel: function(event) {
// not sure if it should be currentTarget or delegateTarget
return this.collection.get($(event.currentTarget).attr("name"));
},
modelDom: function(event) {
return $(event.currentTarget).closest("li");
},
editor: function(event) {
var li = $(event.currentTarget).closest("li");
if (li) {
return $(li).find("form").first();
}
},
dateEntry: function(event) {
var li = $(event.currentTarget).closest("li");
if (li) {
return $(li).find(".date").first();
}
},
push_notification_selected: function(event) {
var push_notification_checkbox;
var li = $(event.currentTarget).closest("li");
if (li) {
push_notification_checkbox = li.find(".new-update-push-notification .toggle-checkbox");
if (push_notification_checkbox) {
return push_notification_checkbox.is(":checked");
}
}
}
});
return CourseInfoUpdateView;
}); // end define()