Implement user search functionality with HTMX support for manual user selection in bulk updates
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
{% if results %}
|
||||||
|
<ul style="list-style:none;padding:0;margin:0">
|
||||||
|
{% for item in results %}
|
||||||
|
<li style="margin:6px 0;">
|
||||||
|
<strong>{{ item.text|escape }}</strong>
|
||||||
|
<button type="button" onclick="setManualUser({{ row }}, {{ item.id }}, '{{ item.text|escapejs }}')" style="margin-left:8px;">Select</button>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<div><em>No users found</em></div>
|
||||||
|
{% endif %}
|
||||||
@@ -1,117 +1,93 @@
|
|||||||
{% extends 'generic/base.html' %}
|
{% extends 'generic/base.html' %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>Bulk update trainees (paste spreadsheet text)</h2>
|
<h2>Bulk update trainees (paste spreadsheet text)</h2>
|
||||||
|
|
||||||
<p>Paste the spreadsheet rows below. Headings like "Year 1 - Group 1" are used to set the grade (Year N -> STN). Rows should contain "Name <tab or 2+spaces> Supervisor".</p>
|
<p>Paste the spreadsheet rows below. Headings like "Year 1 - Group 1" are used to set the grade (Year N -> STN). Rows should contain "Name <tab or 2+spaces> Supervisor".</p>
|
||||||
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<textarea name="data" rows="18" style="width:100%">{% if request.POST.data %}{{ request.POST.data }}{% endif %}</textarea>
|
<textarea name="data" rows="18" style="width:100%">{% if request.POST.data %}{{ request.POST.data }}{% endif %}</textarea>
|
||||||
<div style="margin-top:8px">
|
<div style="margin-top:8px">
|
||||||
<button type="submit" name="action" value="preview" class="btn btn-primary">Preview</button>
|
<button type="submit" name="action" value="preview" class="btn btn-primary">Preview</button>
|
||||||
<button type="submit" name="action" value="apply" class="btn btn-danger" onclick="return confirm('This will apply updates to matched trainee profiles. Are you sure?')">Apply updates</button>
|
<button type="submit" name="action" value="apply" class="btn btn-danger" onclick="return confirm('This will apply updates to matched trainee profiles. Are you sure?')">Apply updates</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if preview %}
|
{% if preview %}
|
||||||
<h3>Preview ({{ preview|length }} rows)</h3>
|
<h3>Preview ({{ preview|length }} rows)</h3>
|
||||||
<table class="table table-sm">
|
<table class="table table-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Raw</th>
|
<th>Raw</th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Grade</th>
|
<th>Grade</th>
|
||||||
<th>Matched user</th>
|
<th>Matched user</th>
|
||||||
<th>Supervisor text</th>
|
<th>Supervisor text</th>
|
||||||
<th>Matched supervisor</th>
|
<th>Matched supervisor</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for r in preview %}
|
{% for r in preview %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><pre style="margin:0">{{ r.raw }}</pre></td>
|
<td><pre style="margin:0">{{ r.raw }}</pre></td>
|
||||||
<td>{{ r.norm_name }}</td>
|
<td>{{ r.norm_name }}</td>
|
||||||
<td>{{ r.grade }}</td>
|
<td>{{ r.grade }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if r.matched_user %}
|
{% if r.matched_user %}
|
||||||
<a href="{% url 'account_profile' r.matched_user.username %}">{{ r.matched_user.first_name }} {{ r.matched_user.last_name }}</a>
|
<a href="{% url 'account_profile' r.matched_user.username %}">{{ r.matched_user.first_name }} {{ r.matched_user.last_name }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div>
|
<div>
|
||||||
<span style="color:red">No match</span>
|
<span style="color:red">No match</span>
|
||||||
<div>
|
<div>
|
||||||
<input type="text" id="user-search-input-{{ r.row_index }}" placeholder="Search users..." style="width:220px" />
|
<form hx-get="{% url 'generic:user_search' %}" hx-target="#user-results-{{ r.row_index }}" hx-swap="innerHTML" onsubmit="return false;">
|
||||||
<button type="button" onclick="searchUsers({{ r.row_index }})">Search</button>
|
<input type="text" name="q" id="user-search-input-{{ r.row_index }}" placeholder="Search users..." style="width:220px" />
|
||||||
</div>
|
<input type="hidden" name="row" value="{{ r.row_index }}" />
|
||||||
<div id="user-results-{{ r.row_index }}"></div>
|
<button type="submit">Search</button>
|
||||||
<input type="hidden" name="manual_user_{{ r.row_index }}" id="manual_user_{{ r.row_index }}" />
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
<div id="user-results-{{ r.row_index }}"></div>
|
||||||
</td>
|
<input type="hidden" name="manual_user_{{ r.row_index }}" id="manual_user_{{ r.row_index }}" />
|
||||||
<td>{{ r.supervisor_text }}</td>
|
</div>
|
||||||
<td>{% if r.matched_supervisor %}<a href="{% url 'generic:supervisor_detail' r.matched_supervisor.pk %}">{{ r.matched_supervisor.name }}</a>{% else %}<span style="color:red">No match</span>{% endif %}</td>
|
{% endif %}
|
||||||
</tr>
|
</td>
|
||||||
{% endfor %}
|
<td>{{ r.supervisor_text }}</td>
|
||||||
</tbody>
|
<td>{% if r.matched_supervisor %}<a href="{% url 'generic:supervisor_detail' r.matched_supervisor.pk %}">{{ r.matched_supervisor.name }}</a>{% else %}<span style="color:red">No match</span>{% endif %}</td>
|
||||||
</table>
|
</tr>
|
||||||
{% endif %}
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if report %}
|
{% if report %}
|
||||||
<h3>Apply report</h3>
|
<h3>Apply report</h3>
|
||||||
<p>Updated: {{ report.updated }}; Skipped (no user matched): {{ report.skipped }}</p>
|
<p>Updated: {{ report.updated }}; Skipped (no user matched): {{ report.skipped }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
<script>
|
<script>
|
||||||
function searchUsers(rowIndex) {
|
// Called from the HTMX-rendered results fragment to set the manual selection
|
||||||
const input = document.getElementById('user-search-input-' + rowIndex);
|
function setManualUser(rowIndex, id, text) {
|
||||||
const q = input ? input.value.trim() : '';
|
const hiddenInput = document.getElementById('manual_user_' + rowIndex);
|
||||||
const resultsDiv = document.getElementById('user-results-' + rowIndex);
|
const resultsDiv = document.getElementById('user-results-' + rowIndex);
|
||||||
const hiddenInput = document.getElementById('manual_user_' + rowIndex);
|
if (hiddenInput) {
|
||||||
resultsDiv.innerHTML = 'Searching...';
|
hiddenInput.value = id;
|
||||||
fetch("{% url 'generic:user_search' %}?q=" + encodeURIComponent(q))
|
}
|
||||||
.then(r => r.json())
|
if (resultsDiv) {
|
||||||
.then(data => {
|
resultsDiv.innerHTML = '<div>Selected: ' + escapeHtml(text) + '</div>';
|
||||||
resultsDiv.innerHTML = '';
|
}
|
||||||
if (!data || !data.results || data.results.length === 0) {
|
}
|
||||||
resultsDiv.innerHTML = '<div><em>No users found</em></div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ul = document.createElement('ul');
|
|
||||||
ul.style.listStyle = 'none';
|
|
||||||
ul.style.padding = '0';
|
|
||||||
data.results.forEach(function(item){
|
|
||||||
const li = document.createElement('li');
|
|
||||||
li.style.margin = '4px 0';
|
|
||||||
const btn = document.createElement('button');
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.textContent = 'Select';
|
|
||||||
btn.style.marginLeft = '8px';
|
|
||||||
btn.onclick = function(){
|
|
||||||
hiddenInput.value = item.id;
|
|
||||||
resultsDiv.innerHTML = '<div>Selected: ' + escapeHtml(item.text) + '</div>';
|
|
||||||
};
|
|
||||||
li.innerHTML = '<strong>' + escapeHtml(item.text) + '</strong>';
|
|
||||||
li.appendChild(btn);
|
|
||||||
ul.appendChild(li);
|
|
||||||
});
|
|
||||||
resultsDiv.appendChild(ul);
|
|
||||||
})
|
|
||||||
.catch(function(err){
|
|
||||||
resultsDiv.innerHTML = '<div style="color:red">Error searching users</div>';
|
|
||||||
console.error(err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(unsafe) {
|
function escapeHtml(unsafe) {
|
||||||
return unsafe
|
if (!unsafe) return '';
|
||||||
.replace(/&/g, "&")
|
return String(unsafe)
|
||||||
.replace(/</g, "<")
|
.replace(/&/g, "&")
|
||||||
.replace(/>/g, ">")
|
.replace(/</g, "<")
|
||||||
.replace(/\"/g, """)
|
.replace(/>/g, ">")
|
||||||
.replace(/'/g, "'");
|
.replace(/\"/g, """)
|
||||||
}
|
.replace(/'/g, "'");
|
||||||
</script>
|
}
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+9
-1
@@ -4412,7 +4412,15 @@ def user_search(request):
|
|||||||
for u in qs:
|
for u in qs:
|
||||||
results.append({"id": u.pk, "text": f"{u.first_name} {u.last_name} ({u.email})"})
|
results.append({"id": u.pk, "text": f"{u.first_name} {u.last_name} ({u.email})"})
|
||||||
|
|
||||||
return JsonResponse({"results": results})
|
# If this is an HTMX request (or a row param was provided) render an HTML fragment
|
||||||
|
# that can be swapped into the preview row's results container. Otherwise return JSON.
|
||||||
|
row = request.GET.get("row")
|
||||||
|
if request.headers.get("HX-Request") == "true" or row is not None:
|
||||||
|
# Render a small partial that lists results with Select buttons that call
|
||||||
|
# the setManualUser(row, id, text) function in the parent page.
|
||||||
|
return render(request, "generic/partials/user_search_results.html", {"results": results, "row": int(row) if row is not None else None})
|
||||||
|
|
||||||
|
return JsonResponse({"results": results})
|
||||||
|
|
||||||
|
|
||||||
def create_trainee(request, context=None):
|
def create_trainee(request, context=None):
|
||||||
|
|||||||
Reference in New Issue
Block a user