Commit 08c3a13f by Eric Fischer

Merge pull request #11968 from edx/efischer/refactor_date_utils

Course Updates date validation
parents 6ef023ba 29961736
...@@ -34,7 +34,7 @@ Feature: CMS.Course updates ...@@ -34,7 +34,7 @@ Feature: CMS.Course updates
Given I have opened a new course in Studio Given I have opened a new course in Studio
And I go to the course updates page And I go to the course updates page
And I add a new update with the text "Hello" And I add a new update with the text "Hello"
When I edit the date to "June 1, 2013" When I edit the date to "06/01/13"
Then I should see the date "June 1, 2013" Then I should see the date "June 1, 2013"
And I see a "saving" notification And I see a "saving" notification
......
...@@ -153,6 +153,21 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model ...@@ -153,6 +153,21 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model
it "does not remove existing course info on click outside modal", -> it "does not remove existing course info on click outside modal", ->
@cancelExistingCourseInfo(false) @cancelExistingCourseInfo(false)
@testInvalidDateValue: (value) ->
@courseInfoEdit.onNew(@event)
expect(@courseInfoEdit.$el.find('.save-button').hasClass("is-disabled")).toEqual(false)
@courseInfoEdit.$el.find('input.date').val(value).trigger("change")
expect(@courseInfoEdit.$el.find('.save-button').hasClass("is-disabled")).toEqual(true)
@courseInfoEdit.$el.find('input.date').val("01/01/16").trigger("change")
expect(@courseInfoEdit.$el.find('.save-button').hasClass("is-disabled")).toEqual(false)
it "does not allow updates to be saved with an invalid date", ->
@testInvalidDateValue("Marchtober 40, 2048")
it "does not allow updates to be saved with a blank date", ->
@testInvalidDateValue("")
describe "Course Updates WITH Push notification", -> describe "Course Updates WITH Push notification", ->
courseInfoTemplate = readFixtures('course_info_update.underscore') courseInfoTemplate = readFixtures('course_info_update.underscore')
......
...@@ -6,6 +6,13 @@ define(["backbone", "jquery", "jquery.ui"], function(Backbone, $) { ...@@ -6,6 +6,13 @@ define(["backbone", "jquery", "jquery.ui"], function(Backbone, $) {
"content" : "", "content" : "",
"push_notification_enabled": false, "push_notification_enabled": false,
"push_notification_selected" : false "push_notification_selected" : false
},
validate: function(attrs) {
var date_exists = (attrs.date !== null && attrs.date !== "");
var date_is_valid_string = ($.datepicker.formatDate('MM d, yy', new Date(attrs.date)) === attrs.date);
if (!(date_exists && date_is_valid_string)) {
return {"date_required": gettext("Action required: Enter a valid date.")};
}
} }
}); });
return CourseUpdate; return CourseUpdate;
......
define(["jquery", "date", "jquery.ui", "jquery.timepicker"], function($, date) { define(["jquery", "date", "js/utils/change_on_enter", "jquery.ui", "jquery.timepicker"],
function($, date, TriggerChangeEventOnEnter) {
'use strict';
var setupDatePicker = function (fieldName, view, index) {
var cacheModel;
var div;
if (typeof index !== "undefined" && view.hasOwnProperty("collection")) {
cacheModel = view.collection.models[index];
div = view.$el.find('#' + view.collectionSelector(cacheModel.cid));
} else {
cacheModel = view.model;
div = view.$el.find('#' + view.fieldToSelectorMap[fieldName]);
}
var datefield = $(div).find("input.date");
var timefield = $(div).find("input.time");
var cacheview = view;
var setfield = function (event) {
var newVal = getDate(datefield, timefield),
oldTime = new Date(cacheModel.get(fieldName)).getTime();
if (newVal) {
if (!cacheModel.has(fieldName) || oldTime !== newVal.getTime()) {
cacheview.clearValidationErrors();
cacheview.setAndValidate(fieldName, newVal, event);
}
}
else {
// Clear date (note that this clears the time as well, as date and time are linked).
// Note also that the validation logic prevents us from clearing the start date
// (start date is required by the back end).
cacheview.clearValidationErrors();
cacheview.setAndValidate(fieldName, null, event);
}
};
// instrument as date and time pickers
timefield.timepicker({'timeFormat' : 'H:i'});
datefield.datepicker();
// Using the change event causes setfield to be triggered twice, but it is necessary
// to pick up when the date is typed directly in the field.
datefield.change(setfield).keyup(TriggerChangeEventOnEnter);
timefield.on('changeTime', setfield);
timefield.on('input', setfield);
var current_date = null;
if (cacheModel) {
current_date = cacheModel.get(fieldName);
}
// timepicker doesn't let us set null, so check that we have a time
if (current_date) {
setDate(datefield, timefield, current_date);
} // but reset fields either way
else {
timefield.val('');
datefield.val('');
}
};
var getDate = function (datepickerInput, timepickerInput) { var getDate = function (datepickerInput, timepickerInput) {
// given a pair of inputs (datepicker and timepicker), return a JS Date // given a pair of inputs (datepicker and timepicker), return a JS Date
// object that corresponds to the datetime.js that they represent. Assume // object that corresponds to the datetime.js that they represent. Assume
// UTC timezone, NOT the timezone of the user's browser. // UTC timezone, NOT the timezone of the user's browser.
var date = $(datepickerInput).datepicker("getDate"); var date = $(datepickerInput).datepicker("getDate"), time = null;
var time = $(timepickerInput).timepicker("getTime"); if (timepickerInput.length > 0) {
time = $(timepickerInput).timepicker("getTime");
}
if(date && time) { if(date && time) {
return new Date(Date.UTC( return new Date(Date.UTC(
date.getFullYear(), date.getMonth(), date.getDate(), date.getFullYear(), date.getMonth(), date.getDate(),
time.getHours(), time.getMinutes() time.getHours(), time.getMinutes()
)); ));
} else if (date) {
return new Date(Date.UTC(
date.getFullYear(), date.getMonth(), date.getDate()));
} else { } else {
return null; return null;
} }
...@@ -21,7 +83,9 @@ define(["jquery", "date", "jquery.ui", "jquery.timepicker"], function($, date) { ...@@ -21,7 +83,9 @@ define(["jquery", "date", "jquery.ui", "jquery.timepicker"], function($, date) {
datetime = date.parse(datetime); datetime = date.parse(datetime);
if (datetime) { if (datetime) {
$(datepickerInput).datepicker("setDate", datetime); $(datepickerInput).datepicker("setDate", datetime);
$(timepickerInput).timepicker("setTime", datetime); if (timepickerInput.length > 0) {
$(timepickerInput).timepicker("setTime", datetime);
}
} }
}; };
...@@ -58,6 +122,7 @@ define(["jquery", "date", "jquery.ui", "jquery.timepicker"], function($, date) { ...@@ -58,6 +122,7 @@ define(["jquery", "date", "jquery.ui", "jquery.timepicker"], function($, date) {
setDate: setDate, setDate: setDate,
renderDate: renderDate, renderDate: renderDate,
convertDateStringsToObjects: convertDateStringsToObjects, convertDateStringsToObjects: convertDateStringsToObjects,
parseDateFromString: parseDateFromString parseDateFromString: parseDateFromString,
setupDatePicker: setupDatePicker
}; };
}); });
define(["js/views/baseview", "codemirror", "js/models/course_update", define(["js/views/validation", "codemirror", "js/models/course_update",
"common/js/components/views/feedback_prompt", "common/js/components/views/feedback_notification", "common/js/components/views/feedback_prompt", "common/js/components/views/feedback_notification",
"js/views/course_info_helper", "js/utils/modal"], "js/views/course_info_helper", "js/utils/modal", "js/utils/date_utils"],
function(BaseView, CodeMirror, CourseUpdateModel, PromptView, NotificationView, CourseInfoHelper, ModalUtils) { function(ValidatingView, CodeMirror, CourseUpdateModel, PromptView, NotificationView,
CourseInfoHelper, ModalUtils, DateUtils) {
var CourseInfoUpdateView = BaseView.extend({ 'use strict';
var CourseInfoUpdateView = ValidatingView.extend({
// collection is CourseUpdateCollection // collection is CourseUpdateCollection
events: { events: {
...@@ -19,6 +21,7 @@ define(["js/views/baseview", "codemirror", "js/models/course_update", ...@@ -19,6 +21,7 @@ define(["js/views/baseview", "codemirror", "js/models/course_update",
this.render(); this.render();
// when the client refetches the updates as a whole, re-render them // when the client refetches the updates as a whole, re-render them
this.listenTo(this.collection, 'reset', this.render); this.listenTo(this.collection, 'reset', this.render);
this.listenTo(this.collection, 'invalid', this.handleValidationError);
}, },
render: function () { render: function () {
...@@ -27,22 +30,68 @@ define(["js/views/baseview", "codemirror", "js/models/course_update", ...@@ -27,22 +30,68 @@ define(["js/views/baseview", "codemirror", "js/models/course_update",
// remove and then add all children // remove and then add all children
$(updateEle).empty(); $(updateEle).empty();
var self = this; var self = this;
this.collection.each(function (update) { this.collection.each(function (update, index) {
try { try {
CourseInfoHelper.changeContentToPreview( CourseInfoHelper.changeContentToPreview(
update, 'content', self.options['base_asset_url']); update, 'content', self.options['base_asset_url']);
// push notification is always disabled for existing updates // push notification is always disabled for existing updates
var newEle = self.template({ updateModel : update, push_notification_enabled : false }); var newEle = self.template({ updateModel : update, push_notification_enabled : false });
$(updateEle).append(newEle); $(updateEle).append(newEle);
DateUtils.setupDatePicker("date", self, index);
update.isValid();
} catch (e) { } catch (e) {
// ignore // ignore
} }
}); });
this.$el.find(".new-update-form").hide(); this.$el.find(".new-update-form").hide();
this.$el.find('.date').datepicker({ 'dateFormat': 'MM d, yy' });
return this; 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) { onNew: function(event) {
event.preventDefault(); event.preventDefault();
var self = this; var self = this;
...@@ -75,15 +124,15 @@ define(["js/views/baseview", "codemirror", "js/models/course_update", ...@@ -75,15 +124,15 @@ define(["js/views/baseview", "codemirror", "js/models/course_update",
// Binding empty function to prevent default hideModal. // Binding empty function to prevent default hideModal.
}); });
$('.date').datepicker('destroy'); DateUtils.setupDatePicker("date", this, 0);
$('.date').datepicker({ 'dateFormat': 'MM d, yy' });
}, },
onSave: function(event) { onSave: function(event) {
event.preventDefault(); event.preventDefault();
var targetModel = this.eventModel(event); var targetModel = this.eventModel(event);
targetModel.set({ targetModel.set({
date : this.dateEntry(event).val(), // 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(), content : this.$codeMirror.getValue(),
push_notification_selected : this.push_notification_selected(event) push_notification_selected : this.push_notification_selected(event)
}); });
...@@ -112,11 +161,13 @@ define(["js/views/baseview", "codemirror", "js/models/course_update", ...@@ -112,11 +161,13 @@ define(["js/views/baseview", "codemirror", "js/models/course_update",
onCancel: function(event) { onCancel: function(event) {
event.preventDefault(); event.preventDefault();
// change editor contents back to model values and hide the editor // Since we're cancelling, the model should be using it's previous attributes
$(this.editor(event)).hide();
// If the model was never created (user created a new update, then pressed Cancel),
// we wish to remove it from the DOM.
var targetModel = this.eventModel(event); 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); this.closeEditor(!targetModel.id);
}, },
...@@ -129,6 +180,13 @@ define(["js/views/baseview", "codemirror", "js/models/course_update", ...@@ -129,6 +180,13 @@ define(["js/views/baseview", "codemirror", "js/models/course_update",
$(this.editor(event)).show(); $(this.editor(event)).show();
var $textArea = this.$currentPost.find(".new-update-content").first(); var $textArea = this.$currentPost.find(".new-update-content").first();
var targetModel = this.eventModel(event); 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( this.$codeMirror = CourseInfoHelper.editWithCodeMirror(
targetModel, 'content', self.options['base_asset_url'], $textArea.get(0)); targetModel, 'content', self.options['base_asset_url'], $textArea.get(0));
...@@ -138,6 +196,9 @@ define(["js/views/baseview", "codemirror", "js/models/course_update", ...@@ -138,6 +196,9 @@ define(["js/views/baseview", "codemirror", "js/models/course_update",
self.closeEditor(false); self.closeEditor(false);
} }
); );
// Ensure validity is marked appropriately
targetModel.isValid();
}, },
onDelete: function(event) { onDelete: function(event) {
...@@ -189,6 +250,8 @@ define(["js/views/baseview", "codemirror", "js/models/course_update", ...@@ -189,6 +250,8 @@ define(["js/views/baseview", "codemirror", "js/models/course_update",
closeEditor: function(removePost) { closeEditor: function(removePost) {
var targetModel = this.collection.get(this.$currentPost.attr('name')); 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) { if(removePost) {
this.$currentPost.remove(); this.$currentPost.remove();
} }
......
define(["js/views/validation", "codemirror", "underscore", "jquery", "jquery.ui", "js/utils/date_utils", "js/models/uploads", define(["js/views/validation", "codemirror", "underscore", "jquery", "jquery.ui", "js/utils/date_utils", "js/models/uploads",
"js/views/uploads", "js/utils/change_on_enter", "js/views/license", "js/models/license", "js/views/uploads", "js/views/license", "js/models/license",
"common/js/components/views/feedback_notification", "jquery.timepicker", "date", "gettext"], "common/js/components/views/feedback_notification", "jquery.timepicker", "date", "gettext"],
function(ValidatingView, CodeMirror, _, $, ui, DateUtils, FileUploadModel, function(ValidatingView, CodeMirror, _, $, ui, DateUtils, FileUploadModel,
FileUploadDialog, TriggerChangeEventOnEnter, LicenseView, LicenseModel, NotificationView, FileUploadDialog, LicenseView, LicenseModel, NotificationView,
timepicker, date, gettext) { timepicker, date, gettext) {
var DetailsView = ValidatingView.extend({ var DetailsView = ValidatingView.extend({
...@@ -63,10 +63,10 @@ var DetailsView = ValidatingView.extend({ ...@@ -63,10 +63,10 @@ var DetailsView = ValidatingView.extend({
}, },
render: function() { render: function() {
this.setupDatePicker('start_date'); DateUtils.setupDatePicker('start_date', this);
this.setupDatePicker('end_date'); DateUtils.setupDatePicker('end_date', this);
this.setupDatePicker('enrollment_start'); DateUtils.setupDatePicker('enrollment_start', this);
this.setupDatePicker('enrollment_end'); DateUtils.setupDatePicker('enrollment_end', this);
this.$el.find('#' + this.fieldToSelectorMap['overview']).val(this.model.get('overview')); this.$el.find('#' + this.fieldToSelectorMap['overview']).val(this.model.get('overview'));
this.codeMirrorize(null, $('#course-overview')[0]); this.codeMirrorize(null, $('#course-overview')[0]);
...@@ -147,51 +147,6 @@ var DetailsView = ValidatingView.extend({ ...@@ -147,51 +147,6 @@ var DetailsView = ValidatingView.extend({
}, true)); }, true));
}, },
setupDatePicker: function (fieldName) {
var cacheModel = this.model;
var div = this.$el.find('#' + this.fieldToSelectorMap[fieldName]);
var datefield = $(div).find("input.date");
var timefield = $(div).find("input.time");
var cachethis = this;
var setfield = function () {
var newVal = DateUtils.getDate(datefield, timefield),
oldTime = new Date(cacheModel.get(fieldName)).getTime();
if (newVal) {
if (!cacheModel.has(fieldName) || oldTime !== newVal.getTime()) {
cachethis.clearValidationErrors();
cachethis.setAndValidate(fieldName, newVal);
}
}
else {
// Clear date (note that this clears the time as well, as date and time are linked).
// Note also that the validation logic prevents us from clearing the start date
// (start date is required by the back end).
cachethis.clearValidationErrors();
cachethis.setAndValidate(fieldName, null);
}
};
// instrument as date and time pickers
timefield.timepicker({'timeFormat' : 'H:i'});
datefield.datepicker();
// Using the change event causes setfield to be triggered twice, but it is necessary
// to pick up when the date is typed directly in the field.
datefield.change(setfield).keyup(TriggerChangeEventOnEnter);
timefield.on('changeTime', setfield);
timefield.on('input', setfield);
date = this.model.get(fieldName)
// timepicker doesn't let us set null, so check that we have a time
if (date) {
DateUtils.setDate(datefield, timefield, date);
} // but reset fields either way
else {
timefield.val('');
datefield.val('');
}
},
updateModel: function(event) { updateModel: function(event) {
switch (event.currentTarget.id) { switch (event.currentTarget.id) {
case 'course-language': case 'course-language':
......
...@@ -66,7 +66,6 @@ ...@@ -66,7 +66,6 @@
line-height: 30px; line-height: 30px;
color: #646464; color: #646464;
letter-spacing: 1px; letter-spacing: 1px;
text-transform: uppercase;
} }
h3 { h3 {
...@@ -74,6 +73,23 @@ ...@@ -74,6 +73,23 @@
@extend %t-strong; @extend %t-strong;
margin: 34px 0 11px; margin: 34px 0 11px;
} }
.display-date {
padding-right: 15px;
}
.message-error {
display: inline-block;
font-weight: normal;
font-size: 1.2em;
&:before {
content: "\f06a";
font-family: FontAwesome;
color: #fdbc56;
padding: 5px;
}
}
} }
.update-contents { .update-contents {
......
...@@ -439,7 +439,7 @@ class CourseInfoModule(CourseInfoFields, HtmlModuleMixin): ...@@ -439,7 +439,7 @@ class CourseInfoModule(CourseInfoFields, HtmlModuleMixin):
return self.data return self.data
else: else:
course_updates = [item for item in self.items if item.get('status') == self.STATUS_VISIBLE] course_updates = [item for item in self.items if item.get('status') == self.STATUS_VISIBLE]
course_updates.sort(key=lambda item: datetime.strptime(item['date'], '%B %d, %Y'), reverse=True) course_updates.sort(key=lambda item: CourseInfoModule.safe_parse_date(item['date']), reverse=True)
context = { context = {
'visible_updates': course_updates[:3], 'visible_updates': course_updates[:3],
...@@ -448,6 +448,16 @@ class CourseInfoModule(CourseInfoFields, HtmlModuleMixin): ...@@ -448,6 +448,16 @@ class CourseInfoModule(CourseInfoFields, HtmlModuleMixin):
return self.system.render_template("{0}/course_updates.html".format(self.TEMPLATE_DIR), context) return self.system.render_template("{0}/course_updates.html".format(self.TEMPLATE_DIR), context)
@staticmethod
def safe_parse_date(date):
"""
Since this is used solely for ordering purposes, use today's date as a default
"""
try:
return datetime.strptime(date, '%B %d, %Y')
except ValueError: # occurs for ill-formatted date values
return datetime.today()
@XBlock.tag("detached") @XBlock.tag("detached")
class CourseInfoDescriptor(CourseInfoFields, HtmlDescriptor): class CourseInfoDescriptor(CourseInfoFields, HtmlDescriptor):
......
...@@ -3,7 +3,7 @@ import unittest ...@@ -3,7 +3,7 @@ import unittest
from mock import Mock from mock import Mock
from xblock.field_data import DictFieldData from xblock.field_data import DictFieldData
from xmodule.html_module import HtmlModule, HtmlDescriptor from xmodule.html_module import HtmlModule, HtmlDescriptor, CourseInfoModule
from . import get_test_system, get_test_descriptor_system from . import get_test_system, get_test_descriptor_system
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
...@@ -144,3 +144,40 @@ class HtmlDescriptorIndexingTestCase(unittest.TestCase): ...@@ -144,3 +144,40 @@ class HtmlDescriptorIndexingTestCase(unittest.TestCase):
"content": {"html_content": " This has HTML comment in it. HTML end. ", "display_name": "Text"}, "content": {"html_content": " This has HTML comment in it. HTML end. ", "display_name": "Text"},
"content_type": "Text" "content_type": "Text"
}) })
class CourseInfoModuleTestCase(unittest.TestCase):
"""
Make sure that CourseInfoModule renders updates properly
"""
def test_updates_render(self):
"""
Tests that a course info module will render its updates, even if they are malformed.
"""
sample_update_data = [
{
"id": i,
"date": data,
"content": "This is a very important update!",
"status": CourseInfoModule.STATUS_VISIBLE,
} for i, data in enumerate(
[
'January 1, 1970',
'Marchtober 45, -1963',
'Welcome!',
'Date means "title", right?'
]
)
]
info_module = CourseInfoModule(
Mock(),
get_test_system(),
DictFieldData({'items': sample_update_data, 'data': ""}),
Mock()
)
# Prior to TNL-4115, an exception would be raised when trying to parse invalid dates in this method
try:
info_module.get_html()
except ValueError:
self.fail("CourseInfoModule could not parse an invalid date!")
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment