Commit 9094f189 by Peter Fogg

Change course settings to require explicit save.

Rather than asynchronously saving when a setting is updated, we now
prompt the user to confirm their changes and only persist the data if
they hit the save button. Lettuce tests are updated to expect this
behavior and some new ones are added.
parent 164a469e
......@@ -48,6 +48,8 @@ moved to be edited as metadata.
XModule: Only write out assets files if the contents have changed.
Studio: Course settings are now saved explicitly.
XModule: Don't delete generated xmodule asset files when compiling (for
instance, when XModule provides a coffeescript file, don't delete
the associated javascript)
......
......@@ -55,6 +55,12 @@ def i_have_opened_a_new_course(_step):
open_new_course()
@step('I (save|cancel) my changes$')
def save_changes(step, action):
button_css = '.action-%s' % action
world.css_click(button_css)
####### HELPER FUNCTIONS ##############
def open_new_course():
world.clear_courses()
......
......@@ -5,15 +5,18 @@ Feature: Course Settings
Given I have opened a new course in Studio
When I select Schedule and Details
And I set course dates
And I save my changes
Then I see the set dates on refresh
Scenario: User can clear previously set course dates (except start date)
Given I have set course dates
And I clear all the dates except start
And I save my changes
Then I see cleared dates on refresh
Scenario: User cannot clear the course start date
Given I have set course dates
And I save my changes
And I clear the course start date
Then I receive a warning about course start date
And The previously set start date is shown on refresh
......@@ -21,5 +24,13 @@ Feature: Course Settings
Scenario: User can correct the course start date warning
Given I have tried to clear the course start
And I have entered a new course start date
And I save my changes
Then The warning about course start date goes away
And My new course start date is shown on refresh
Scenario: Settings are only persisted when saved
Given I have set course dates
And I save my changes
When I change fields
And I cancel my changes
Then I do not see the new changes persisted on refresh
......@@ -47,8 +47,6 @@ def test_and_i_set_course_dates(step):
set_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
set_date_or_time(ENROLLMENT_END_TIME_CSS, DUMMY_TIME)
pause()
@step('Then I see the set dates on refresh$')
def test_then_i_see_the_set_dates_on_refresh(step):
......@@ -71,8 +69,6 @@ def test_and_i_clear_all_the_dates_except_start(step):
set_date_or_time(ENROLLMENT_START_DATE_CSS, '')
set_date_or_time(ENROLLMENT_END_DATE_CSS, '')
pause()
@step('Then I see cleared dates on refresh$')
def test_then_i_see_cleared_dates_on_refresh(step):
......@@ -119,7 +115,6 @@ def test_i_have_tried_to_clear_the_course_start(step):
@step('I have entered a new course start date$')
def test_i_have_entered_a_new_course_start_date(step):
set_date_or_time(COURSE_START_DATE_CSS, '12/22/2013')
pause()
@step('The warning about course start date goes away$')
......@@ -137,6 +132,20 @@ def test_my_new_course_start_date_is_shown_on_refresh(step):
verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
@step('I change fields$')
def test_i_change_fields(step):
set_date_or_time(COURSE_START_DATE_CSS, '7/7/7777')
set_date_or_time(COURSE_END_DATE_CSS, '7/7/7777')
set_date_or_time(ENROLLMENT_START_DATE_CSS, '7/7/7777')
set_date_or_time(ENROLLMENT_END_DATE_CSS, '7/7/7777')
@step('I do not see the new changes persisted on refresh$')
def test_changes_not_shown_on_refresh(step):
reload_the_page(step)
step.then('Then I see the set dates on refresh')
############### HELPER METHODS ####################
def set_date_or_time(css, date_or_time):
"""
......@@ -153,11 +162,3 @@ def verify_date_or_time(css, date_or_time):
Verifies date or time field.
"""
assert_equal(date_or_time, world.css_find(css).first.value)
def pause():
"""
Must sleep briefly to allow last time save to finish,
else refresh of browser will fail.
"""
time.sleep(float(1))
......@@ -32,6 +32,7 @@ Feature: Course Grading
And I have populated the course
And I am viewing the grading settings
When I change assignment type "Homework" to "New Type"
And I save my changes
And I go back to the main course page
Then I do see the assignment name "New Type"
And I do not see the assignment name "Homework"
......@@ -49,5 +50,14 @@ Feature: Course Grading
And I have populated the course
And I am viewing the grading settings
When I add a new assignment type "New Type"
And I save my changes
And I go back to the main course page
Then I do see the assignment name "New Type"
Scenario: Settings are only persisted when saved
Given I have opened a new course in Studio
And I have populated the course
And I am viewing the grading settings
When I change assignment type "Homework" to "New Type"
And I cancel my changes
Then I do not see the changes persisted on refresh
......@@ -3,6 +3,7 @@
from lettuce import world, step
from common import *
from terrain.steps import reload_the_page
@step(u'I am viewing the grading settings')
......@@ -59,6 +60,8 @@ def change_assignment_name(step, old_name, new_name):
for count in range(len(old_name)):
f._element.send_keys(Keys.END, Keys.BACK_SPACE)
f._element.send_keys(new_name)
# Without this, the "you've made changes" notification won't pop up
f._element.send_keys(Keys.ENTER)
@step(u'I go back to the main course page')
......@@ -91,6 +94,8 @@ def add_assignment_type(step, new_name):
name_id = '#course-grading-assignment-name'
f = world.css_find(name_id)[4]
f._element.send_keys(new_name)
# Without this, the "you've made changes" notification won't pop up
f._element.send_keys(Keys.ENTER)
@step(u'I have populated the course')
......@@ -99,6 +104,14 @@ def populate_course(step):
step.given('I have added a new subsection')
@step(u'I do not see the changes persisted on refresh$')
def changes_not_persisted(step):
reload_the_page(step)
name_id = '#course-grading-assignment-name'
ele = world.css_find(name_id)[0]
assert(ele.value == 'Homework')
def get_type_index(name):
name_id = '#course-grading-assignment-name'
f = world.css_find(name_id)
......
......@@ -63,13 +63,13 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({
},
_videokey_illegal_chars : /[^a-zA-Z0-9_-]/g,
save_videosource: function(newsource) {
set_videosource: function(newsource) {
// newsource either is <video youtube="speed:key, *"/> or just the "speed:key, *" string
// returns the videosource for the preview which iss the key whose speed is closest to 1
if (_.isEmpty(newsource) && !_.isEmpty(this.get('intro_video'))) this.save({'intro_video': null});
if (_.isEmpty(newsource) && !_.isEmpty(this.get('intro_video'))) this.set({'intro_video': null}, {validate: true});
// TODO remove all whitespace w/in string
else {
if (this.get('intro_video') !== newsource) this.save('intro_video', newsource);
if (this.get('intro_video') !== newsource) this.set('intro_video', newsource, {validate: true});
}
return this.videosourceSample();
......
......@@ -15,6 +15,7 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
'blur :input' : "inputUnfocus"
},
initialize : function() {
this.fileAnchorTemplate = _.template('<a href="<%= fullpath %>"> <i class="icon-file"></i><%= filename %></a>');
// fill in fields
......@@ -87,7 +88,7 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
var datefield = $(div).find("input:.date");
var timefield = $(div).find("input:.time");
var cachethis = this;
var savefield = function () {
var setfield = function () {
cachethis.clearValidationErrors();
var date = datefield.datepicker('getDate');
if (date) {
......@@ -97,14 +98,14 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
}
var newVal = new Date(date.getTime() + time * 1000);
if (!cacheModel.has(fieldName) || cacheModel.get(fieldName).getTime() !== newVal.getTime()) {
cacheModel.save(fieldName, newVal);
cacheModel.set(fieldName, newVal, {validate: true});
}
}
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).
cacheModel.save(fieldName, null);
cacheModel.set(fieldName, null, {validate: true});
}
};
......@@ -112,10 +113,10 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
timefield.timepicker({'timeFormat' : 'H:i'});
datefield.datepicker();
// Using the change event causes savefield to be triggered twice, but it is necessary
// 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(savefield);
timefield.on('changeTime', savefield);
datefield.change(setfield);
timefield.on('changeTime', setfield);
datefield.datepicker('setDate', this.model.get(fieldName));
if (this.model.has(fieldName)) timefield.timepicker('setTime', this.model.get(fieldName));
......@@ -123,22 +124,13 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
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;
case 'course-effort':
this.saveIfChanged(event);
this.setField(event);
break;
// Don't make the user reload the page to check the Youtube ID.
case 'course-introduction-video':
this.clearValidationErrors();
var previewsource = this.model.save_videosource($(event.currentTarget).val());
var previewsource = this.model.set_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();
......@@ -147,15 +139,16 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
this.$el.find('.remove-course-introduction-video').hide();
}
break;
default:
default: // Everything else is handled by datepickers and CodeMirror.
break;
}
var self = this;
this.showNotificationBar(this.save_message,
_.bind(this.saveModel, this));
},
removeSyllabus: function() {
if (this.model.has('syllabus')) this.model.save({'syllabus': null});
if (this.model.has('syllabus')) this.model.set({'syllabus': null});
},
assetSyllabus : function() {
......@@ -164,7 +157,7 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
removeVideo: function() {
if (this.model.has('intro_video')) {
this.model.save_videosource(null);
this.model.set_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();
......@@ -185,11 +178,15 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
var field = this.selectorToField[thisTarget.id];
this.codeMirrors[thisTarget.id] = CodeMirror.fromTextArea(thisTarget, {
mode: "text/html", lineNumbers: true, lineWrapping: true,
onBlur: function (mirror) {
onChange: function (mirror) {
mirror.save();
cachethis.clearValidationErrors();
var newVal = mirror.getValue();
if (cachethis.model.get(field) != newVal) cachethis.model.save(field, newVal);
if (cachethis.model.get(field) != newVal) {
cachethis.model.set(field, newVal);
cachethis.showNotificationBar(cachethis.save_message,
_.bind(cachethis.saveModel, cachethis));
}
}
});
}
......
......@@ -88,9 +88,12 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
'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);
var self = event.data;
self.clearValidationErrors();
var newVal = self.model.dateToGracePeriod($(event.currentTarget).timepicker('getTime'));
self.model.set('grace_period', newVal, {validate: true});
self.showNotificationBar(self.save_message,
_.bind(self.saveModel, self));
},
updateModel : function(event) {
if (!this.selectorToField[event.currentTarget.id]) return;
......@@ -100,9 +103,12 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
break;
default:
this.saveIfChanged(event);
this.setField(event);
break;
}
var self = this;
this.showNotificationBar(this.save_message,
_.bind(self.saveModel, self));
},
// Grade sliders attributes and methods
......@@ -220,13 +226,16 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
},
saveCutoffs: function() {
this.model.save('grade_cutoffs',
this.model.set('grade_cutoffs',
_.reduce(this.descendingCutoffs,
function(object, cutoff) {
object[cutoff['designation']] = cutoff['cutoff'] / 100.0;
return object;
},
{}));
{}),
{validate: true});
this.showNotificationBar(this.save_message,
_.bind(this.saveModel, this));
},
addNewGrade: function(e) {
......@@ -342,11 +351,12 @@ CMS.Views.Settings.GraderView = CMS.Views.ValidatingView.extend({
switch (event.currentTarget.id) {
case 'course-grading-assignment-totalassignments':
this.$el.find('#course-grading-assignment-droppable').attr('max', $(event.currentTarget).val());
this.saveIfChanged(event);
this.setField(event);
break;
case 'course-grading-assignment-name':
var oldName = this.model.get('type');
if (this.saveIfChanged(event) && !_.isEmpty(oldName)) {
// If the name has changed, alert the user to change all subsection names.
if (this.setField(event) != oldName && !_.isEmpty(oldName)) {
// overload the error display logic
this._cacheValidationErrors.push(event.currentTarget);
$(event.currentTarget).parent().append(
......@@ -355,9 +365,12 @@ CMS.Views.Settings.GraderView = CMS.Views.ValidatingView.extend({
}
break;
default:
this.saveIfChanged(event);
this.setField(event);
break;
}
var self = this;
this.showNotificationBar(this.save_message,
_.bind(this.saveModel, this));
},
deleteModel : function(e) {
this.model.destroy();
......
......@@ -9,6 +9,8 @@ CMS.Views.ValidatingView = Backbone.View.extend({
errorTemplate : _.template('<span class="message-error"><%= message %></span>'),
save_message: gettext("Your changes will not take effect until you save your progress."),
events : {
"change input" : "clearValidationErrors",
"change textarea" : "clearValidationErrors"
......@@ -38,17 +40,13 @@ CMS.Views.ValidatingView = Backbone.View.extend({
}
},
saveIfChanged : function(event) {
// returns true if the value changed and was thus sent to server
setField : function(event) {
// Set model field and return the new value.
this.clearValidationErrors();
var field = this.selectorToField[event.currentTarget.id];
var currentVal = this.model.get(field);
var newVal = $(event.currentTarget).val();
this.clearValidationErrors(); // curr = new if user reverts manually
if (currentVal != newVal) {
this.model.save(field, newVal);
return true;
}
else return false;
this.model.set(field, newVal, {validate: true});
return newVal;
},
// these should perhaps go into a superclass but lack of event hash inheritance demotivates me
inputFocus : function(event) {
......@@ -101,5 +99,23 @@ CMS.Views.ValidatingView = Backbone.View.extend({
}});
this.notificationBarShowing = true;
this.confirmation.show();
},
showSavedBar: function(title, message) {
var defaultTitle = gettext('Your changes have been saved.');
this.saved = new CMS.Views.Alert.Confirmation({
title: title || defaultTitle,
message: message,
closeIcon: false
});
this.saved.show();
},
saveModel: function() {
var self = this;
this.model.save({},
{success: function() {
self.showSavedBar();
}});
}
});
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