From 20b4d6b6af411d9071d5a3a2994608ea61c5ddfc Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 23 Mar 2026 13:34:28 +0000 Subject: [PATCH] Add finding and displayset search functionality with corresponding widgets and endpoints --- atlas/static/atlas/js/markup_inserter.js | 48 ++++++++++++++++++- .../partials/_displayset_search_results.html | 20 ++++++++ .../partials/_finding_search_results.html | 20 ++++++++ .../partials/displayset_search_widget.html | 45 +++++++++++++++++ .../atlas/partials/finding_search_widget.html | 45 +++++++++++++++++ atlas/templatetags/case_widgets.py | 32 +++++++++++++ atlas/urls.py | 4 ++ atlas/views.py | 42 ++++++++++++++++ 8 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 atlas/templates/atlas/partials/_displayset_search_results.html create mode 100644 atlas/templates/atlas/partials/_finding_search_results.html create mode 100644 atlas/templates/atlas/partials/displayset_search_widget.html create mode 100644 atlas/templates/atlas/partials/finding_search_widget.html diff --git a/atlas/static/atlas/js/markup_inserter.js b/atlas/static/atlas/js/markup_inserter.js index 1bc7a4cc..97edf4d5 100644 --- a/atlas/static/atlas/js/markup_inserter.js +++ b/atlas/static/atlas/js/markup_inserter.js @@ -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 = 'Select finding'; + 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 = '
Search failed
'; }); }, 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 = 'Select display set'; + 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 = '
Search failed
'; }); }, 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); diff --git a/atlas/templates/atlas/partials/_displayset_search_results.html b/atlas/templates/atlas/partials/_displayset_search_results.html new file mode 100644 index 00000000..43eb2768 --- /dev/null +++ b/atlas/templates/atlas/partials/_displayset_search_results.html @@ -0,0 +1,20 @@ +
+ {% if displaysets %} + {% for ds in displaysets %} +
+
+
+ {{ ds.name }} + {{ ds.pk }} +
+

{{ ds.description }}

+
+
+ View +
+
+ {% endfor %} + {% else %} +
No display sets found
+ {% endif %} +
diff --git a/atlas/templates/atlas/partials/_finding_search_results.html b/atlas/templates/atlas/partials/_finding_search_results.html new file mode 100644 index 00000000..f663d8da --- /dev/null +++ b/atlas/templates/atlas/partials/_finding_search_results.html @@ -0,0 +1,20 @@ +
+ {% if findings %} + {% for f in findings %} +
+
+
+ {{ f.name }} + {{ f.pk }} +
+

{{ f.description }}

+
+
+ View +
+
+ {% endfor %} + {% else %} +
No findings found
+ {% endif %} +
diff --git a/atlas/templates/atlas/partials/displayset_search_widget.html b/atlas/templates/atlas/partials/displayset_search_widget.html new file mode 100644 index 00000000..c5211de0 --- /dev/null +++ b/atlas/templates/atlas/partials/displayset_search_widget.html @@ -0,0 +1,45 @@ +
+ {% with input_id=input_id|default:'displayset-search-input' target_id=target_id|default:'displayset-search-results' %} + + + +
+ {% include 'atlas/partials/_displayset_search_results.html' with displaysets=displaysets case=case %} +
+ {% endwith %} +
+ + diff --git a/atlas/templates/atlas/partials/finding_search_widget.html b/atlas/templates/atlas/partials/finding_search_widget.html new file mode 100644 index 00000000..2e34d50d --- /dev/null +++ b/atlas/templates/atlas/partials/finding_search_widget.html @@ -0,0 +1,45 @@ +
+ {% with input_id=input_id|default:'finding-search-input' target_id=target_id|default:'finding-search-results' %} + + + +
+ {% include 'atlas/partials/_finding_search_results.html' with findings=findings case=case %} +
+ {% endwith %} +
+ + diff --git a/atlas/templatetags/case_widgets.py b/atlas/templatetags/case_widgets.py index c30b987f..a6fd3635 100644 --- a/atlas/templatetags/case_widgets.py +++ b/atlas/templatetags/case_widgets.py @@ -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, + } diff --git a/atlas/urls.py b/atlas/urls.py index 15ca3dcc..9645a34f 100755 --- a/atlas/urls.py +++ b/atlas/urls.py @@ -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/", views.condition_detail, name="condition_detail"), diff --git a/atlas/views.py b/atlas/views.py index 4c3e2b49..1161ab24 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -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)