Add finding and displayset search functionality with corresponding widgets and endpoints

This commit is contained in:
Ross
2026-03-23 13:34:28 +00:00
parent d7ed8881d8
commit 20b4d6b6af
8 changed files with 254 additions and 2 deletions
+46 -2
View File
@@ -42,9 +42,9 @@
if(t === 'case'){
openCasePicker(textarea);
} else if(t === 'finding'){
const id = prompt('Finding ID to link to:'); if(!id) return; insertAtCursor(textarea, '[['+t+':'+id+']]' );
openFindingPicker(textarea);
} else if(t === 'displayset'){
const caseId = prompt('Case ID containing the display set:'); if(!caseId) return; const setName = prompt('Display set name:'); if(!setName) return; insertAtCursor(textarea, '[['+t+':'+caseId+':'+setName+']]' );
openDisplaysetPicker(textarea);
}
});
});
@@ -187,4 +187,48 @@
}catch(err){ console.error('openCasePicker error', err); }
}
function openFindingPicker(textarea, caseId){
try{
console.log('Opening finding picker for', textarea);
const overlay = document.createElement('div');
overlay.className = 'markup-case-picker-overlay';
Object.assign(overlay.style, {position:'fixed',left:0,top:0,right:0,bottom:0,background:'rgba(0,0,0,0.4)',zIndex:1050,display:'flex',alignItems:'center',justifyContent:'center'});
const dialog = document.createElement('div'); dialog.className='markup-case-picker-dialog card'; Object.assign(dialog.style, {width:'min(900px,95%)',maxHeight:'80vh',overflow:'auto'});
const header = document.createElement('div'); header.className='card-header d-flex justify-content-between align-items-center'; header.innerHTML = '<strong>Select finding</strong>';
const closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn-close'; closeBtn.setAttribute('aria-label','Close'); closeBtn.addEventListener('click', function(){ document.body.removeChild(overlay); }); header.appendChild(closeBtn);
const body = document.createElement('div'); body.className='card-body';
const input = document.createElement('input'); input.type='search'; input.className='form-control mb-2'; input.placeholder='Type to search findings...';
const results = document.createElement('div'); results.className='finding-search-results'; body.appendChild(input); body.appendChild(results);
dialog.appendChild(header); dialog.appendChild(body); overlay.appendChild(dialog); document.body.appendChild(overlay); input.focus();
var timer = null;
input.addEventListener('input', function(){ if(timer) clearTimeout(timer); timer = setTimeout(function(){ var q = encodeURIComponent(input.value || ''); var url = window.location.origin + '/atlas/search/findings/?q=' + q + (caseId ? '&case=' + caseId : ''); fetch(url, {credentials:'same-origin'}).then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(e){ console.error('finding picker fetch error', e); results.innerHTML = '<div class="alert alert-danger">Search failed</div>'; }); }, 300); });
results.addEventListener('click', function(e){ var item = e.target.closest('.list-group-item'); if(!item) return; var pk = item.getAttribute('data-finding-pk'); if(!pk) return; insertAtCursor(textarea, '[['+'finding:'+pk+']]' ); if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); });
overlay.tabIndex = -1; overlay.focus(); fetch(window.location.origin + '/atlas/search/findings/').then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(){});
}catch(err){ console.error('openFindingPicker error', err); }
}
function openDisplaysetPicker(textarea, caseId){
try{
console.log('Opening displayset picker for', textarea);
const overlay = document.createElement('div');
overlay.className = 'markup-case-picker-overlay';
Object.assign(overlay.style, {position:'fixed',left:0,top:0,right:0,bottom:0,background:'rgba(0,0,0,0.4)',zIndex:1050,display:'flex',alignItems:'center',justifyContent:'center'});
const dialog = document.createElement('div'); dialog.className='markup-case-picker-dialog card'; Object.assign(dialog.style, {width:'min(900px,95%)',maxHeight:'80vh',overflow:'auto'});
const header = document.createElement('div'); header.className='card-header d-flex justify-content-between align-items-center'; header.innerHTML = '<strong>Select display set</strong>';
const closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn-close'; closeBtn.setAttribute('aria-label','Close'); closeBtn.addEventListener('click', function(){ document.body.removeChild(overlay); }); header.appendChild(closeBtn);
const body = document.createElement('div'); body.className='card-body';
const input = document.createElement('input'); input.type='search'; input.className='form-control mb-2'; input.placeholder='Type to search display sets...';
const results = document.createElement('div'); results.className='displayset-search-results'; body.appendChild(input); body.appendChild(results);
dialog.appendChild(header); dialog.appendChild(body); overlay.appendChild(dialog); document.body.appendChild(overlay); input.focus();
var timer = null;
input.addEventListener('input', function(){ if(timer) clearTimeout(timer); timer = setTimeout(function(){ var q = encodeURIComponent(input.value || ''); var url = window.location.origin + '/atlas/search/displaysets/?q=' + q + (caseId ? '&case=' + caseId : ''); fetch(url, {credentials:'same-origin'}).then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(e){ console.error('displayset picker fetch error', e); results.innerHTML = '<div class="alert alert-danger">Search failed</div>'; }); }, 300); });
results.addEventListener('click', function(e){ var item = e.target.closest('.list-group-item'); if(!item) return; var pk = item.getAttribute('data-ds-pk'); if(!pk) return; insertAtCursor(textarea, '[['+'displayset:'+pk+']]' ); if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); });
overlay.tabIndex = -1; overlay.focus(); fetch(window.location.origin + '/atlas/search/displaysets/').then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(){});
}catch(err){ console.error('openDisplaysetPicker error', err); }
}
})(window);
@@ -0,0 +1,20 @@
<div class="list-group">
{% if displaysets %}
{% for ds in displaysets %}
<div class="list-group-item d-flex justify-content-between align-items-center" data-ds-pk="{{ ds.pk }}">
<div class="flex-fill">
<div class="d-flex w-100 justify-content-between">
<strong class="mb-1">{{ ds.name }}</strong>
<small class="text-muted">{{ ds.pk }}</small>
</div>
<p class="mb-1 text-truncate">{{ ds.description }}</p>
</div>
<div class="ms-2">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_displaysets_detail' ds.pk %}">View</a>
</div>
</div>
{% endfor %}
{% else %}
<div class="list-group-item">No display sets found</div>
{% endif %}
</div>
@@ -0,0 +1,20 @@
<div class="list-group">
{% if findings %}
{% for f in findings %}
<div class="list-group-item d-flex justify-content-between align-items-center" data-finding-pk="{{ f.pk }}">
<div class="flex-fill">
<div class="d-flex w-100 justify-content-between">
<strong class="mb-1">{{ f.name }}</strong>
<small class="text-muted">{{ f.pk }}</small>
</div>
<p class="mb-1 text-truncate">{{ f.description }}</p>
</div>
<div class="ms-2">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:finding_detail' f.pk %}">View</a>
</div>
</div>
{% endfor %}
{% else %}
<div class="list-group-item">No findings found</div>
{% endif %}
</div>
@@ -0,0 +1,45 @@
<div class="displayset-search-widget">
{% with input_id=input_id|default:'displayset-search-input' target_id=target_id|default:'displayset-search-results' %}
<label for="{{ input_id }}" class="form-label">Search display sets</label>
<input id="{{ input_id }}" name="q" class="form-control" type="search"
placeholder="Type to search display sets..."
hx-get="{% url 'atlas:displayset_search_partial' %}"
hx-include="#{{ input_id }}"
{% if case %}hx-vals='{"case": "{{ case.pk }}"}'{% endif %}
hx-target="#{{ target_id }}"
hx-trigger="keyup changed delay:400ms"
autocomplete="off">
<div id="{{ target_id }}" class="mt-2">
{% include 'atlas/partials/_displayset_search_results.html' with displaysets=displaysets case=case %}
</div>
{% endwith %}
</div>
<script>
(function(){
try {
var input = document.getElementById('{{ input_id|default:"displayset-search-input" }}');
if (!input) return;
var timer = null;
input.addEventListener('input', function(){
if (timer) clearTimeout(timer);
timer = setTimeout(function(){
try {
var q = encodeURIComponent(input.value || '');
var url = "{% url 'atlas:displayset_search' %}?q=" + q + ({% if case %} "&case={{ case.pk }}" {% else %} '' {% endif %});
var target = '#{{ target_id|default:"displayset-search-results" }}';
if (window.htmx && typeof window.htmx.ajax === 'function') {
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML' });
} else {
fetch(url, { credentials: 'same-origin' }).then(function(r){ return r.text(); }).then(function(html){
var el = document.querySelector(target);
if (el) el.innerHTML = html;
}).catch(function(e){ console.error('displayset search fetch error', e); });
}
} catch(e) { console.error(e); }
}, 400);
});
} catch(e) { console.error('displayset search widget init error', e); }
})();
</script>
@@ -0,0 +1,45 @@
<div class="finding-search-widget">
{% with input_id=input_id|default:'finding-search-input' target_id=target_id|default:'finding-search-results' %}
<label for="{{ input_id }}" class="form-label">Search findings</label>
<input id="{{ input_id }}" name="q" class="form-control" type="search"
placeholder="Type to search findings..."
hx-get="{% url 'atlas:finding_search_partial' %}"
hx-include="#{{ input_id }}"
{% if case %}hx-vals='{"case": "{{ case.pk }}"}'{% endif %}
hx-target="#{{ target_id }}"
hx-trigger="keyup changed delay:400ms"
autocomplete="off">
<div id="{{ target_id }}" class="mt-2">
{% include 'atlas/partials/_finding_search_results.html' with findings=findings case=case %}
</div>
{% endwith %}
</div>
<script>
(function(){
try {
var input = document.getElementById('{{ input_id|default:"finding-search-input" }}');
if (!input) return;
var timer = null;
input.addEventListener('input', function(){
if (timer) clearTimeout(timer);
timer = setTimeout(function(){
try {
var q = encodeURIComponent(input.value || '');
var url = "{% url 'atlas:finding_search' %}?q=" + q + ({% if case %} "&case={{ case.pk }}" {% else %} '' {% endif %});
var target = '#{{ target_id|default:"finding-search-results" }}';
if (window.htmx && typeof window.htmx.ajax === 'function') {
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML' });
} else {
fetch(url, { credentials: 'same-origin' }).then(function(r){ return r.text(); }).then(function(html){
var el = document.querySelector(target);
if (el) el.innerHTML = html;
}).catch(function(e){ console.error('finding search fetch error', e); });
}
} catch(e) { console.error(e); }
}, 400);
});
} catch(e) { console.error('finding search widget init error', e); }
})();
</script>
+32
View File
@@ -36,3 +36,35 @@ def case_search_widget(context, collection=None, cases=None, recent_cases=None,
'selection_mode': selection_mode,
'action_url': action_url,
}
@register.inclusion_tag('atlas/partials/finding_search_widget.html', takes_context=True)
def finding_search_widget(context, case=None, findings=None, input_id='finding-search-input', target_id='finding-search-results'):
"""Render a reusable finding search widget, optionally scoped to a `case`.
Parameters:
- case: optional Case instance to scope findings to
- findings: optional initial queryset/list of findings to render
"""
request = context.get('request')
user = getattr(request, 'user', None)
return {
'request': request,
'case': case,
'findings': findings,
'input_id': input_id,
'target_id': target_id,
}
@register.inclusion_tag('atlas/partials/displayset_search_widget.html', takes_context=True)
def displayset_search_widget(context, case=None, displaysets=None, input_id='displayset-search-input', target_id='displayset-search-results'):
"""Render a reusable displayset search widget scoped to a `case` by default."""
request = context.get('request')
return {
'request': request,
'case': case,
'displaysets': displaysets,
'input_id': input_id,
'target_id': target_id,
}
+4
View File
@@ -445,6 +445,10 @@ urlpatterns = [
path("categories/", views.categories_list, name="categories_list"),
path("categories/search_partial/", views.categories_search_partial, name="categories_search_partial"),
path("search/cases/", views.case_search_partial, name="case_search_partial"),
path("search/findings/", views.finding_search, name="finding_search"),
path("search/findings/partial/", views.finding_search, name="finding_search_partial"),
path("search/displaysets/", views.displayset_search, name="displayset_search"),
path("search/displaysets/partial/", views.displayset_search, name="displayset_search_partial"),
path("search/cases/page/", views.case_search_page, name="case_search_page"),
path("normals/", views.NormalCaseList.as_view(), name="normals_list"),
path("condition/<int:pk>", views.condition_detail, name="condition_detail"),
+42
View File
@@ -359,6 +359,48 @@ def case_search_partial(request):
return HttpResponse(html)
@login_required
def finding_search(request):
"""HTMX endpoint returning findings, optionally filtered by case and query."""
if not request.htmx:
return HttpResponse(status=400)
q = request.GET.get('q', '').strip()
case_id = request.GET.get('case')
qs = Finding.objects.all()
if case_id:
try:
cid = int(case_id)
qs = qs.filter(case__pk=cid)
except Exception:
pass
if q:
qs = qs.filter(name__icontains=q)
qs = qs.order_by('name')[:50]
html = render_to_string('atlas/partials/_finding_search_results.html', {'findings': qs}, request=request)
return HttpResponse(html)
@login_required
def displayset_search(request):
"""HTMX endpoint returning display sets, scoped to a case if provided."""
if not request.htmx:
return HttpResponse(status=400)
q = request.GET.get('q', '').strip()
case_id = request.GET.get('case')
qs = CaseDisplaySet.objects.all()
if case_id:
try:
cid = int(case_id)
qs = qs.filter(case__pk=cid)
except Exception:
pass
if q:
qs = qs.filter(name__icontains=q)
qs = qs.order_by('name')[:50]
html = render_to_string('atlas/partials/_displayset_search_results.html', {'displaysets': qs}, request=request)
return HttpResponse(html)
class AuthorOrCheckerRequiredMixin(object):
def get_object(self, *args, **kwargs):
obj = super().get_object(*args, **kwargs)