Enhance exam navigation by adding a confirmation modal for unsaved changes when skipping questions.
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user