280 lines
15 KiB
HTML
280 lines
15 KiB
HTML
{% include "generic/exam_candidate_headers.html" %}
|
|
|
|
<div class="card mb-2 bg-dark text-light border-0">
|
|
<div class="card-body py-2">
|
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
|
<div>
|
|
<h5 class="card-title mb-0">CID Users</h5>
|
|
{% if groups %}
|
|
<div class="small text-muted mt-1">Users from the following groups are available:
|
|
{% for group in groups %}
|
|
<a href='{{group.get_absolute_url}}' class="badge bg-light text-dark border ms-1">{{group}}</a>
|
|
{% endfor %}
|
|
</div>
|
|
{% else %}
|
|
<div class="small text-muted mt-1">This exam is not associated with any user groups. <a href="{% url exam.app_name|add:':exam_groups_edit' exam.pk %}">Edit and add a group</a> to enable user management.</div>
|
|
{% endif %}
|
|
</div>
|
|
<div class="btn-group">
|
|
{% if current_cid_users or available_cid_users %}
|
|
<button class="btn btn-sm btn-outline-light toggle-all-btn">Toggle all</button>
|
|
{% endif %}
|
|
<button class="btn btn-sm btn-outline-success add-all-btn" data-bulk-url="{% url exam.app_name|add:':exam_cid_bulk_edit' exam.pk %}">Add all</button>
|
|
<button class="btn btn-sm btn-outline-danger remove-all-btn" data-bulk-url="{% url exam.app_name|add:':exam_cid_bulk_edit' exam.pk %}">Remove all</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="d-flex align-items-center mb-2">
|
|
<div class="me-auto small text-muted">Sort by:</div>
|
|
<div class="btn-group btn-group-sm" role="group" aria-label="Sort">
|
|
<button type="button" class="btn btn-sm btn-outline-light sort-btn" data-key="cid">CID <span class="sort-caret"></span></button>
|
|
<button type="button" class="btn btn-sm btn-outline-light sort-btn" data-key="name">Name <span class="sort-caret"></span></button>
|
|
<button type="button" class="btn btn-sm btn-outline-light sort-btn" data-key="email">Email <span class="sort-caret"></span></button>
|
|
</div>
|
|
</div>
|
|
|
|
{% include 'generic/partials/cid_user_list.html' with exam=exam current_cid_users=current_cid_users available_cid_users=available_cid_users %}
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function(){
|
|
// Bulk Add / Remove using a single HTMX POST
|
|
const bulkForm = document.createElement('form');
|
|
bulkForm.id = 'bulk-cid-form';
|
|
bulkForm.className = 'd-none';
|
|
bulkForm.setAttribute('hx-target', '#cid-users-wrap');
|
|
bulkForm.setAttribute('hx-swap', 'outerHTML');
|
|
const bulkInput = document.createElement('input'); bulkInput.type='hidden'; bulkInput.name='bulk_pks'; bulkInput.id='bulk_pks_input'; bulkForm.appendChild(bulkInput);
|
|
const bulkAddInput = document.createElement('input'); bulkAddInput.type='hidden'; bulkAddInput.name='add'; bulkAddInput.id='bulk_add_input'; bulkForm.appendChild(bulkAddInput);
|
|
// include csrf token
|
|
const csrfTokenInput = document.createElement('input'); csrfTokenInput.type='hidden'; csrfTokenInput.name='csrfmiddlewaretoken'; csrfTokenInput.value='{{ csrf_token }}'; bulkForm.appendChild(csrfTokenInput);
|
|
document.body.appendChild(bulkForm);
|
|
|
|
function setLoading(btn, loading){
|
|
if(!btn) return;
|
|
try{
|
|
if(loading){
|
|
btn.dataset._origDisabled = btn.disabled ? '1' : '0';
|
|
btn.disabled = true;
|
|
const spinner = document.createElement('span');
|
|
spinner.className = 'btn-spinner spinner-border spinner-border-sm ms-2';
|
|
spinner.setAttribute('role','status');
|
|
spinner.setAttribute('aria-hidden','true');
|
|
btn.appendChild(spinner);
|
|
} else {
|
|
if(typeof btn.dataset._origDisabled !== 'undefined'){
|
|
btn.disabled = btn.dataset._origDisabled === '1';
|
|
delete btn.dataset._origDisabled;
|
|
} else {
|
|
btn.disabled = false;
|
|
}
|
|
const s = btn.querySelector('.btn-spinner'); if(s) s.remove();
|
|
}
|
|
}catch(e){ /* ignore */ }
|
|
}
|
|
|
|
function submitBulk(url, pks, add, triggeringButton){
|
|
// prefer HTMX if available
|
|
if (window.htmx && typeof htmx.submit === 'function'){
|
|
if(triggeringButton) setLoading(triggeringButton, true);
|
|
bulkForm.setAttribute('hx-post', url);
|
|
bulkInput.value = JSON.stringify(pks);
|
|
if (add === null) {
|
|
bulkAddInput.disabled = true;
|
|
} else {
|
|
bulkAddInput.disabled = false;
|
|
bulkAddInput.value = add ? 'true' : 'false';
|
|
}
|
|
htmx.submit(bulkForm);
|
|
return;
|
|
}
|
|
|
|
// fallback: use fetch and swap the list manually
|
|
if(triggeringButton) setLoading(triggeringButton, true);
|
|
const fd = new FormData();
|
|
fd.append('bulk_pks', JSON.stringify(pks));
|
|
if (add !== null) fd.append('add', add ? 'true' : 'false');
|
|
const csrf = document.querySelector('input[name="csrfmiddlewaretoken"]').value;
|
|
const headers = { 'X-CSRFToken': csrf };
|
|
fetch(url, { method: 'POST', body: fd, headers: headers, credentials: 'same-origin' })
|
|
.then(resp => resp.text().then(text => ({ resp, text })))
|
|
.then(({ resp, text }) => {
|
|
// swap the list
|
|
try{
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(text, 'text/html');
|
|
// Prefer replacing the whole wrapper (contains counts + list)
|
|
const newWrap = doc.querySelector('#cid-users-wrap');
|
|
if (newWrap){
|
|
const oldWrap = document.querySelector('#cid-users-wrap');
|
|
if (oldWrap) oldWrap.replaceWith(newWrap);
|
|
} else {
|
|
const newList = doc.querySelector('#cid-users-list');
|
|
if (newList){
|
|
const old = document.querySelector('#cid-users-list');
|
|
if (old) old.replaceWith(newList);
|
|
}
|
|
}
|
|
}catch(e){ console.error('bulk swap failed', e); }
|
|
// dispatch HX-Trigger equivalent if present
|
|
const trigger = resp.headers.get('HX-Trigger');
|
|
if (trigger){
|
|
try{ const detail = JSON.parse(trigger); for(const k in detail){ document.body.dispatchEvent(new CustomEvent(k, { detail: detail[k] })); } }catch(e){}
|
|
}
|
|
}).catch(err => { console.error('bulk request failed', err); })
|
|
.finally(()=>{ if(triggeringButton) setLoading(triggeringButton, false); });
|
|
}
|
|
|
|
document.querySelectorAll('.add-all-btn').forEach(function(btn){
|
|
btn.addEventListener('click', function(){
|
|
const url = btn.dataset.bulkUrl;
|
|
const items = document.querySelectorAll('#cid-users-list li:not(.current)');
|
|
const pks = Array.from(items).map(i => i.dataset.pk).filter(Boolean).map(Number);
|
|
if (!pks.length) return;
|
|
submitBulk(url, pks, true, btn);
|
|
});
|
|
});
|
|
document.querySelectorAll('.remove-all-btn').forEach(function(btn){
|
|
btn.addEventListener('click', function(){
|
|
if(!confirm('Remove all selected users from the exam?')) return;
|
|
const url = btn.dataset.bulkUrl;
|
|
const items = document.querySelectorAll('#cid-users-list li.current');
|
|
const pks = Array.from(items).map(i => i.dataset.pk).filter(Boolean).map(Number);
|
|
if (!pks.length) return;
|
|
submitBulk(url, pks, false, btn);
|
|
});
|
|
});
|
|
|
|
// Toggle all: send mixed per-item {pk, add} payload so server can apply add/remove per item
|
|
document.querySelectorAll('.toggle-all-btn').forEach(function(btn){
|
|
btn.addEventListener('click', function(){
|
|
const url = btn.dataset.bulkUrl || document.querySelector('.add-all-btn')?.dataset.bulkUrl;
|
|
// build mixed list: for each li include {pk, add}
|
|
const items = Array.from(document.querySelectorAll('#cid-users-list li')).map(function(li){
|
|
return { pk: Number(li.dataset.pk), add: !li.classList.contains('current') };
|
|
});
|
|
if (!items.length) return;
|
|
submitBulk(url, items, null, btn);
|
|
});
|
|
});
|
|
|
|
// HTMX: listen for server HX-Trigger to show toasts for toggles and update counts
|
|
document.body.addEventListener('cid_toggled', function(evt){
|
|
try {
|
|
const detail = evt.detail || {};
|
|
const added = detail.added;
|
|
if (window.toastr) {
|
|
if (added) toastr.info(`User added to exam`);
|
|
else toastr.info(`User removed from exam`);
|
|
}
|
|
// Update counts for single-item toggles (bulk swaps replace the whole wrapper)
|
|
const addedEl = document.getElementById('cid-count-added');
|
|
const availEl = document.getElementById('cid-count-available');
|
|
if (addedEl && availEl){
|
|
let addedCount = parseInt(addedEl.textContent || '0') || 0;
|
|
let availCount = parseInt(availEl.textContent || '0') || 0;
|
|
if (added){ addedCount += 1; availCount = Math.max(0, availCount - 1); }
|
|
else { addedCount = Math.max(0, addedCount - 1); availCount += 1; }
|
|
addedEl.textContent = String(addedCount);
|
|
availEl.textContent = String(availCount);
|
|
}
|
|
} catch(e) { /* ignore */ }
|
|
});
|
|
|
|
// HTMX: clear loading indicators when requests finish
|
|
document.body.addEventListener('htmx:afterRequest', function(evt){
|
|
try{
|
|
const elt = (evt.detail && evt.detail.elt) || null;
|
|
let btn = null;
|
|
if(elt){
|
|
if(elt.tagName === 'BUTTON') btn = elt;
|
|
else if(elt.tagName === 'FORM') btn = elt.querySelector('button[type=submit]');
|
|
else btn = elt.closest && elt.closest('button') ? elt.closest('button') : null;
|
|
}
|
|
if(btn) setLoading(btn, false);
|
|
}catch(e){ }
|
|
});
|
|
|
|
// Delegate toggle button clicks so newly-inserted items work
|
|
document.body.addEventListener('click', function(e){
|
|
const btn = e.target.closest && e.target.closest('.toggle-btn');
|
|
if(!btn) return;
|
|
// find enclosing form
|
|
const form = btn.closest('form');
|
|
if(!form) return;
|
|
e.preventDefault();
|
|
setLoading(btn, true);
|
|
if(window.htmx && typeof htmx.submit === 'function'){
|
|
htmx.submit(form);
|
|
return;
|
|
}
|
|
|
|
// fallback: send via fetch and swap the nearest li with returned HTML
|
|
(async function(){
|
|
try{
|
|
const url = form.action || form.getAttribute('hx-post');
|
|
const fd = new FormData(form);
|
|
const csrf = document.querySelector('input[name="csrfmiddlewaretoken"]')?.value;
|
|
const headers = { 'HX-Request': 'true' };
|
|
if(csrf) headers['X-CSRFToken'] = csrf;
|
|
|
|
const resp = await fetch(url, { method: 'POST', body: fd, headers: headers, credentials: 'same-origin' });
|
|
const text = await resp.text();
|
|
// try to parse HTML and replace the closest li
|
|
try{
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(text, 'text/html');
|
|
// the server should return the li outerHTML
|
|
const newLi = doc.querySelector('li');
|
|
const oldLi = form.closest('li');
|
|
if(newLi && oldLi){ oldLi.replaceWith(newLi); }
|
|
// dispatch any HX-Trigger header events
|
|
const trigger = resp.headers.get('HX-Trigger');
|
|
if (trigger){ try{ const detail = JSON.parse(trigger); for(const k in detail){ document.body.dispatchEvent(new CustomEvent(k, { detail: detail[k] })); } }catch(e){} }
|
|
}catch(err){ console.error('toggle parse/swap failed', err); }
|
|
}catch(err){ console.error('toggle request failed', err); }
|
|
finally{ setLoading(btn, false); }
|
|
})();
|
|
});
|
|
|
|
// Sorting: pure JS implementation
|
|
let currentSort = { key: 'cid', dir: 'asc' };
|
|
function updateCarets(){
|
|
document.querySelectorAll('.sort-btn').forEach(function(b){
|
|
const key = b.dataset.key;
|
|
const caret = b.querySelector('.sort-caret');
|
|
if (caret) caret.textContent = (currentSort.key === key) ? (currentSort.dir === 'asc' ? ' ▲' : ' ▼') : '';
|
|
});
|
|
}
|
|
function sortList(key){
|
|
const ul = document.getElementById('cid-users-list');
|
|
const items = Array.from(ul.children);
|
|
const dir = (currentSort.key === key && currentSort.dir === 'asc') ? 'desc' : 'asc';
|
|
items.sort(function(a,b){
|
|
const va = (a.dataset[key] || '').toString().toLowerCase();
|
|
const vb = (b.dataset[key] || '').toString().toLowerCase();
|
|
if (va < vb) return dir === 'asc' ? -1 : 1;
|
|
if (va > vb) return dir === 'asc' ? 1 : -1;
|
|
return 0;
|
|
});
|
|
items.forEach(function(it){ ul.appendChild(it); });
|
|
currentSort = { key: key, dir: dir };
|
|
updateCarets();
|
|
}
|
|
document.querySelectorAll('.sort-btn').forEach(function(b){ b.addEventListener('click', function(){ sortList(b.dataset.key); }); });
|
|
updateCarets();
|
|
});
|
|
</script>
|
|
|
|
<style>
|
|
/* Compact, dark-mode adjustments */
|
|
.card.bg-dark { background-color: #111 !important; }
|
|
.card-body { padding: .5rem !important; }
|
|
.list-group-item { padding: .35rem .5rem; font-size: .88rem; border-left: 0; border-right: 0; }
|
|
.list-group-item.current { background: rgba(255,255,255,0.03); border-left: 4px solid rgba(13,110,253,0.25); }
|
|
.list-group-item .small { display:block; color: #bfc6cf; }
|
|
.btn-group .btn { padding: .25rem .45rem; font-size: .8rem; }
|
|
.btn-outline-light { color: #e9ecef; border-color: rgba(233,236,239,0.12); }
|
|
.btn-outline-light:hover { color:#fff; background: rgba(233,236,239,0.06); }
|
|
</style> |