Enhance exam navigation by adding a confirmation modal for unsaved changes when skipping questions.

This commit is contained in:
Ross
2026-01-05 12:32:14 +00:00
parent f44f3bbcfe
commit e4cfb1f782
@@ -1,7 +1,7 @@
<div class="exam-question-fragment">
<form method="POST" class="post-form"
hx-post="{% if cid %}{% url 'physics:exam_take' pk=exam.pk sk=pos cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_user' pk=exam.pk sk=pos %}{% endif %}"
hx-target="#question-fragment" hx-swap="innerHTML">
hx-post="{% if cid %}{% url 'physics:exam_take' pk=exam.pk sk=pos cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_user' pk=exam.pk sk=pos %}{% endif %}"
hx-target="#question-fragment" hx-swap="innerHTML">
{% csrf_token %}
{{ form.non_field_errors }}
@@ -60,13 +60,13 @@
{% endif %}
{% if next %}
<button type="submit" name="next" class="save btn btn-secondary" title="Click to save your answer(s) and go to the next question">Next</button>
<button type="submit" name="next" class="save btn btn-secondary" title="Click to save your answer(s) and go to the next question">Next</button>
<!-- Skip: load the next question fragment without submitting the form -->
<button type="button" id="skip-button" class="btn btn-outline-secondary ms-2" title="Skip to next question without saving"
<button type="button" id="skip-button" class="btn btn-outline-secondary ms-2" title="Skip to next question without saving"
hx-get="{% if cid %}{% url 'physics:exam_take_fragment' pk=exam.pk sk=pos|add:1 cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_fragment_user' pk=exam.pk sk=pos|add:1 %}{% endif %}"
hx-target="#question-fragment" hx-swap="innerHTML">Skip</button>
<!-- Fallback link for non-HTMX clients: simple navigation without saving -->
<a class="d-none" href="{% if cid %}{% url 'physics:exam_take' pk=exam.pk sk=pos|add:1 cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_user' pk=exam.pk sk=pos|add:1 %}{% endif %}"></a>
<a class="d-none" href="{% if cid %}{% url 'physics:exam_take' pk=exam.pk sk=pos|add:1 cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_user' pk=exam.pk sk=pos|add:1 %}{% endif %}"></a>
{% else %}
{% if not exam.publish_results %}
<button type="submit" name="save" class="save btn btn-default" title="Click to save your current answer(s)">Save</button>
@@ -114,7 +114,7 @@
if (el == li_el.find('input').get(0).checked) {
li_el.addClass('answer-correct');
} else {
li_el.addClass('answer-incorrect');
li_el.addClass('answer-incorrect');
}
});
@@ -130,10 +130,99 @@
$('#menu-list').append(qbutton);
}
$('button.question-menu-item').on('click', (e) => {
document.getElementById('goto-button').value = e.currentTarget.dataset.qn;
$('#goto-button').click();
});
// Template URL for loading a fragment — use sk=0 as placeholder to replace later
const fragUrlTemplate = `{% if cid %}{% url 'physics:exam_take_fragment' pk=exam.pk sk=0 cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_fragment_user' pk=exam.pk sk=0 %}{% endif %}`;
// Use event delegation on the menu list to reliably catch clicks
(function(){
const menu = document.getElementById('menu-list');
if(!menu) return;
if(menu.dataset.delegateBound) return; // idempotent
menu.dataset.delegateBound = '1';
menu.addEventListener('click', function(e){
const btn = e.target.closest && e.target.closest('.question-menu-item');
if(!btn) return;
const destIndex = btn.dataset.qn;
try{
const allFalse = Array.from(document.querySelectorAll('ol.physics-answer-list li')).every(li => {
const inp = li.querySelector('input');
return !(inp && inp.checked);
});
if(allFalse){
const save = confirm('All answers are false. OK to save these answers, Cancel to skip without saving.');
if(save){
const form = document.querySelector('.post-form');
if(form){
const tmp = document.createElement('button'); tmp.type='submit'; tmp.name='save'; tmp.style.display='none'; form.appendChild(tmp);
const onSaved = function(){ document.removeEventListener('saved', onSaved); document.getElementById('goto-button').value = destIndex; $('#goto-button').click(); };
document.addEventListener('saved', onSaved);
tmp.click();
setTimeout(function(){ document.removeEventListener('saved', onSaved); document.getElementById('goto-button').value = destIndex; $('#goto-button').click(); }, 1500);
return;
}
}
// Skip without saving: load fragment directly via HTMX and update URL
try{
const url = fragUrlTemplate.replace('/0/', '/' + destIndex + '/');
if(window.htmx && typeof htmx.ajax === 'function'){
htmx.ajax('GET', url, {target: '#question-fragment', swap: 'innerHTML'});
} else {
window.location.href = url;
}
// update browser URL to match the question destination
try{
const pageUrl = `{% if cid %}{% url 'physics:exam_take' pk=exam.pk sk=0 cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_user' pk=exam.pk sk=0 %}{% endif %}`.replace('/0/', '/' + destIndex + '/');
history.replaceState(null, '', pageUrl);
}catch(e){}
}catch(e){
document.getElementById('goto-button').value = destIndex;
$('#goto-button').click();
}
return;
}
}catch(ex){ console && console.error && console.error(ex); }
// Default: perform the form-based navigation (which will save)
document.getElementById('goto-button').value = destIndex;
$('#goto-button').click();
});
})();
// Intercept Overview button to offer Save or Skip when all answers false
(function(){
const overview = document.getElementById('overview-button');
if(!overview) return;
const overviewUrl = `{% if cid %}{% url 'physics:exam_take_overview' pk=exam.pk cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_overview_user' pk=exam.pk %}{% endif %}`;
overview.addEventListener('click', function(evt){
try{
const allFalse = Array.from(document.querySelectorAll('ol.physics-answer-list li')).every(li => {
const inp = li.querySelector('input');
return !(inp && inp.checked);
});
if(!allFalse) return; // allow normal submit (which will save)
// prevent the default submit while we prompt
evt.preventDefault(); evt.stopImmediatePropagation();
openSaveSkipModal('All answers are false. Save these answers or skip without saving?').then(function(choice){
if(choice === 'save'){
const form = document.querySelector('.post-form');
if(form){
const tmp = document.createElement('button'); tmp.type='submit'; tmp.name='save'; tmp.style.display='none'; form.appendChild(tmp);
const onSaved = function(){ document.removeEventListener('saved', onSaved); window.location = overviewUrl; };
document.addEventListener('saved', onSaved);
tmp.click();
setTimeout(function(){ document.removeEventListener('saved', onSaved); window.location = overviewUrl; }, 1500);
return;
}
}
// skip or cancel -> navigate without saving
window.location = overviewUrl;
}).catch(function(){ window.location = overviewUrl; });
}catch(ex){ console && console.error && console.error(ex); }
});
})();
// Skip confirmation: if the user's current selections differ from
// the saved answer, prompt before allowing the HTMX skip to proceed.
@@ -175,12 +264,28 @@
}
if(changed){
const ok = confirm('You have unsaved changes. Skip to the next question without saving?');
if(!ok){
// prevent HTMX from handling the click
evt.preventDefault(); evt.stopImmediatePropagation();
return false;
}
evt.preventDefault(); evt.stopImmediatePropagation();
openSaveSkipModal('You have unsaved changes. Skip to the next question without saving?').then(function(choice){
if(choice === 'skip'){
// trigger the HTMX GET on the button by invoking its click handler programmatically
const hxGet = skip.getAttribute('hx-get');
if(hxGet){
if(window.htmx && typeof htmx.ajax === 'function'){
htmx.ajax('GET', hxGet, {target: '#question-fragment', swap: 'innerHTML'});
} else {
window.location.href = hxGet;
}
}
} else if(choice === 'save'){
const form = document.querySelector('.post-form');
if(form){
const tmp = document.createElement('button'); tmp.type='submit'; tmp.name='save'; tmp.style.display='none'; form.appendChild(tmp);
tmp.click();
}
}
// cancel => do nothing
}).catch(function(){/* ignore */});
return false;
}
// allow the HTMX attribute on the button to proceed
}catch(e){ console && console.error && console.error(e); }
@@ -188,4 +293,60 @@
})();
})();
</script>
<!-- Save / Skip Modal -->
<div class="modal fade" id="saveSkipModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Confirm</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p id="saveSkipModalMessage">Are you sure?</p>
</div>
<div class="modal-footer">
<button type="button" id="saveSkipModalSave" class="btn btn-primary">Save</button>
<button type="button" id="saveSkipModalSkip" class="btn btn-secondary">Skip</button>
<button type="button" id="saveSkipModalCancel" class="btn btn-link" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
</div>
<script>
// Modal helper: returns a Promise resolved with 'save'|'skip'|'cancel'
function openSaveSkipModal(message){
return new Promise(function(resolve, reject){
try{
let modalEl = document.getElementById('saveSkipModal');
if(!modalEl) return resolve('cancel');
modalEl.querySelector('#saveSkipModalMessage').textContent = message || '';
const bsModal = new bootstrap.Modal(modalEl, { backdrop: 'static' });
function cleanup(){
saveBtn.removeEventListener('click', onSave);
skipBtn.removeEventListener('click', onSkip);
cancelBtn.removeEventListener('click', onCancel);
modalEl.removeEventListener('hidden.bs.modal', onHidden);
}
function onSave(){ cleanup(); bsModal.hide(); resolve('save'); }
function onSkip(){ cleanup(); bsModal.hide(); resolve('skip'); }
function onCancel(){ cleanup(); bsModal.hide(); resolve('cancel'); }
function onHidden(){ cleanup(); resolve('cancel'); }
const saveBtn = modalEl.querySelector('#saveSkipModalSave');
const skipBtn = modalEl.querySelector('#saveSkipModalSkip');
const cancelBtn = modalEl.querySelector('#saveSkipModalCancel');
saveBtn.addEventListener('click', onSave);
skipBtn.addEventListener('click', onSkip);
cancelBtn.addEventListener('click', onCancel);
modalEl.addEventListener('hidden.bs.modal', onHidden);
bsModal.show();
}catch(e){ reject(e); }
});
}
</script>
</div>