Implement user search widget with HTMX for dynamic user selection
This commit is contained in:
+178
-1
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user