Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions conf/defaults.config
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,12 @@ $practiceUserPrefix = "practice";
# after the official due date during which we'll still grade the test
$gatewayGracePeriod = 120;

# If $gatewayAutosaveInterval is set to a positive number of seconds, then answers on timed
# gateway tests are automatically saved in the background at approximately that interval,
# and only when the answers have changed since the last save. Note that each automatic save
# costs a preview render on the server. Set to 0 to disable automatic saving.
$gatewayAutosaveInterval = 0;

################################################################################
# Session Management
################################################################################
Expand Down
67 changes: 67 additions & 0 deletions htdocs/js/GatewayQuiz/gateway.js
Original file line number Diff line number Diff line change
Expand Up @@ -348,4 +348,71 @@
const bsToast = new bootstrap.Toast(toast, { delay: 5000 });
bsToast.show();
});

// Periodically save answers on timed tests by submitting the form in the background as a preview request. The
// server saves the last answers for preview requests, so this ensures that the work of a student whose session is
// interrupted (for example, by a computer crash or dropped connection) is not lost. This is disabled by default,
// and is enabled by setting $gatewayAutosaveInterval in the course environment. If enabled, the template provides
// the gw-autosave-status element with the configured interval. This is not done when an instructor is acting as
// another user, so that the student's stored answers are not unintentionally modified.
const autosaveStatus = document.getElementById('gw-autosave-status');
const autosaveInterval = 1000 * (parseInt(autosaveStatus?.dataset.interval ?? '') || 0);
if (timerDiv && !timerDiv.dataset.acting && autosaveInterval > 0 && document.gwquiz.elements.previewAnswers) {
const serializeAnswers = () => {
const formData = new URLSearchParams(new FormData(document.gwquiz));
// Submit buttons are not included in the FormData object, so the previewAnswers parameter must be
// added. The server only checks that the parameter has a true value to treat the request as a preview.
// Since no submit button parameters are included, the request cannot grade the test or change pages.
formData.set('previewAnswers', '1');
return formData;
};

let lastSavedAnswers = serializeAnswers().toString();

const autosave = async () => {
const formData = serializeAnswers();

// Only save if the answers have changed since the last successful save.
if (formData.toString() === lastSavedAnswers) return;

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);

const response = await fetch(document.gwquiz.getAttribute('action'), {
method: 'post',
mode: 'same-origin',
body: formData,
signal: controller.signal
}).catch(() => {
/* Errors are handled below. */
});

clearTimeout(timeoutId);

let saved = false;
if (response && response.ok) {
const doc = new DOMParser().parseFromString(await response.text(), 'text/html');
// If the session was lost, then the response is a login page that does not contain the gwquiz form.
if (doc.querySelector('form[name="gwquiz"]')) saved = true;
}

if (saved) {
lastSavedAnswers = formData.toString();
autosaveStatus.textContent = autosaveStatus.dataset.successMessage.replace(
'{time}',
new Date().toLocaleTimeString()
);
autosaveStatus.classList.remove('d-none', 'alert', 'alert-danger');
autosaveStatus.classList.add('small', 'text-muted');
} else {
autosaveStatus.textContent = autosaveStatus.dataset.errorMessage;
autosaveStatus.classList.remove('d-none', 'small', 'text-muted');
autosaveStatus.classList.add('alert', 'alert-danger');
}
};

// Randomize the interval by up to a third to stagger the autosave load from a class full of students
// that all start a test at the same time.
setInterval(autosave, autosaveInterval + Math.floor((Math.random() * autosaveInterval) / 3));
}
})();
9 changes: 9 additions & 0 deletions templates/ContentGenerator/GatewayQuiz.html.ep
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,15 @@
<%= $jumpLinks->() =%>
<div class="gwDivider"></div>
%
% if ($ce->{gatewayAutosaveInterval}) {
<div id="gw-autosave-status" class="d-none" role="status" aria-live="polite"
data-interval="<%= $ce->{gatewayAutosaveInterval} %>"
data-success-message="<%= maketext('Answers automatically saved at [_1].', '{time}') %>"
data-error-message="<%= maketext('Automatic saving of your answers failed. Your latest answers may not '
. 'be saved. Click "Preview Test" to save them and confirm you are still signed in.') %>">
</div>
% }
%
<div class="submit-buttons-container col-12 mb-2">
<%= submit_button maketext('Preview Test'), name => 'previewAnswers', class => 'btn btn-primary mb-1' =%>
% if ($c->{can}{checkAnswersNextTime}
Expand Down