Implement user search widget with HTMX for dynamic user selection

This commit is contained in:
Ross
2025-11-12 21:40:24 +00:00
parent da04138861
commit ea9dca5804
5 changed files with 235 additions and 5 deletions
+178 -1
View File
@@ -78,6 +78,9 @@ from crispy_forms.bootstrap import Accordion, AccordionGroup
from dal import autocomplete
from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidget, ModelAutocomplete, register as autocomplete_register
from django.utils.safestring import mark_safe
from django.urls import reverse
from django.contrib.auth.models import User
class SplitDateTimeFieldDefaultTime(SplitDateTimeField):
@@ -554,12 +557,186 @@ class UserUserGroupModelChoiceField(ModelMultipleChoiceField):
class UserUserGroupForm(ModelForm):
class UserSearchSelectMultiple(object):
"""
Lightweight widget renderer for a searchable multi-user selector.
Renders a hidden <select multiple> containing selected user ids and
a search box with results. Uses a small inline script to fetch
results from the `generic:user_search_widget` endpoint and add/remove
users from the selection.
This keeps implementation simple and avoids external JS dependencies.
"""
def __init__(self, name, value):
self.name = name
# value is a list of selected user ids
self.value = value or []
def render(self):
field_id = f"id_{self.name}"
search_id = f"user_search_{self.name}"
results_id = f"user_search_results_{self.name}"
selected_list_id = f"selected_users_{self.name}"
# Build initial options for selected users
users = User.objects.filter(pk__in=self.value) if self.value else []
options_html = ""
selected_html = ""
for u in users:
options_html += f'<option value="{u.pk}" selected>{u.get_full_name() or u.username} - {u.email}</option>'
selected_html += (
f'<li id="selected_user_{self.name}_{u.pk}" class="list-group-item d-flex justify-content-between align-items-center">'
f'<span>{u.get_full_name() or u.username} <small class="text-muted">{u.email}</small></span>'
f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeUserFromField(\'{self.name}\', {u.pk})">Remove</button>'
f'</li>'
)
url = reverse("generic:user_search_widget")
html = f"""
<div class="user-search-widget">
<select name="{self.name}" id="{field_id}" multiple class="d-none">
{options_html}
</select>
<div class="mb-2">
<input type="search" id="{search_id}" class="form-control form-control-sm" placeholder="Search users by name, username or email" />
<div id="{results_id}" class="mt-2"></div>
</div>
<div>
<label class="form-label small">Selected users</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;
}}
fetch('{url}?field=' + encodeURIComponent('{self.name}') + '&q=' + encodeURIComponent(q))
.then(r => r.text())
.then(html => {{ results.innerHTML = html; }});
}}
search.addEventListener('keyup', function(e) {{
clearTimeout(timeout);
timeout = setTimeout(() => fetchResults(search.value), 300);
}});
window.addUserToField = function(fieldName, id, text) {{
const sel = document.getElementById('id_' + fieldName);
if (!sel) return;
// prevent duplicate
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);
// add to visible list
const li = document.createElement('li');
li.className = 'list-group-item d-flex justify-content-between align-items-center';
li.id = 'selected_user_' + 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() {{ removeUserFromField(fieldName, id); }});
li.appendChild(span); li.appendChild(btn);
selectedList.appendChild(li);
}}
window.removeUserFromField = 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_user_' + fieldName + '_' + id);
if (li && li.parentNode) li.parentNode.removeChild(li);
}}
}})();
</script>
</div>
"""
return mark_safe(html)
users = UserUserGroupModelChoiceField(
required=False,
queryset=User.objects.all(),
widget=FilteredSelectMultiple(verbose_name="Users", is_stacked=False),
# use our lightweight widget rendering
widget=None,
)
# monkey-patch the field's widget rendering when the form is instantiated
def __init__(self, *args, **kwargs):
ModelForm.__init__(self, *args, **kwargs)
# replace widget with callable that renders our HTML
if 'users' in self.fields:
orig_field = self.fields['users']
class RenderWrapper:
def __init__(self, field):
self.field = field
# Minimal widget-compatible wrapper used by Django templates and
# form machinery. We implement the small subset of the Widget
# API that the templates/checks expect: `is_hidden`,
# `needs_multipart_form`, `render(...)` and `value_from_datadict(...)`.
is_hidden = False
needs_multipart_form = False
use_fieldset = False
# attrs is expected by BoundField.id_for_label (widget.attrs.get('id'))
attrs = {}
def render(self, name, value, attrs=None, renderer=None):
# store attrs so template helpers can inspect them
self.attrs = attrs or {}
value = value or []
widget = UserUserGroupForm.UserSearchSelectMultiple(name, value)
return widget.render()
def value_from_datadict(self, data, files, name):
# data is usually QueryDict with getlist
if hasattr(data, 'getlist'):
return data.getlist(name)
# fallback
val = data.get(name)
if val is None:
return []
return [val]
def id_for_label(self, id_):
# Called by BoundField.id_for_label; prefer explicit id in attrs
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):
# Called by Django to decide whether to add the required HTML attribute.
# We defer to the underlying field/widget if available; otherwise
# return False to avoid unexpected 'required' attributes.
try:
if hasattr(self, 'field') and getattr(self, 'field') is not None:
# If original field/widget had such method, prefer it.
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
self.fields['users'].widget = RenderWrapper(orig_field)
class Meta:
model = UserUserGroup
fields = ["name", "archive", "open_access", "users"]
+13 -4
View File
@@ -1993,8 +1993,13 @@ class ExamCollection(models.Model, AuthorMixin):
for exam in exams:
# Each exam model exposes a `user_user_groups` ManyToManyField
exam.user_user_groups.add(*groups_qs)
updated += 1
try:
exam.user_user_groups.add(*groups_qs)
# Ensure any downstream signals / caches see the change
exam.save()
updated += 1
except Exception as e:
logger.exception("Failed to add user_user_groups to exam %s: %s", getattr(exam, 'pk', exam), e)
return updated
@@ -2027,8 +2032,12 @@ class ExamCollection(models.Model, AuthorMixin):
for exam in exams:
# Each exam model exposes a `cid_user_groups` ManyToManyField
exam.cid_user_groups.add(*groups_qs)
updated += 1
try:
exam.cid_user_groups.add(*groups_qs)
exam.save()
updated += 1
except Exception as e:
logger.exception("Failed to add cid_user_groups to exam %s: %s", getattr(exam, 'pk', exam), e)
return updated
@@ -0,0 +1,14 @@
{% if results %}
<ul class="list-unstyled mb-0">
{% for item in results %}
<li class="py-1 d-flex justify-content-between align-items-center">
<div class="flex-grow-1">{{ item.text|escape }}</div>
<div>
<button type="button" class="btn btn-sm btn-primary" onclick="addUserToField('{{ field }}', {{ item.id }}, '{{ item.text|escapejs }}')">Add</button>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div><em>No users found</em></div>
{% endif %}
+1
View File
@@ -58,6 +58,7 @@ urlpatterns = [
name="supervisor-autocomplete",
),
path("user-search/", views.user_search, name="user_search"),
path("user-search-widget/", views.user_search_widget, name="user_search_widget"),
path("supervisor-search/", views.supervisor_search, name="supervisor_search"),
path(
"cids/manage/<int:pk>/update", views.CidUserUpdate.as_view(), name="update_cid"
+29
View File
@@ -5908,6 +5908,35 @@ def user_search(request):
return render(request, "generic/partials/user_search_results.html", {"results": results, "row": row, "q": q})
@login_required
def user_search_widget(request):
"""HTMX/JS endpoint to search users for generic selectors.
Accepts GET params:
- q: query string
- field: the form field name to return with the results (so the
template can call addUserToField(field, id, text)).
Returns a small HTML fragment listing up to 10 matching users. Each
result will call `addUserToField(field, id, text)` when clicked.
"""
q = (request.GET.get("q") or "").strip()
field = request.GET.get("field") or request.GET.get("row")
if not q:
return HttpResponse("")
users_qs = User.objects.filter(
Q(first_name__icontains=q)
| Q(last_name__icontains=q)
| Q(email__icontains=q)
| Q(username__icontains=q)
).order_by("last_name", "first_name")[:10]
results = [{"id": u.pk, "text": f"{u.get_full_name() or u.username} - {u.email or ''}"} for u in users_qs]
return render(request, "generic/partials/user_search_widget_results.html", {"results": results, "field": field, "q": q})
@login_required
def supervisor_search(request):
"""HTMX endpoint to search supervisors for the trainees bulk-update UI.