From 2cc3c59e88ff35dfcc95562319a179a4a910c3ab Mon Sep 17 00:00:00 2001 From: Pan Luo Date: Fri, 10 Jul 2026 21:48:58 -0700 Subject: [PATCH] Periodically autosave answers on timed gateway tests Submit the gwquiz form in the background as a preview request so the server saves the last answers, protecting students on timed tests from losing work to a crash or dropped connection. Disabled by default; enabled by setting $gatewayAutosaveInterval (seconds) in the course environment. Saves are skipped when the answers have not changed and when an instructor is acting as another user. --- conf/defaults.config | 6 ++ htdocs/js/GatewayQuiz/gateway.js | 67 +++++++++++++++++++ .../ContentGenerator/GatewayQuiz.html.ep | 9 +++ 3 files changed, 82 insertions(+) diff --git a/conf/defaults.config b/conf/defaults.config index d2bbfaf56c..9b8992fcc7 100644 --- a/conf/defaults.config +++ b/conf/defaults.config @@ -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 ################################################################################ diff --git a/htdocs/js/GatewayQuiz/gateway.js b/htdocs/js/GatewayQuiz/gateway.js index ee4024bd7f..705fa10c31 100644 --- a/htdocs/js/GatewayQuiz/gateway.js +++ b/htdocs/js/GatewayQuiz/gateway.js @@ -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)); + } })(); diff --git a/templates/ContentGenerator/GatewayQuiz.html.ep b/templates/ContentGenerator/GatewayQuiz.html.ep index 26a0e56c6c..82ac7412b8 100644 --- a/templates/ContentGenerator/GatewayQuiz.html.ep +++ b/templates/ContentGenerator/GatewayQuiz.html.ep @@ -693,6 +693,15 @@ <%= $jumpLinks->() =%>
% + % if ($ce->{gatewayAutosaveInterval}) { +
"> +
+ % } + %
<%= submit_button maketext('Preview Test'), name => 'previewAnswers', class => 'btn btn-primary mb-1' =%> % if ($c->{can}{checkAnswersNextTime}