.
This commit is contained in:
+202
-1
@@ -179,6 +179,8 @@ class UserSearchSelectMultipleWidget:
|
||||
sel.appendChild(opt);
|
||||
|
||||
// add to visible list (include grade if provided)
|
||||
const listEl = document.getElementById('selected_users_' + fieldName);
|
||||
if (!listEl) return;
|
||||
const li = document.createElement('li');
|
||||
li.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||
li.id = 'selected_user_' + fieldName + '_' + id;
|
||||
@@ -187,7 +189,7 @@ class UserSearchSelectMultipleWidget:
|
||||
const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-danger ms-2'; btn.innerText = 'Remove';
|
||||
btn.addEventListener('click', function() {{ removeUserFromField(fieldName, id); }});
|
||||
li.appendChild(span); li.appendChild(btn);
|
||||
selectedList.appendChild(li);
|
||||
listEl.appendChild(li);
|
||||
}}
|
||||
|
||||
window.removeUserFromField = function(fieldName, id) {{
|
||||
@@ -268,3 +270,202 @@ class UserSearchWidget:
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
class ExamSearchSelectMultipleWidget:
|
||||
"""Lightweight renderer for selecting exams across types.
|
||||
|
||||
Parameters
|
||||
- name, value: same as form widget usage
|
||||
- exam_model: optional Django model class for an exam type (used to
|
||||
adapt display and search behaviour)
|
||||
- display_fields: optional list of extra fields to show in results
|
||||
"""
|
||||
|
||||
def __init__(self, name, value, exam_model=None, display_fields=None):
|
||||
self.name = name
|
||||
self.value = value or []
|
||||
self.exam_model = exam_model
|
||||
self.display_fields = display_fields or []
|
||||
|
||||
def render(self):
|
||||
field_id = f"id_{self.name}"
|
||||
search_id = f"exam_search_{self.name}"
|
||||
results_id = f"exam_search_results_{self.name}"
|
||||
selected_list_id = f"selected_exams_{self.name}"
|
||||
|
||||
# Render currently selected exams as <option>s
|
||||
options_html = ""
|
||||
selected_html = ""
|
||||
try:
|
||||
if self.value:
|
||||
from django.apps import apps
|
||||
|
||||
# Accept either pks or model instances
|
||||
instances = []
|
||||
for v in self.value:
|
||||
if hasattr(v, "pk"):
|
||||
instances.append(v)
|
||||
else:
|
||||
# try to resolve by pk across possible models
|
||||
# when exam_model is provided prefer that
|
||||
if self.exam_model is not None:
|
||||
try:
|
||||
inst = self.exam_model.objects.filter(pk=v).first()
|
||||
if inst:
|
||||
instances.append(inst)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
# fallback: try generic lookup across known exam models is expensive;
|
||||
# just attempt to treat v as a pk for display-less option
|
||||
instances.append(type("_", (), {"pk": v, "__str__": lambda s: str(v)}))
|
||||
|
||||
for ex in instances:
|
||||
label = str(ex)
|
||||
options_html += f'<option value="{getattr(ex, "pk", "")}" selected>{label}</option>'
|
||||
selected_html += (
|
||||
f'<li id="selected_exam_{self.name}_{getattr(ex, "pk", "")}" class="list-group-item d-flex justify-content-between align-items-center">'
|
||||
f'<span>{label}</span>'
|
||||
f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeExamFromField(\'{self.name}\', {getattr(ex, "pk", "")})">Remove</button>'
|
||||
f'</li>'
|
||||
)
|
||||
except Exception:
|
||||
options_html = ""
|
||||
selected_html = ""
|
||||
|
||||
url = reverse("generic:exam_search_widget") if reverse else ""
|
||||
|
||||
html = f"""
|
||||
<div class="exam-search-widget">
|
||||
<select name="{self.name}" id="{field_id}" multiple class="d-none">
|
||||
{options_html}
|
||||
</select>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="d-flex gap-2 align-items-start">
|
||||
<input type="search" id="{search_id}" class="form-control form-control-sm" placeholder="Search exams by name or code" />
|
||||
<!-- optional type indicator -->
|
||||
{f'<div class="badge bg-secondary small">{self.exam_model._meta.verbose_name.title()}</div>' if self.exam_model is not None else ''}
|
||||
</div>
|
||||
<div id="{results_id}" class="mt-2"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label small">Selected exams</label>
|
||||
<ul id="{selected_list_id}" class="list-group list-group-flush">
|
||||
{selected_html}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {{
|
||||
const search = document.getElementById('{search_id}');
|
||||
const results = document.getElementById('{results_id}');
|
||||
const select = document.getElementById('{field_id}');
|
||||
const selectedList = document.getElementById('{selected_list_id}');
|
||||
|
||||
let timeout = null;
|
||||
function fetchResults(q) {{
|
||||
if (!q || q.trim().length === 0) {{
|
||||
results.innerHTML = '';
|
||||
return;
|
||||
}}
|
||||
const url = '{url}?field=' + encodeURIComponent('{self.name}') + '&q=' + encodeURIComponent(q) + ("&model=" + encodeURIComponent('{self.exam_model._meta.label}') || '');
|
||||
fetch(url)
|
||||
.then(r => r.text())
|
||||
.then(html => {{ results.innerHTML = html; }});
|
||||
}}
|
||||
|
||||
search.addEventListener('keyup', function(e) {{
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => fetchResults(search.value), 300);
|
||||
}});
|
||||
|
||||
results.addEventListener('click', function(e) {{
|
||||
const btn = e.target.closest('.exam-add-btn');
|
||||
if (!btn) return;
|
||||
const id = btn.getAttribute('data-exam-id');
|
||||
const text = btn.getAttribute('data-exam-text') || btn.textContent.trim();
|
||||
addExamToField('{self.name}', id, text);
|
||||
}});
|
||||
|
||||
window.addExamToField = function(fieldName, id, text) {{
|
||||
const sel = document.getElementById('id_' + fieldName);
|
||||
if (!sel) return;
|
||||
for (let i=0;i<sel.options.length;i++) {{ if (sel.options[i].value == id) return; }}
|
||||
const opt = document.createElement('option'); opt.value = id; opt.selected = true; opt.text = text;
|
||||
sel.appendChild(opt);
|
||||
const listEl = document.getElementById('selected_exams_' + fieldName);
|
||||
if (!listEl) return;
|
||||
const li = document.createElement('li');
|
||||
li.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||
li.id = 'selected_exam_' + fieldName + '_' + id;
|
||||
const span = document.createElement('span'); span.innerHTML = text;
|
||||
const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-danger ms-2'; btn.innerText = 'Remove';
|
||||
btn.addEventListener('click', function() {{ removeExamFromField(fieldName, id); }});
|
||||
li.appendChild(span); li.appendChild(btn); listEl.appendChild(li);
|
||||
}}
|
||||
|
||||
window.removeExamFromField = function(fieldName, id) {{
|
||||
const sel = document.getElementById('id_' + fieldName);
|
||||
if (!sel) return;
|
||||
for (let i=sel.options.length-1;i>=0;i--) {{ if (sel.options[i].value == id) sel.remove(i); }}
|
||||
const li = document.getElementById('selected_exam_' + fieldName + '_' + id);
|
||||
if (li && li.parentNode) li.parentNode.removeChild(li);
|
||||
}}
|
||||
}})();
|
||||
</script>
|
||||
</div>
|
||||
"""
|
||||
|
||||
return mark_safe(html)
|
||||
|
||||
|
||||
class ExamSearchWidget:
|
||||
"""Widget wrapper that is compatible with Django form machinery.
|
||||
|
||||
Accepts `exam_model` to specialise behaviour/display.
|
||||
"""
|
||||
|
||||
is_hidden = False
|
||||
needs_multipart_form = False
|
||||
use_fieldset = False
|
||||
|
||||
def __init__(self, field=None, exam_model=None, display_fields=None):
|
||||
self.field = field
|
||||
self.attrs = {}
|
||||
self.exam_model = exam_model
|
||||
self.display_fields = display_fields or []
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
self.attrs = attrs or {}
|
||||
value = value or []
|
||||
widget = ExamSearchSelectMultipleWidget(name, value, exam_model=self.exam_model, display_fields=self.display_fields)
|
||||
return widget.render()
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
if hasattr(data, 'getlist'):
|
||||
return data.getlist(name)
|
||||
val = data.get(name)
|
||||
if val is None:
|
||||
return []
|
||||
return [val]
|
||||
|
||||
def id_for_label(self, id_):
|
||||
try:
|
||||
if hasattr(self, 'attrs') and self.attrs and self.attrs.get('id'):
|
||||
return self.attrs.get('id')
|
||||
except Exception:
|
||||
pass
|
||||
return id_
|
||||
|
||||
def use_required_attribute(self, initial):
|
||||
try:
|
||||
if hasattr(self, 'field') and getattr(self, 'field') is not None:
|
||||
orig_widget = getattr(self.field, 'widget', None)
|
||||
if orig_widget and hasattr(orig_widget, 'use_required_attribute'):
|
||||
return orig_widget.use_required_attribute(initial)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user