Commit 3baad585 by Will Daly

Merge pull request #313 from edx/will/autosave

Implemented autosave
parents b7e87326 05f13bdd
...@@ -62,9 +62,7 @@ ...@@ -62,9 +62,7 @@
id="submission__answer__value" id="submission__answer__value"
placeholder="" placeholder=""
maxlength="100000" maxlength="100000"
> >{{ saved_response }}</textarea>
{{ saved_response }}
</textarea>
<span class="tip">{% trans "You may continue to work on your response until you submit it." %}</span> <span class="tip">{% trans "You may continue to work on your response until you submit it." %}</span>
</li> </li>
</ol> </ol>
......
...@@ -86,24 +86,29 @@ describe("OpenAssessment.ResponseView", function() { ...@@ -86,24 +86,29 @@ describe("OpenAssessment.ResponseView", function() {
}); });
}); });
afterEach(function() {
// Disable autosave polling (if it was enabled)
view.setAutoSaveEnabled(false);
});
it("updates submit/save buttons and save status when response text changes", function() { it("updates submit/save buttons and save status when response text changes", function() {
// Response is blank --> save/submit buttons disabled // Response is blank --> save/submit buttons disabled
view.response(''); view.response('');
view.responseChanged(); view.handleResponseChanged();
expect(view.submitEnabled()).toBe(false); expect(view.submitEnabled()).toBe(false);
expect(view.saveEnabled()).toBe(false); expect(view.saveEnabled()).toBe(false);
expect(view.saveStatus()).toContain('This response has not been saved.'); expect(view.saveStatus()).toContain('This response has not been saved.');
// Response is whitespace --> save/submit buttons disabled // Response is whitespace --> save/submit buttons disabled
view.response(' \n \n '); view.response(' \n \n ');
view.responseChanged(); view.handleResponseChanged();
expect(view.submitEnabled()).toBe(false); expect(view.submitEnabled()).toBe(false);
expect(view.saveEnabled()).toBe(false); expect(view.saveEnabled()).toBe(false);
expect(view.saveStatus()).toContain('This response has not been saved.'); expect(view.saveStatus()).toContain('This response has not been saved.');
// Response is not blank --> submit button enabled // Response is not blank --> submit button enabled
view.response('Test response'); view.response('Test response');
view.responseChanged(); view.handleResponseChanged();
expect(view.submitEnabled()).toBe(true); expect(view.submitEnabled()).toBe(true);
expect(view.saveEnabled()).toBe(true); expect(view.saveEnabled()).toBe(true);
expect(view.saveStatus()).toContain('This response has not been saved.'); expect(view.saveStatus()).toContain('This response has not been saved.');
...@@ -135,14 +140,14 @@ describe("OpenAssessment.ResponseView", function() { ...@@ -135,14 +140,14 @@ describe("OpenAssessment.ResponseView", function() {
// Keep the text the same, but trigger an update // Keep the text the same, but trigger an update
// Should still be saved // Should still be saved
view.response('Lorem ipsum'); view.response('Lorem ipsum');
view.responseChanged(); view.handleResponseChanged();
expect(view.saveEnabled()).toBe(false); expect(view.saveEnabled()).toBe(false);
expect(view.saveStatus()).toContain('saved but not submitted'); expect(view.saveStatus()).toContain('saved but not submitted');
// Change the text // Change the text
// This should cause it to change to unsaved draft // This should cause it to change to unsaved draft
view.response('changed '); view.response('changed ');
view.responseChanged(); view.handleResponseChanged();
expect(view.saveEnabled()).toBe(true); expect(view.saveEnabled()).toBe(true);
expect(view.saveStatus()).toContain('This response has not been saved.'); expect(view.saveStatus()).toContain('This response has not been saved.');
}); });
...@@ -238,7 +243,7 @@ describe("OpenAssessment.ResponseView", function() { ...@@ -238,7 +243,7 @@ describe("OpenAssessment.ResponseView", function() {
// Change the text, then expect the unsaved warning to be enabled. // Change the text, then expect the unsaved warning to be enabled.
view.response('Lorem ipsum'); view.response('Lorem ipsum');
view.responseChanged(); view.handleResponseChanged();
// Expect the unsaved work warning to be enabled // Expect the unsaved work warning to be enabled
expect(view.unsavedWarningEnabled()).toBe(true); expect(view.unsavedWarningEnabled()).toBe(true);
...@@ -247,7 +252,7 @@ describe("OpenAssessment.ResponseView", function() { ...@@ -247,7 +252,7 @@ describe("OpenAssessment.ResponseView", function() {
it("disables the unsaved work warning when the user saves a response", function() { it("disables the unsaved work warning when the user saves a response", function() {
// Change the text, then expect the unsaved warning to be enabled. // Change the text, then expect the unsaved warning to be enabled.
view.response('Lorem ipsum'); view.response('Lorem ipsum');
view.responseChanged(); view.handleResponseChanged();
expect(view.unsavedWarningEnabled()).toBe(true); expect(view.unsavedWarningEnabled()).toBe(true);
// Save the response and expect the unsaved warning to be disabled // Save the response and expect the unsaved warning to be disabled
...@@ -258,11 +263,104 @@ describe("OpenAssessment.ResponseView", function() { ...@@ -258,11 +263,104 @@ describe("OpenAssessment.ResponseView", function() {
it("disables the unsaved work warning when the user submits a response", function() { it("disables the unsaved work warning when the user submits a response", function() {
// Change the text, then expect the unsaved warning to be enabled. // Change the text, then expect the unsaved warning to be enabled.
view.response('Lorem ipsum'); view.response('Lorem ipsum');
view.responseChanged(); view.handleResponseChanged();
expect(view.unsavedWarningEnabled()).toBe(true); expect(view.unsavedWarningEnabled()).toBe(true);
// Submit the response and expect the unsaved warning to be disabled // Submit the response and expect the unsaved warning to be disabled
view.submit(); view.submit();
expect(view.unsavedWarningEnabled()).toBe(false); expect(view.unsavedWarningEnabled()).toBe(false);
}); });
it("autosaves after a user changes a response", function() {
// Disable the autosave delay after changing/saving a response
view.AUTO_SAVE_WAIT = -1;
// Check that the problem is initially unsaved
expect(view.saveStatus()).toContain('not been saved');
// Change the response
view.response('Lorem ipsum');
view.handleResponseChanged();
// Usually autosave would be called by a timer.
// For testing purposes, we disable the timer
// and trigger the autosave manually.
view.autoSave();
// Expect that the problem has been saved
expect(view.saveStatus()).toContain('saved but not submitted');
// Expect that the unsaved warning is disabled
expect(view.unsavedWarningEnabled()).toBe(false);
});
it("schedules autosave polling", function() {
runs(function() {
// Spy on the autosave call
spyOn(view, 'autoSave').andCallThrough();
// Enable autosave with a short poll interval
view.AUTO_SAVE_POLL_INTERVAL = 1;
view.setAutoSaveEnabled(true);
});
// Wait for autosave to be called
waitsFor(function() {
return view.autoSave.callCount > 0;
}, "AutoSave should have been called", 5000);
});
it("stops autosaving after a save error", function() {
// Disable the autosave delay after changing/saving a response
view.AUTO_SAVE_WAIT = -1;
// Simulate a server error
var errorPromise = $.Deferred(function(defer) {
defer.rejectWith(this, ["This response could not be saved"]);
}).promise();
spyOn(server, 'save').andCallFake(function() { return errorPromise; });
// Change the response and save it
view.response('Lorem ipsum');
view.handleResponseChanged();
view.save();
// Expect that the save status shows an error
expect(view.saveStatus()).toContain('Error');
// Autosave (usually would be called by a timer, but we disable
// that for testing purposes).
view.autoSave();
// The server save shoulde have been called just once
// (autosave didn't call it).
expect(server.save.callCount).toEqual(1);
});
it("waits after user changes a response to autosave", function() {
// Set a long autosave delay
view.AUTO_SAVE_WAIT = 900000;
// Change the response
view.response('Lorem ipsum');
view.handleResponseChanged();
// Autosave
view.autoSave();
// Expect that the problem is still unsaved
expect(view.saveStatus()).toContain('not been saved');
});
it("does not autosave if a user hasn't changed the response", function() {
// Disable the autosave delay after changing/saving a response
view.AUTO_SAVE_WAIT = -1;
// Autosave (usually would be called by a timer, but we disable
// that for testing purposes).
view.autoSave();
// Since we haven't made any changes, the response should still be unsaved.
expect(view.saveStatus()).toContain('not been saved');
});
}); });
...@@ -14,10 +14,21 @@ OpenAssessment.ResponseView = function(element, server, baseView) { ...@@ -14,10 +14,21 @@ OpenAssessment.ResponseView = function(element, server, baseView) {
this.server = server; this.server = server;
this.baseView = baseView; this.baseView = baseView;
this.savedResponse = ""; this.savedResponse = "";
this.lastChangeTime = Date.now();
this.errorOnLastSave = false;
this.autoSaveTimerId = null;
}; };
OpenAssessment.ResponseView.prototype = { OpenAssessment.ResponseView.prototype = {
// Milliseconds between checks for whether we should autosave.
AUTO_SAVE_POLL_INTERVAL: 2000,
// Required delay after the user changes a response or a save occurs
// before we can autosave.
AUTO_SAVE_WAIT: 2000,
/** /**
Load the response (submission) view. Load the response (submission) view.
**/ **/
...@@ -28,6 +39,7 @@ OpenAssessment.ResponseView.prototype = { ...@@ -28,6 +39,7 @@ OpenAssessment.ResponseView.prototype = {
// Load the HTML and install event handlers // Load the HTML and install event handlers
$('#openassessment__response', view.element).replaceWith(html); $('#openassessment__response', view.element).replaceWith(html);
view.installHandlers(); view.installHandlers();
view.setAutoSaveEnabled(true);
} }
).fail(function(errMsg) { ).fail(function(errMsg) {
view.baseView.showLoadError('response'); view.baseView.showLoadError('response');
...@@ -46,7 +58,7 @@ OpenAssessment.ResponseView.prototype = { ...@@ -46,7 +58,7 @@ OpenAssessment.ResponseView.prototype = {
// Install change handler for textarea (to enable submission button) // Install change handler for textarea (to enable submission button)
this.savedResponse = this.response(); this.savedResponse = this.response();
var handleChange = function(eventData) { view.responseChanged(); }; var handleChange = function(eventData) { view.handleResponseChanged(); };
sel.find('#submission__answer__value').on('change keyup drop paste', handleChange); sel.find('#submission__answer__value').on('change keyup drop paste', handleChange);
// Install a click handler for submission // Install a click handler for submission
...@@ -69,6 +81,29 @@ OpenAssessment.ResponseView.prototype = { ...@@ -69,6 +81,29 @@ OpenAssessment.ResponseView.prototype = {
}, },
/** /**
Enable or disable autosave polling.
Args:
enabled (boolean): If true, start polling for whether we need to autosave.
Otherwise, stop polling.
**/
setAutoSaveEnabled: function(enabled) {
if (enabled) {
if (this.autoSaveTimerId === null) {
this.autoSaveTimerId = setInterval(
$.proxy(this.autoSave, this),
this.AUTO_SAVE_POLL_INTERVAL
);
}
}
else {
if (this.autoSaveTimerId !== null) {
clearInterval(this.autoSaveTimerId);
}
}
},
/**
Enable/disable the submit button. Enable/disable the submit button.
Check that whether the submit button is enabled. Check that whether the submit button is enabled.
...@@ -88,7 +123,7 @@ OpenAssessment.ResponseView.prototype = { ...@@ -88,7 +123,7 @@ OpenAssessment.ResponseView.prototype = {
if (typeof enabled === 'undefined') { if (typeof enabled === 'undefined') {
return !sel.hasClass('is--disabled'); return !sel.hasClass('is--disabled');
} else { } else {
sel.toggleClass('is--disabled', !enabled) sel.toggleClass('is--disabled', !enabled);
} }
}, },
...@@ -193,29 +228,69 @@ OpenAssessment.ResponseView.prototype = { ...@@ -193,29 +228,69 @@ OpenAssessment.ResponseView.prototype = {
} }
}, },
/**
Check whether the response text has changed since the last save.
Returns: boolean
**/
responseChanged: function() {
var currentResponse = $.trim(this.response());
var savedResponse = $.trim(this.savedResponse);
return savedResponse !== currentResponse;
},
/**
Automatically save the user's response if certain conditions are met.
Usually, this would be called by a timer (see `setAutoSaveEnabled()`).
For testing purposes, it's useful to disable the timer
and call this function synchronously.
**/
autoSave: function() {
var timeSinceLastChange = Date.now() - this.lastChangeTime;
// We only autosave if the following conditions are met:
// (1) The response has changed. We don't need to keep saving the same response.
// (2) Sufficient time has passed since the user last made a change to the response.
// We don't want to save a response while the user is in the middle of typing.
// (3) No errors occurred on the last save. We don't want to keep refreshing
// the error message in the UI. (The user can still retry the save manually).
if (this.responseChanged() && timeSinceLastChange > this.AUTO_SAVE_WAIT && !this.errorOnLastSave) {
this.save();
}
},
/** /**
Enable/disable the submission and save buttons based on whether Enable/disable the submission and save buttons based on whether
the user has entered a response. the user has entered a response.
**/ **/
responseChanged: function() { handleResponseChanged: function() {
// Enable the save/submit button only for non-blank responses // Enable the save/submit button only for non-blank responses
var currentResponse = $.trim(this.response()); var isBlank = ($.trim(this.response()) !== '');
var isBlank = (currentResponse !== '');
this.submitEnabled(isBlank); this.submitEnabled(isBlank);
// Update the save button, save status, and "unsaved changes" warning // Update the save button, save status, and "unsaved changes" warning
// only if the response has changed // only if the response has changed
if ($.trim(this.savedResponse) !== currentResponse) { if (this.responseChanged()) {
this.saveEnabled(isBlank); this.saveEnabled(isBlank);
this.saveStatus(gettext('This response has not been saved.')); this.saveStatus(gettext('This response has not been saved.'));
this.unsavedWarningEnabled(true); this.unsavedWarningEnabled(true);
} }
// Record the current time (used for autosave)
this.lastChangeTime = Date.now();
}, },
/** /**
Save a response without submitting it. Save a response without submitting it.
**/ **/
save: function() { save: function() {
// If there were errors on previous calls to save, forget
// about them for now. If an error occurs on *this* save,
// we'll set this back to true in the error handler.
this.errorOnLastSave = false;
// Update the save status and error notifications // Update the save status and error notifications
this.saveStatus(gettext('Saving...')); this.saveStatus(gettext('Saving...'));
this.baseView.toggleActionError('save', null); this.baseView.toggleActionError('save', null);
...@@ -240,6 +315,11 @@ OpenAssessment.ResponseView.prototype = { ...@@ -240,6 +315,11 @@ OpenAssessment.ResponseView.prototype = {
}).fail(function(errMsg) { }).fail(function(errMsg) {
view.saveStatus(gettext('Error')); view.saveStatus(gettext('Error'));
view.baseView.toggleActionError('save', errMsg); view.baseView.toggleActionError('save', errMsg);
// Remember that an error occurred
// so we can disable autosave
//(avoids repeatedly refreshing the error message)
view.errorOnLastSave = true;
}); });
}, },
......
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