Commit 05f13bdd by Will Daly

Implemented autosave

Fixed a bug in which whitespace was added to saved responses on page reload.
parent 2378c165
......@@ -62,9 +62,7 @@
id="submission__answer__value"
placeholder=""
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>
</li>
</ol>
......
......@@ -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() {
// Response is blank --> save/submit buttons disabled
view.response('');
view.responseChanged();
view.handleResponseChanged();
expect(view.submitEnabled()).toBe(false);
expect(view.saveEnabled()).toBe(false);
expect(view.saveStatus()).toContain('This response has not been saved.');
// Response is whitespace --> save/submit buttons disabled
view.response(' \n \n ');
view.responseChanged();
view.handleResponseChanged();
expect(view.submitEnabled()).toBe(false);
expect(view.saveEnabled()).toBe(false);
expect(view.saveStatus()).toContain('This response has not been saved.');
// Response is not blank --> submit button enabled
view.response('Test response');
view.responseChanged();
view.handleResponseChanged();
expect(view.submitEnabled()).toBe(true);
expect(view.saveEnabled()).toBe(true);
expect(view.saveStatus()).toContain('This response has not been saved.');
......@@ -135,14 +140,14 @@ describe("OpenAssessment.ResponseView", function() {
// Keep the text the same, but trigger an update
// Should still be saved
view.response('Lorem ipsum');
view.responseChanged();
view.handleResponseChanged();
expect(view.saveEnabled()).toBe(false);
expect(view.saveStatus()).toContain('saved but not submitted');
// Change the text
// This should cause it to change to unsaved draft
view.response('changed ');
view.responseChanged();
view.handleResponseChanged();
expect(view.saveEnabled()).toBe(true);
expect(view.saveStatus()).toContain('This response has not been saved.');
});
......@@ -238,7 +243,7 @@ describe("OpenAssessment.ResponseView", function() {
// Change the text, then expect the unsaved warning to be enabled.
view.response('Lorem ipsum');
view.responseChanged();
view.handleResponseChanged();
// Expect the unsaved work warning to be enabled
expect(view.unsavedWarningEnabled()).toBe(true);
......@@ -247,7 +252,7 @@ describe("OpenAssessment.ResponseView", 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.
view.response('Lorem ipsum');
view.responseChanged();
view.handleResponseChanged();
expect(view.unsavedWarningEnabled()).toBe(true);
// Save the response and expect the unsaved warning to be disabled
......@@ -258,11 +263,104 @@ describe("OpenAssessment.ResponseView", 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.
view.response('Lorem ipsum');
view.responseChanged();
view.handleResponseChanged();
expect(view.unsavedWarningEnabled()).toBe(true);
// Submit the response and expect the unsaved warning to be disabled
view.submit();
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) {
this.server = server;
this.baseView = baseView;
this.savedResponse = "";
this.lastChangeTime = Date.now();
this.errorOnLastSave = false;
this.autoSaveTimerId = null;
};
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.
**/
......@@ -28,6 +39,7 @@ OpenAssessment.ResponseView.prototype = {
// Load the HTML and install event handlers
$('#openassessment__response', view.element).replaceWith(html);
view.installHandlers();
view.setAutoSaveEnabled(true);
}
).fail(function(errMsg) {
view.baseView.showLoadError('response');
......@@ -46,7 +58,7 @@ OpenAssessment.ResponseView.prototype = {
// Install change handler for textarea (to enable submission button)
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);
// Install a click handler for submission
......@@ -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.
Check that whether the submit button is enabled.
......@@ -88,7 +123,7 @@ OpenAssessment.ResponseView.prototype = {
if (typeof enabled === 'undefined') {
return !sel.hasClass('is--disabled');
} else {
sel.toggleClass('is--disabled', !enabled)
sel.toggleClass('is--disabled', !enabled);
}
},
......@@ -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
the user has entered a response.
**/
responseChanged: function() {
handleResponseChanged: function() {
// Enable the save/submit button only for non-blank responses
var currentResponse = $.trim(this.response());
var isBlank = (currentResponse !== '');
var isBlank = ($.trim(this.response()) !== '');
this.submitEnabled(isBlank);
// Update the save button, save status, and "unsaved changes" warning
// only if the response has changed
if ($.trim(this.savedResponse) !== currentResponse) {
if (this.responseChanged()) {
this.saveEnabled(isBlank);
this.saveStatus(gettext('This response has not been saved.'));
this.unsavedWarningEnabled(true);
}
// Record the current time (used for autosave)
this.lastChangeTime = Date.now();
},
/**
Save a response without submitting it.
**/
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
this.saveStatus(gettext('Saving...'));
this.baseView.toggleActionError('save', null);
......@@ -240,6 +315,11 @@ OpenAssessment.ResponseView.prototype = {
}).fail(function(errMsg) {
view.saveStatus(gettext('Error'));
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