Refactor UserUserGroupForm to use a reusable UserSearchSelectMultipleWidget for improved user selection and search functionality

This commit is contained in:
Ross
2025-11-13 21:48:58 +00:00
parent 345977e2ef
commit a70354c18d
+203 -204
View File
@@ -556,225 +556,224 @@ class UserUserGroupModelChoiceField(ModelMultipleChoiceField):
return f"{obj.username} ({obj.userprofile.grade}) [{obj.email}]"
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.
"""
class UserSearchSelectMultipleWidget:
"""Reusable lightweight widget renderer for a searchable multi-user selector.
def __init__(self, name, value):
self.name = name
# value is a list of selected user ids
self.value = value or []
Usage: instantiate with (name, value) where value is an iterable of user ids
and call .render() to get the HTML fragment. This mirrors the previous
nested widget but is available module-wide for reuse.
"""
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}"
def __init__(self, name, value):
self.name = name
self.value = value or []
# 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>'
# include grade display in the selected list if available
grade_text = ""
try:
up = getattr(u, "userprofile", None)
if up and getattr(up, "grade", None):
g = up.grade
grade_text = getattr(g, "name", str(g)) if g is not None else ""
except Exception:
grade_text = ""
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}"
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>'
+ (f' <small class="text-muted ms-2">{grade_text}</small>' if grade_text else '')
+ '</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")
# Build a small grade filter select
grades_options = '<option value="">All grades</option>'
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>'
grade_text = ""
try:
from generic.models import UserGrades
grades_qs = UserGrades.objects.all()
for g in grades_qs:
grades_options += f'<option value="{g.pk}">{g.name}</option>'
up = getattr(u, "userprofile", None)
if up and getattr(up, "grade", None):
g = up.grade
grade_text = getattr(g, "name", str(g)) if g is not None else ""
except Exception:
grades_options = '<option value="">All grades</option>'
grade_text = ""
html = f"""
<div class="user-search-widget">
<select name="{self.name}" id="{field_id}" multiple class="d-none">
{options_html}
</select>
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>'
+ (f' <small class="text-muted ms-2">{grade_text}</small>' if grade_text else '')
+ '</span>'
f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeUserFromField(\'{self.name}\', {u.pk})">Remove</button>'
f'</li>'
)
<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 users by name, username or email" />
<select id="grade_filter_{self.name}" class="form-select form-select-sm" style="max-width:180px">
{grades_options}
</select>
<!-- Help button for advanced search features -->
<button type="button" id="user_search_help_btn_{self.name}" class="btn btn-sm btn-outline-secondary" aria-expanded="false" aria-controls="user_search_help_panel_{self.name}" title="Advanced search help">?</button>
</div>
url = reverse("generic:user_search_widget")
<div id="{results_id}" class="mt-2"></div>
grades_options = '<option value="">All grades</option>'
try:
from generic.models import UserGrades
<!-- Collapsible help panel (hidden by default) -->
<div id="user_search_help_panel_{self.name}" class="card card-body mt-2 d-none" style="font-size:.9rem;">
<strong>Advanced search tips</strong>
<ul class="mb-0 mt-1">
<li>Basic: type any part of a user's first name, last name, username or email (case-insensitive substring match). Example: <code>smith</code> or <code>joe@example.com</code>.</li>
<li>Wildcards: use <code>*</code> or <code>%</code> as the query to return many users (use carefully). Wildcard queries return up to 50 results; normal queries return up to 10.</li>
<li>Field-specific searches: prefix with <code>field:term</code> to restrict the search. Supported fields:
<ul class="mb-0 mt-1">
<li><code>name:</code> or <code>full_name:</code> — matches first OR last name (e.g. <code>name:smith</code>).</li>
<li><code>first:</code> or <code>first_name:</code> — matches first name.</li>
<li><code>last:</code> or <code>last_name:</code> — matches last name.</li>
<li><code>email:</code> — matches email address.</li>
<li><code>username:</code> — matches username.</li>
<li><code>id:</code> or <code>pk:</code> — match by numeric user id (e.g. <code>id:123</code>).</li>
<li><code>grade:</code> — match by grade name or grade id (e.g. <code>grade:ST3</code> or <code>grade:2</code>).</li>
</ul>
</li>
<li>Grade filter: use the grade dropdown next to the search box to narrow results by trainee grade in addition to your query.</li>
<li>To add users, click the <em>Add</em> button beside a result. If an <em>Add all results</em> button appears, it will add all currently visible results.</li>
<li>If you expect many matches, narrow your query or use a field-specific search to improve relevance and performance.</li>
</ul>
</div>
grades_qs = UserGrades.objects.all()
for g in grades_qs:
grades_options += f'<option value="{g.pk}">{g.name}</option>'
except Exception:
grades_options = '<option value="">All grades</option>'
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">
<div class="d-flex gap-2 align-items-start">
<input type="search" id="{search_id}" class="form-control form-control-sm" placeholder="Search users by name, username or email" />
<select id="grade_filter_{self.name}" class="form-select form-select-sm" style="max-width:180px">
{grades_options}
</select>
<!-- Help button for advanced search features -->
<button type="button" id="user_search_help_btn_{self.name}" class="btn btn-sm btn-outline-secondary" aria-expanded="false" aria-controls="user_search_help_panel_{self.name}" title="Advanced search help">?</button>
</div>
<div>
<label class="form-label small">Selected users</label>
<ul id="{selected_list_id}" class="list-group list-group-flush">
{selected_html}
<div id="{results_id}" class="mt-2"></div>
<!-- Collapsible help panel (hidden by default) -->
<div id="user_search_help_panel_{self.name}" class="card card-body mt-2 d-none" style="font-size:.9rem;">
<strong>Advanced search tips</strong>
<ul class="mb-0 mt-1">
<li>Basic: type any part of a user's first name, last name, username or email (case-insensitive substring match). Example: <code>smith</code> or <code>joe@example.com</code>.</li>
<li>Wildcards: use <code>*</code> or <code>%</code> as the query to return many users (use carefully). Wildcard queries return up to 50 results; normal queries return up to 10.</li>
<li>Field-specific searches: prefix with <code>field:term</code> to restrict the search. Supported fields:
<ul class="mb-0 mt-1">
<li><code>name:</code> or <code>full_name:</code> — matches first OR last name (e.g. <code>name:smith</code>).</li>
<li><code>first:</code> or <code>first_name:</code> — matches first name.</li>
<li><code>last:</code> or <code>last_name:</code> — matches last name.</li>
<li><code>email:</code> — matches email address.</li>
<li><code>username:</code> — matches username.</li>
<li><code>id:</code> or <code>pk:</code> — match by numeric user id (e.g. <code>id:123</code>).</li>
<li><code>grade:</code> — match by grade name or grade id (e.g. <code>grade:ST3</code> or <code>grade:2</code>).</li>
</ul>
</li>
<li>Grade filter: use the grade dropdown next to the search box to narrow results by trainee grade in addition to your query.</li>
<li>To add users, click the <em>Add</em> button beside a result. If an <em>Add all results</em> button appears, it will add all currently visible results.</li>
<li>If you expect many matches, narrow your query or use a field-specific search to improve relevance and performance.</li>
</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}');
const gradeSelect = document.getElementById('grade_filter_{self.name}');
const helpBtn = document.getElementById('user_search_help_btn_{self.name}');
const helpPanel = document.getElementById('user_search_help_panel_{self.name}');
let timeout = null;
function fetchResults(q) {{
const grade = gradeSelect ? gradeSelect.value : '';
if (!q || q.trim().length === 0) {{
results.innerHTML = '';
return;
}}
const url = '{url}?field=' + encodeURIComponent('{self.name}') + '&q=' + encodeURIComponent(q) + (grade ? '&grade=' + encodeURIComponent(grade) : '');
fetch(url)
.then(r => r.text())
.then(html => {{ results.innerHTML = html; }});
}}
search.addEventListener('keyup', function(e) {{
clearTimeout(timeout);
timeout = setTimeout(() => fetchResults(search.value), 300);
}});
gradeSelect && gradeSelect.addEventListener('change', function(e) {{
// re-run search with same query when grade changes
clearTimeout(timeout);
timeout = setTimeout(() => fetchResults(search.value), 50);
}});
// Toggle help panel visibility
if (helpBtn && helpPanel) {{
helpBtn.addEventListener('click', function(e) {{
e.preventDefault();
helpPanel.classList.toggle('d-none');
const expanded = !helpPanel.classList.contains('d-none');
helpBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
}});
}}
// fieldName for this widget instance
const widgetFieldName = '{self.name}';
// Add button click delegation: results list buttons have data attributes
results.addEventListener('click', function(e) {{
const btn = e.target.closest('.user-add-btn');
if (!btn) return;
const id = btn.getAttribute('data-user-id');
const text = btn.getAttribute('data-user-text') || btn.textContent.trim();
const grade = btn.getAttribute('data-user-grade') || '';
addUserToField(widgetFieldName, id, text, grade);
}});
// Wire up the "Add all results" button if present
const addAllBtn = results.parentElement.querySelector('.user-add-all-btn');
if (addAllBtn) {{
addAllBtn.addEventListener('click', function(e) {{
e.preventDefault();
addAllFromResults(widgetFieldName);
}});
}}
window.addUserToField = function(fieldName, id, text, grade) {{
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 (include grade if provided)
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 + (grade ? ' <small class="text-muted ms-2">' + grade + '</small>' : '');
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);
}}
window.addAllFromResults = function(fieldName) {{
const list = document.getElementById('user_search_results_list_' + fieldName);
if (!list) return;
const items = list.querySelectorAll('li[data-user-id]');
items.forEach(function(it) {{
const id = it.getAttribute('data-user-id');
const text = it.getAttribute('data-user-text') || it.textContent.trim();
const grade = it.getAttribute('data-user-grade') || '';
addUserToField(fieldName, id, text, grade);
}});
}}
}})();
</script>
</div>
"""
return mark_safe(html)
<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}');
const gradeSelect = document.getElementById('grade_filter_{self.name}');
const helpBtn = document.getElementById('user_search_help_btn_{self.name}');
const helpPanel = document.getElementById('user_search_help_panel_{self.name}');
let timeout = null;
function fetchResults(q) {{
const grade = gradeSelect ? gradeSelect.value : '';
if (!q || q.trim().length === 0) {{
results.innerHTML = '';
return;
}}
const url = '{url}?field=' + encodeURIComponent('{self.name}') + '&q=' + encodeURIComponent(q) + (grade ? '&grade=' + encodeURIComponent(grade) : '');
fetch(url)
.then(r => r.text())
.then(html => {{ results.innerHTML = html; }});
}}
search.addEventListener('keyup', function(e) {{
clearTimeout(timeout);
timeout = setTimeout(() => fetchResults(search.value), 300);
}});
gradeSelect && gradeSelect.addEventListener('change', function(e) {{
// re-run search with same query when grade changes
clearTimeout(timeout);
timeout = setTimeout(() => fetchResults(search.value), 50);
}});
// Toggle help panel visibility
if (helpBtn && helpPanel) {{
helpBtn.addEventListener('click', function(e) {{
e.preventDefault();
helpPanel.classList.toggle('d-none');
const expanded = !helpPanel.classList.contains('d-none');
helpBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
}});
}}
// fieldName for this widget instance
const widgetFieldName = '{self.name}';
// Add button click delegation: results list buttons have data attributes
results.addEventListener('click', function(e) {{
const btn = e.target.closest('.user-add-btn');
if (!btn) return;
const id = btn.getAttribute('data-user-id');
const text = btn.getAttribute('data-user-text') || btn.textContent.trim();
const grade = btn.getAttribute('data-user-grade') || '';
addUserToField(widgetFieldName, id, text, grade);
}});
// Wire up the "Add all results" button if present
const addAllBtn = results.parentElement.querySelector('.user-add-all-btn');
if (addAllBtn) {{
addAllBtn.addEventListener('click', function(e) {{
e.preventDefault();
addAllFromResults(widgetFieldName);
}});
}}
window.addUserToField = function(fieldName, id, text, grade) {{
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 (include grade if provided)
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 + (grade ? ' <small class="text-muted ms-2">' + grade + '</small>' : '');
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);
}}
window.addAllFromResults = function(fieldName) {{
const list = document.getElementById('user_search_results_list_' + fieldName);
if (!list) return;
const items = list.querySelectorAll('li[data-user-id]');
items.forEach(function(it) {{
const id = it.getAttribute('data-user-id');
const text = it.getAttribute('data-user-text') || it.textContent.trim();
const grade = it.getAttribute('data-user-grade') || '';
addUserToField(fieldName, id, text, grade);
}});
}}
}})();
</script>
</div>
"""
return mark_safe(html)
class UserUserGroupForm(ModelForm):
# Use module-level UserSearchSelectMultipleWidget for rendering the users field
users = UserUserGroupModelChoiceField(
required=False,
@@ -808,7 +807,7 @@ class UserUserGroupForm(ModelForm):
# store attrs so template helpers can inspect them
self.attrs = attrs or {}
value = value or []
widget = UserUserGroupForm.UserSearchSelectMultiple(name, value)
widget = UserSearchSelectMultipleWidget(name, value)
return widget.render()
def value_from_datadict(self, data, files, name):