Add finding and displayset search functionality with corresponding widgets and endpoints
This commit is contained in:
@@ -42,9 +42,9 @@
|
|||||||
if(t === 'case'){
|
if(t === 'case'){
|
||||||
openCasePicker(textarea);
|
openCasePicker(textarea);
|
||||||
} else if(t === 'finding'){
|
} 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'){
|
} 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); }
|
}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);
|
})(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>
|
||||||
@@ -36,3 +36,35 @@ def case_search_widget(context, collection=None, cases=None, recent_cases=None,
|
|||||||
'selection_mode': selection_mode,
|
'selection_mode': selection_mode,
|
||||||
'action_url': action_url,
|
'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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -445,6 +445,10 @@ urlpatterns = [
|
|||||||
path("categories/", views.categories_list, name="categories_list"),
|
path("categories/", views.categories_list, name="categories_list"),
|
||||||
path("categories/search_partial/", views.categories_search_partial, name="categories_search_partial"),
|
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/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("search/cases/page/", views.case_search_page, name="case_search_page"),
|
||||||
path("normals/", views.NormalCaseList.as_view(), name="normals_list"),
|
path("normals/", views.NormalCaseList.as_view(), name="normals_list"),
|
||||||
path("condition/<int:pk>", views.condition_detail, name="condition_detail"),
|
path("condition/<int:pk>", views.condition_detail, name="condition_detail"),
|
||||||
|
|||||||
@@ -359,6 +359,48 @@ def case_search_partial(request):
|
|||||||
return HttpResponse(html)
|
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):
|
class AuthorOrCheckerRequiredMixin(object):
|
||||||
def get_object(self, *args, **kwargs):
|
def get_object(self, *args, **kwargs):
|
||||||
obj = super().get_object(*args, **kwargs)
|
obj = super().get_object(*args, **kwargs)
|
||||||
|
|||||||
Reference in New Issue
Block a user