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">
|
<div class="exam-question-fragment">
|
||||||
<form method="POST" class="post-form"
|
<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-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-target="#question-fragment" hx-swap="innerHTML">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.non_field_errors }}
|
{{ form.non_field_errors }}
|
||||||
|
|
||||||
@@ -60,13 +60,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if next %}
|
{% 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 -->
|
<!-- 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-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>
|
hx-target="#question-fragment" hx-swap="innerHTML">Skip</button>
|
||||||
<!-- Fallback link for non-HTMX clients: simple navigation without saving -->
|
<!-- 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 %}
|
{% else %}
|
||||||
{% if not exam.publish_results %}
|
{% 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>
|
<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) {
|
if (el == li_el.find('input').get(0).checked) {
|
||||||
li_el.addClass('answer-correct');
|
li_el.addClass('answer-correct');
|
||||||
} else {
|
} else {
|
||||||
li_el.addClass('answer-incorrect');
|
li_el.addClass('answer-incorrect');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -130,10 +130,99 @@
|
|||||||
$('#menu-list').append(qbutton);
|
$('#menu-list').append(qbutton);
|
||||||
}
|
}
|
||||||
|
|
||||||
$('button.question-menu-item').on('click', (e) => {
|
// Template URL for loading a fragment — use sk=0 as placeholder to replace later
|
||||||
document.getElementById('goto-button').value = e.currentTarget.dataset.qn;
|
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 %}`;
|
||||||
$('#goto-button').click();
|
|
||||||
});
|
// 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
|
// Skip confirmation: if the user's current selections differ from
|
||||||
// the saved answer, prompt before allowing the HTMX skip to proceed.
|
// the saved answer, prompt before allowing the HTMX skip to proceed.
|
||||||
@@ -175,12 +264,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(changed){
|
if(changed){
|
||||||
const ok = confirm('You have unsaved changes. Skip to the next question without saving?');
|
evt.preventDefault(); evt.stopImmediatePropagation();
|
||||||
if(!ok){
|
openSaveSkipModal('You have unsaved changes. Skip to the next question without saving?').then(function(choice){
|
||||||
// prevent HTMX from handling the click
|
if(choice === 'skip'){
|
||||||
evt.preventDefault(); evt.stopImmediatePropagation();
|
// trigger the HTMX GET on the button by invoking its click handler programmatically
|
||||||
return false;
|
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
|
// allow the HTMX attribute on the button to proceed
|
||||||
}catch(e){ console && console.error && console.error(e); }
|
}catch(e){ console && console.error && console.error(e); }
|
||||||
@@ -188,4 +293,60 @@
|
|||||||
})();
|
})();
|
||||||
})();
|
})();
|
||||||
</script>
|
</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>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user