Add case inline editing functionality and search widget improvements

This commit is contained in:
Ross
2026-03-02 12:23:51 +00:00
parent 4e6f72e045
commit 4c1e0c3d2a
11 changed files with 575 additions and 85 deletions
@@ -1,7 +1,9 @@
<div id="add-case-form" class="p-2 border rounded"> <div id="add-case-form" class="p-2 border rounded">
<p class="mb-2">Add an existing case to this collection. You can also use the case overview view to add multiple cases at once: <a href="{% url 'atlas:case_view' %}">case overview</a>.</p> <p class="mb-2">Add an existing case to this collection. You can also use the case overview view to add multiple cases at once: <a href="{% url 'atlas:case_view' %}">case overview</a>.</p>
{% include 'atlas/partials/case_search_widget.html' %} {% load case_widgets %}
{% url 'atlas:add_case_to_collection' collection.pk as add_case_url %}
{% case_search_widget collection=collection cases=cases recent_cases=recent_cases selection_mode='multiple' action_url=add_case_url input_id='case-search-input-collection-'|add:collection.pk|stringformat:'s' target_id='case-search-results-collection-'|add:collection.pk|stringformat:'s' %}
<div class="mt-2"> <div class="mt-2">
<button class="btn btn-sm btn-secondary" _="on click remove #add-case-form">Close</button> <button class="btn btn-sm btn-secondary" _="on click remove #add-case-form">Close</button>
+157 -10
View File
@@ -24,8 +24,91 @@
{% endfor %} {% endfor %}
{% endpartialdef %} {% endpartialdef %}
<style>
.case-inline-field {
position: relative;
}
.case-inline-field .quick-edit-button {
border: 0;
background: transparent;
color: var(--bs-secondary-color, #6c757d);
font-size: 0.8rem;
line-height: 1.1;
padding: 0;
text-decoration: none;
}
.case-inline-field .quick-edit-button:hover {
text-decoration: underline;
}
.case-inline-field .quick-edit-compact {
position: absolute;
top: 0;
right: 0;
z-index: 2;
opacity: 0.35;
pointer-events: auto;
}
.case-inline-field .quick-edit-trigger {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
.case-inline-field:hover .quick-edit-trigger,
.case-inline-field:focus-within .quick-edit-trigger {
opacity: 0.65;
pointer-events: auto;
}
.case-inline-field .quick-edit-trigger:hover {
opacity: 1;
}
.case-inline-field .quick-edit-compact.quick-edit-trigger {
opacity: 0.35;
pointer-events: auto;
}
.case-inline-field:hover .quick-edit-compact.quick-edit-trigger,
.case-inline-field:focus-within .quick-edit-compact.quick-edit-trigger {
opacity: 0.9;
}
/* Generic hover-only quick-edit behaviour for host-wrapped buttons */
.quick-edit-wrap { position: relative; display: inline-flex; align-items: center; }
.quick-edit-wrap .quick-edit-button {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
.quick-edit-wrap:hover .quick-edit-button,
.quick-edit-wrap:focus-within .quick-edit-button {
opacity: 0.65;
pointer-events: auto;
}
.quick-edit-wrap .quick-edit-button:hover { opacity: 1; }
</style>
<div class="atlas {% if case.scrapped %}atlas-scrapped{% endif %}"> <div class="atlas {% if case.scrapped %}atlas-scrapped{% endif %}">
<h2>Case: {{ case.title }}</h2> <h2 class="d-flex align-items-center" style="gap: .5rem; white-space: nowrap;">
<span>Case:</span>
<div class="d-flex align-items-center quick-edit-wrap">
<span id="case-inline-title"
hx-get="{% url 'atlas:case_inline_field' case.pk 'title' %}?show_label=0"
hx-trigger="load"
hx-swap="innerHTML">{{ case.title }}</span>
{% if can_edit %}
<button class="btn btn-sm btn-link text-muted p-0 ms-2 quick-edit-button"
hx-get="{% url 'atlas:case_inline_field' case.pk 'title' %}?edit=1&show_label=0"
hx-target="#case-inline-title"
hx-swap="innerHTML">quick edit</button>
{% endif %}
</div>
</h2>
{% if case.previous_case or case.next_case %} {% if case.previous_case or case.next_case %}
<div class="alert alert-info" role="alert"> <div class="alert alert-info" role="alert">
This case is part of a <a href='{% url "atlas:linked_cases_overview" case.pk %}'>series.</a><br/> This case is part of a <a href='{% url "atlas:linked_cases_overview" case.pk %}'>series.</a><br/>
@@ -144,8 +227,22 @@
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{# Diagnostic certainty as a badge #} {# Diagnostic certainty quick inline editor #}
Diagnostic certainty: <span class="badge bg-info text-dark ms-2">{{ case.get_diagnostic_certainty_display }}</span> <div class="d-flex align-items-center quick-edit-wrap">
<span>Diagnostic certainty:</span>
{% if can_edit %}
<button class="btn btn-sm btn-link text-muted p-0 ms-2 quick-edit-button"
hx-get="{% url 'atlas:case_inline_field' case.pk 'diagnostic_certainty' %}?edit=1&show_label=0"
hx-target="#case-inline-diagnostic_certainty"
hx-swap="innerHTML">quick edit</button>
{% endif %}
<div id="case-inline-diagnostic_certainty" class="ms-2"
hx-get="{% url 'atlas:case_inline_field' case.pk 'diagnostic_certainty' %}?show_label=0"
hx-trigger="load"
hx-swap="innerHTML">
<span class="badge bg-info text-dark">{{ case.get_diagnostic_certainty_display }}</span>
</div>
</div>
{# Normal toggle HTMX block #} {# Normal toggle HTMX block #}
<div class="mt-1"> <div class="mt-1">
{% include "atlas/partials/_normal_toggle.html" %} {% include "atlas/partials/_normal_toggle.html" %}
@@ -163,7 +260,21 @@
></div> ></div>
</details> </details>
<p class="pre-whitespace"><b>Description:</b> {{ case.description }}</p> <div class="d-flex align-items-start justify-content-between quick-edit-wrap">
<div class="d-flex align-items-start flex-grow-1">
<strong>Description:</strong>
<div id="case-inline-description" class="ms-2 flex-grow-1"
hx-get="{% url 'atlas:case_inline_field' case.pk 'description' %}?show_label=0"
hx-trigger="load"
hx-swap="innerHTML">{{ case.description }}</div>
</div>
{% if can_edit %}
<button class="btn btn-sm btn-link text-muted p-0 ms-2 quick-edit-button"
hx-get="{% url 'atlas:case_inline_field' case.pk 'description' %}?edit=1&show_label=0"
hx-target="#case-inline-description"
hx-swap="innerHTML">quick edit</button>
{% endif %}
</div>
<p> <p>
<b>Presentation:</b> <b>Presentation:</b>
@@ -177,25 +288,61 @@
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
<div class="col-12 col-md-12"> <div class="col-12 col-md-12">
<div class="card h-100"> <div class="card h-100">
<div class="card-header">History</div> <div class="card-header d-flex justify-content-between align-items-center quick-edit-wrap">History
{% if can_edit %}
<button class="btn btn-sm btn-link text-muted p-0 quick-edit-button"
hx-get="{% url 'atlas:case_inline_field' case.pk 'history' %}?edit=1&show_label=0"
hx-target="#case-inline-history"
hx-swap="innerHTML">quick edit</button>
{% endif %}
</div>
<div class="card-body"> <div class="card-body">
<div class="pre-whitespace">{{ case.history|linebreaks }}</div> <div id="case-inline-history"
hx-get="{% url 'atlas:case_inline_field' case.pk 'history' %}?show_label=0"
hx-trigger="load"
hx-swap="innerHTML">
<div class="pre-whitespace">{{ case.history|linebreaks }}</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-12 col-md-12"> <div class="col-12 col-md-12">
<div class="card h-100"> <div class="card h-100">
<div class="card-header">Discussion</div> <div class="card-header d-flex justify-content-between align-items-center quick-edit-wrap">Discussion
{% if can_edit %}
<button class="btn btn-sm btn-link text-muted p-0 quick-edit-button"
hx-get="{% url 'atlas:case_inline_field' case.pk 'discussion' %}?edit=1&show_label=0"
hx-target="#case-inline-discussion"
hx-swap="innerHTML">quick edit</button>
{% endif %}
</div>
<div class="card-body"> <div class="card-body">
<div class="pre-whitespace">{{ case.discussion|linebreaks }}</div> <div id="case-inline-discussion"
hx-get="{% url 'atlas:case_inline_field' case.pk 'discussion' %}?show_label=0"
hx-trigger="load"
hx-swap="innerHTML">
<div class="pre-whitespace">{{ case.discussion|linebreaks }}</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-12 col-md-12"> <div class="col-12 col-md-12">
<div class="card h-100"> <div class="card h-100">
<div class="card-header">Report</div> <div class="card-header d-flex justify-content-between align-items-center quick-edit-wrap">Report
{% if can_edit %}
<button class="btn btn-sm btn-link text-muted p-0 quick-edit-button"
hx-get="{% url 'atlas:case_inline_field' case.pk 'report' %}?edit=1&show_label=0"
hx-target="#case-inline-report"
hx-swap="innerHTML">quick edit</button>
{% endif %}
</div>
<div class="card-body"> <div class="card-body">
<div class="pre-whitespace">{{ case.report|linebreaks }}</div> <div id="case-inline-report"
hx-get="{% url 'atlas:case_inline_field' case.pk 'report' %}?show_label=0"
hx-trigger="load"
hx-swap="innerHTML">
<div class="pre-whitespace">{{ case.report|linebreaks }}</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -2,39 +2,52 @@
<div class="list-group"> <div class="list-group">
{% if cases %} {% if cases %}
{% for case in cases %} {% for case in cases %}
<a href="{% url 'atlas:case_detail' case.pk %}" class="list-group-item list-group-item-action"> <div class="list-group-item d-flex justify-content-between align-items-center" data-case-pk="{{ case.pk }}">
<div class="d-flex w-100 justify-content-between"> <div class="flex-fill me-3">
<h6 class="mb-1">{{ case.title }}</h6> <div class="d-flex w-100 justify-content-between">
<small class="text-muted">{{ case.created_date|date:"Y-m-d" }}</small> <h6 class="mb-1">{{ case.title }}</h6>
</div> <small class="text-muted">{{ case.created_date|date:"Y-m-d" }}</small>
<div class="mb-1"> </div>
<small class="text-muted me-2">{% if case.author.all %}By {{ case.author.all|join:", " }}{% endif %}</small> <div class="mb-1">
{% for cond in case.condition.all %} <small class="text-muted me-2">{% if case.author.all %}By {{ case.author.all|join:", " }}{% endif %}</small>
{% if cond.name %}<span class="badge bg-secondary me-1" title="Condition: {{ cond.name }}">{{ cond.name }}</span>{% endif %} {% for cond in case.condition.all %}
{% endfor %} {% if cond.name %}<span class="badge bg-secondary me-1" title="Condition: {{ cond.name }}">{{ cond.name }}</span>{% endif %}
{% for pres in case.presentation.all %} {% endfor %}
{% if pres.name %}<span class="badge bg-secondary me-1" title="Presentation: {{ pres.name }}">{{ pres.name }}</span>{% endif %} {% for pres in case.presentation.all %}
{% endfor %} {% if pres.name %}<span class="badge bg-secondary me-1" title="Presentation: {{ pres.name }}">{{ pres.name }}</span>{% endif %}
{% for ss in case.subspecialty.all %} {% endfor %}
{% if ss.name %}<span class="badge bg-secondary" title="Subspecialty: {{ ss.name }}">{{ ss.name }}</span>{% endif %} {% for ss in case.subspecialty.all %}
{% endfor %} {% if ss.name %}<span class="badge bg-secondary" title="Subspecialty: {{ ss.name }}">{{ ss.name }}</span>{% endif %}
</div> {% endfor %}
</div>
<div class="mb-1"> <div class="mb-1">
{# show up to three series modalities/examinations if present #} {# show up to three series modalities/examinations if present #}
{% for s in case.series.all|slice:":3" %} {% for s in case.series.all|slice:":3" %}
{% if s.modality and s.modality.modality or s.examination and s.examination.examination %} {% if s.modality and s.modality.modality or s.examination and s.examination.examination %}
<span class="badge bg-light text-dark me-1" title="Series: {% if s.modality and s.modality.modality %}{{ s.modality.modality }}{% endif %}{% if s.examination and s.examination.examination %} / {{ s.examination.examination }}{% endif %}"> <span class="badge bg-light text-dark me-1" title="Series: {% if s.modality and s.modality.modality %}{{ s.modality.modality }}{% endif %}{% if s.examination and s.examination.examination %} / {{ s.examination.examination }}{% endif %}">
{% if s.modality and s.modality.modality %}{{ s.modality.modality }}{% endif %}{% if s.examination and s.examination.examination %}{% if s.modality and s.modality.modality %} / {% endif %}{{ s.examination.examination }}{% endif %} {% if s.modality and s.modality.modality %}{{ s.modality.modality }}{% endif %}{% if s.examination and s.examination.examination %}{% if s.modality and s.modality.modality %} / {% endif %}{{ s.examination.examination }}{% endif %}
</span> </span>
{% endif %} {% endif %}
{% empty %} {% empty %}
<small class="text-muted">No series</small> <small class="text-muted">No series</small>
{% endfor %} {% endfor %}
</div> </div>
<p class="mb-1 text-truncate">{{ case.description }}</p> <p class="mb-1 text-truncate">{{ case.description }}</p>
</a> </div>
<div class="ms-2">
{% if collection %}
<form class="m-0" hx-post="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-target="#full-question-list" hx-swap="beforeend">
{% csrf_token %}
<input type="hidden" name="case" value="{{ case.pk }}">
<button type="submit" class="btn btn-sm btn-outline-primary">Add</button>
</form>
{% else %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_displaysets' case.pk %}">View</a>
{% endif %}
</div>
</div>
{% endfor %} {% endfor %}
{% else %} {% else %}
<div class="list-group-item">No cases found</div> <div class="list-group-item">No cases found</div>
@@ -0,0 +1,93 @@
<div class="case-inline-field">
{% if show_label or editing %}
<div class="d-flex {% if show_label %}justify-content-between{% else %}justify-content-end{% endif %} align-items-start gap-2">
{% if show_label %}
<strong>{{ field_label }}:</strong>
{% endif %}
{% if can_edit %}
{% if editing %}
<button class="btn btn-sm btn-outline-secondary"
type="button"
hx-get="{{ endpoint_url_default }}"
hx-target="#{{ container_id }}"
hx-swap="innerHTML">
Cancel
</button>
{% else %}
<button class="quick-edit-trigger quick-edit-button"
type="button"
hx-get="{{ endpoint_url_edit }}"
hx-target="#{{ container_id }}"
hx-swap="innerHTML">
quick edit
</button>
{% endif %}
{% endif %}
</div>
{% endif %}
{% if editing %}
<form hx-post="{{ endpoint_url_default }}"
hx-target="#{{ container_id }}"
hx-swap="innerHTML"
class="mt-2">
{% csrf_token %}
{% if field_kind == "textarea" %}
<textarea class="form-control" name="value" rows="4">{{ value|default_if_none:"" }}</textarea>
{% elif field_kind == "select" %}
<select class="form-select" name="value">
{% for option_value, option_label in select_choices %}
<option value="{{ option_value }}" {% if value|stringformat:"s" == option_value %}selected{% endif %}>{{ option_label }}</option>
{% endfor %}
</select>
{% else %}
<input class="form-control" type="text" name="value" value="{{ value|default_if_none:"" }}" />
{% endif %}
{% if error_message %}
<div class="text-danger small mt-1">{{ error_message }}</div>
{% endif %}
{% if suggestions %}
<div class="mt-2">
<div class="small text-muted mb-1">Suggested options</div>
<div class="d-flex flex-wrap gap-1">
{% for suggestion in suggestions %}
<button type="button"
class="btn btn-sm btn-outline-info"
onclick="(function(btn){const f=btn.closest('form'); const el=f.querySelector('[name=value]'); if(!el){return;} el.value=btn.getAttribute('data-suggestion') || ''; if(el.tagName==='TEXTAREA' || el.tagName==='INPUT'){el.focus();}})(this)"
data-suggestion="{{ suggestion }}">
{{ suggestion|truncatechars:80 }}
</button>
{% endfor %}
</div>
</div>
{% endif %}
<div class="mt-2">
<button class="btn btn-sm btn-primary" type="submit">Save</button>
</div>
</form>
{% else %}
{% if field_kind == "textarea" %}
{% if display_value %}
<div class="pre-whitespace{% if show_label %} mt-1{% endif %}">{{ display_value|linebreaks }}</div>
{% else %}
<div class="{% if show_label %}mt-1 {% endif %}text-muted">Not set</div>
{% endif %}
{% elif field_name == "diagnostic_certainty" %}
<div class="{% if show_label %}mt-1{% endif %}">
{% if show_label %}Diagnostic certainty: {% endif %}
<span class="badge bg-info text-dark ms-1">{{ display_value|default:"Not set" }}</span>
</div>
{% else %}
<div class="{% if show_label %}mt-1{% endif %}">
{% if display_value %}
{{ display_value }}
{% else %}
<span class="text-muted">Not set</span>
{% endif %}
</div>
{% endif %}
{% endif %}
</div>
@@ -2,25 +2,38 @@
{% if cases and cases|length > 0 %} {% if cases and cases|length > 0 %}
<div class="list-group mb-2"> <div class="list-group mb-2">
{% for case in cases %} {% for case in cases %}
<a href="{% url 'atlas:case_detail' case.pk %}" class="list-group-item list-group-item-action"> <div class="list-group-item d-flex justify-content-between align-items-center" data-case-pk="{{ case.pk }}">
<div class="d-flex w-100 justify-content-between"> <div class="flex-fill me-3">
<h6 class="mb-1">{{ case.title }}</h6> <div class="d-flex w-100 justify-content-between">
<small class="text-muted">{{ case.created_date|date:"Y-m-d" }}</small> <h6 class="mb-1">{{ case.title }}</h6>
<small class="text-muted">{{ case.created_date|date:"Y-m-d" }}</small>
</div>
<div class="mb-1">
<small class="text-muted me-2">{% if case.author.all %}By {{ case.author.all|join:", " }}{% endif %}</small>
{% for cond in case.condition.all %}
{% if cond.name %}<span class="badge bg-secondary me-1" title="Condition: {{ cond.name }}">{{ cond.name }}</span>{% endif %}
{% endfor %}
{% for pres in case.presentation.all %}
{% if pres.name %}<span class="badge bg-secondary me-1" title="Presentation: {{ pres.name }}">{{ pres.name }}</span>{% endif %}
{% endfor %}
{% for ss in case.subspecialty.all %}
{% if ss.name %}<span class="badge bg-secondary" title="Subspecialty: {{ ss.name }}">{{ ss.name }}</span>{% endif %}
{% endfor %}
</div>
<p class="mb-1 text-truncate">{% if case.description %}{{ case.description|truncatechars:140 }}{% endif %}</p>
</div> </div>
<div class="mb-1"> <div class="ms-2">
<small class="text-muted me-2">{% if case.author.all %}By {{ case.author.all|join:", " }}{% endif %}</small> {% if collection %}
{% for cond in case.condition.all %} <form class="m-0" hx-post="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-target="#full-question-list" hx-swap="beforeend">
{% if cond.name %}<span class="badge bg-secondary me-1" title="Condition: {{ cond.name }}">{{ cond.name }}</span>{% endif %} {% csrf_token %}
{% endfor %} <input type="hidden" name="case" value="{{ case.pk }}">
{% for pres in case.presentation.all %} <button type="submit" class="btn btn-sm btn-outline-primary">Add</button>
{% if pres.name %}<span class="badge bg-secondary me-1" title="Presentation: {{ pres.name }}">{{ pres.name }}</span>{% endif %} </form>
{% endfor %} {% else %}
{% for ss in case.subspecialty.all %} <a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_displaysets' case.pk %}">View</a>
{% if ss.name %}<span class="badge bg-secondary" title="Subspecialty: {{ ss.name }}">{{ ss.name }}</span>{% endif %} {% endif %}
{% endfor %}
</div> </div>
<p class="mb-1 text-truncate">{% if case.description %}{{ case.description|truncatechars:140 }}{% endif %}</p> </div>
</a>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
@@ -1,45 +1,47 @@
<div class="case-search-widget"> <div class="case-search-widget">
<label for="case-search-input" class="form-label">Search cases</label> {% with input_id=input_id|default:'case-search-input' target_id=target_id|default:'case-search-results' %}
<input id="case-search-input" name="q" class="form-control" type="search" <label for="{{ input_id }}" class="form-label">Search cases</label>
<input id="{{ input_id }}" name="q" class="form-control" type="search"
placeholder="Type to search cases..." placeholder="Type to search cases..."
hx-get="{% url 'atlas:case_search' %}" hx-get="{% url 'atlas:case_search' %}"
hx-include="#case-search-input" hx-include="#{{ input_id }}"
hx-vals='{"collection": "{{ collection.pk }}"}' {% if collection %}hx-vals='{"collection": "{{ collection.pk }}"}'{% endif %}
hx-target="#case-search-results" hx-target="#{{ target_id }}"
hx-trigger="keyup changed delay:400ms" hx-trigger="keyup changed delay:400ms"
autocomplete="off"> autocomplete="off">
<div id="case-search-results" class="mt-2"> <div id="{{ target_id }}" class="mt-2">
{# Render initial results (search + recent) on initial GET; HTMX searches will replace this div with partial results #} {# Render initial results (search + recent) on initial GET; HTMX searches will replace this div with partial results #}
{% include 'atlas/partials/case_search_results.html' with cases=cases recent_cases=recent_cases collection=collection %} {% include 'atlas/partials/case_search_results.html' with cases=cases recent_cases=recent_cases collection=collection %}
</div> </div>
{% endwith %}
</div> </div>
<script> <script>
(function(){ (function(){
// Robust fallback: ensure repeated searches always work by using htmx.ajax when available.
try { try {
var input = document.getElementById('case-search-input'); var input = document.getElementById('{{ input_id|default:"case-search-input" }}');
if (!input) return; if (!input) return;
var timer = null; var timer = null;
input.addEventListener('input', function(){ input.addEventListener('input', function(){
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer);
timer = setTimeout(function(){ timer = setTimeout(function(){
try { try {
var q = encodeURIComponent(input.value || ''); var q = encodeURIComponent(input.value || '');
var url = "{% url 'atlas:case_search' %}?q=" + q + "&collection={{ collection.pk }}"; var url = "{% url 'atlas:case_search' %}?q=" + q + ({% if collection %} "&collection={{ collection.pk }}" {% else %} '' {% endif %});
var target = '#{{ target_id|default:"case-search-results" }}';
if (window.htmx && typeof window.htmx.ajax === 'function') { if (window.htmx && typeof window.htmx.ajax === 'function') {
window.htmx.ajax('GET', url, { target: '#case-search-results', swap: 'innerHTML' }); window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML' });
} else { } else {
// fallback to fetch
fetch(url, { credentials: 'same-origin' }).then(function(r){ return r.text(); }).then(function(html){ fetch(url, { credentials: 'same-origin' }).then(function(r){ return r.text(); }).then(function(html){
var el = document.getElementById('case-search-results'); var el = document.querySelector(target);
if (el) el.innerHTML = html; if (el) el.innerHTML = html;
}).catch(function(e){ console.error('case search fetch error', e); }); }).catch(function(e){ console.error('case search fetch error', e); });
} }
} catch(e) { console.error(e); } } catch(e) { console.error(e); }
}, 400); }, 400);
}); });
// No multi-select action bar; items provide per-item Add buttons.
} catch(e) { console.error('case search widget init error', e); } } catch(e) { console.error('case search widget init error', e); }
})(); })();
</script> </script>
+2 -12
View File
@@ -1,4 +1,5 @@
{% extends 'atlas/base.html' %} {% extends 'atlas/base.html' %}
{% load case_widgets %}
{% block content %} {% block content %}
<h2>Uploaded dicoms</h2> <h2>Uploaded dicoms</h2>
@@ -178,18 +179,7 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="mb-2"> <div class="mb-2">
<label for="case-search-input-modal" class="form-label">Search cases</label> {% case_search_widget collection=None input_id='case-search-input-modal' target_id='case-search-results-modal' %}
<input id="case-search-input-modal" name="q" class="form-control" type="search"
placeholder="Type to search cases..."
hx-get="{% url 'atlas:case_search' %}"
hx-include="#case-search-input-modal"
hx-target="#case-search-results-modal"
hx-trigger="keyup changed delay:400ms"
autocomplete="off">
</div>
<div id="case-search-results-modal" class="mt-2">
<!-- initial empty state; results will be loaded by HTMX -->
<div class="text-muted small">Type to search cases to import into.</div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
+1
View File
@@ -0,0 +1 @@
# package marker for atlas templatetags
+38
View File
@@ -0,0 +1,38 @@
from django import template
from atlas.helpers import get_cases_available_to_user
register = template.Library()
@register.inclusion_tag('atlas/partials/case_search_widget.html', takes_context=True)
def case_search_widget(context, collection=None, cases=None, recent_cases=None, input_id='case-search-input', target_id='case-search-results', selection_mode='single', action_url=None):
"""Render a reusable case search widget.
Parameters:
- collection: optional Collection object to scope searches
- cases: optional initial queryset/list of cases to render
- recent_cases: optional recent_cases list; if omitted, populate with user's recent cases
- input_id: DOM id to use for the search input
- target_id: DOM id to use for results container
"""
request = context.get('request')
user = getattr(request, 'user', None)
# If recent_cases not provided, default to user's recent cases
if recent_cases is None and user is not None and user.is_authenticated:
try:
recent_cases = get_cases_available_to_user(user, collection=collection).filter(author=user).order_by('-created_date')[:5]
except Exception:
recent_cases = []
return {
'request': request,
'collection': collection,
'cases': cases,
'recent_cases': recent_cases,
'input_id': input_id,
'target_id': target_id,
'selection_mode': selection_mode,
'action_url': action_url,
}
+1
View File
@@ -549,6 +549,7 @@ urlpatterns = [
# path("unchecked/", views.unchecked_list, name="unchecked_list"), # path("unchecked/", views.unchecked_list, name="unchecked_list"),
# path("verified/<int:pk>/", views.verified_detail, name="verified_detail"), # path("verified/<int:pk>/", views.verified_detail, name="verified_detail"),
path("case/<int:pk>/", views.case_detail, name="case_detail"), path("case/<int:pk>/", views.case_detail, name="case_detail"),
path("case/<int:pk>/inline-field/<str:field_name>/", views.case_inline_field, name="case_inline_field"),
path("case/<int:pk>/toggle_normal/", views.toggle_case_normal, name="case_toggle_normal"), path("case/<int:pk>/toggle_normal/", views.toggle_case_normal, name="case_toggle_normal"),
path("case/<int:pk>/normal_form/", views.case_normal_form, name="case_normal_form"), path("case/<int:pk>/normal_form/", views.case_normal_form, name="case_normal_form"),
path("case/<int:pk>/create_normal/", views.create_case_normal, name="case_create_normal"), path("case/<int:pk>/create_normal/", views.create_case_normal, name="case_create_normal"),
+191 -1
View File
@@ -754,6 +754,195 @@ def case_detail(request, pk):
) )
CASE_INLINE_FIELD_CONFIG = {
"title": {"label": "Title", "kind": "text"},
"description": {"label": "Description", "kind": "textarea"},
"history": {"label": "History", "kind": "textarea"},
"discussion": {"label": "Discussion", "kind": "textarea"},
"report": {"label": "Report", "kind": "textarea"},
"diagnostic_certainty": {"label": "Diagnostic certainty", "kind": "select"},
}
def _dedupe_non_empty(values):
out = []
seen = set()
for value in values:
text = (value or "").strip()
if not text or text in seen:
continue
seen.add(text)
out.append(text)
return out
def _get_case_inline_suggestions(case, field_name):
condition_names = [c.name for c in case.condition.all()[:3]]
presentation_names = [p.name for p in case.presentation.all()[:3]]
procedure_names = [p.name for p in case.procedures.all()[:3]]
conditions = ", ".join(condition_names)
presentations = ", ".join(presentation_names)
procedures = ", ".join(procedure_names)
first_series = None
try:
ordered = case.get_ordered_series()
if ordered:
first_series = ordered[0]
except Exception:
first_series = None
modality_text = ""
examination_text = ""
if first_series is not None:
try:
if first_series.modality is not None:
modality_text = str(first_series.modality)
except Exception:
modality_text = ""
try:
if first_series.examination is not None:
examination_text = str(first_series.examination)
except Exception:
examination_text = ""
if field_name == "title":
return _dedupe_non_empty([
conditions,
f"{conditions} presenting with {presentations}" if conditions and presentations else "",
presentations,
f"{modality_text} {examination_text}" if modality_text or examination_text else "",
])[:4]
if field_name == "description":
return _dedupe_non_empty([
f"{modality_text} {examination_text}".strip(),
f"Case demonstrating {conditions}" if conditions else "",
f"Key presentation: {presentations}" if presentations else "",
])[:4]
if field_name == "history":
return _dedupe_non_empty([
f"Presentation: {presentations}." if presentations else "",
f"Relevant background includes {procedures}." if procedures else "",
f"Working diagnosis: {conditions}." if conditions else "",
])[:4]
if field_name == "discussion":
return _dedupe_non_empty([
f"Imaging findings are most consistent with {conditions}." if conditions else "",
f"Differential considerations include {conditions}." if conditions else "",
"Correlation with clinical history and prior imaging is advised.",
])[:4]
if field_name == "report":
return _dedupe_non_empty([
f"Findings are in keeping with {conditions}." if conditions else "",
f"Clinical indication: {presentations}." if presentations else "",
"No acute abnormality identified.",
])[:4]
return []
def _validate_case_inline_value(field_name, raw_value):
if field_name == "diagnostic_certainty":
try:
parsed = int(raw_value)
except Exception:
return None, "Please choose a valid diagnostic certainty option."
allowed = {choice.value for choice in Case.CertaintyChoices}
if parsed not in allowed:
return None, "Please choose a valid diagnostic certainty option."
return parsed, None
value = (raw_value or "").strip()
if field_name == "title":
if not value:
return None, "Title cannot be empty."
if len(value) > 255:
return None, "Title must be 255 characters or fewer."
return value, None
@login_required
@user_has_case_view_access
@require_http_methods(["GET", "POST"])
def case_inline_field(request, pk, field_name):
case = get_case_for_case_detail(pk)
field_cfg = CASE_INLINE_FIELD_CONFIG.get(field_name)
if field_cfg is None:
raise Http404("Unknown inline-edit field.")
can_edit = case.check_user_can_edit(request.user)
editing = request.GET.get("edit") == "1"
show_label = request.GET.get("show_label", "1") not in ("0", "false", "False")
error_message = ""
if request.method == "POST":
if not can_edit:
return HttpResponse(status=403)
editing = True
cleaned_value, error_message = _validate_case_inline_value(
field_name=field_name,
raw_value=request.POST.get("value", ""),
)
if error_message:
value = request.POST.get("value", "")
else:
setattr(case, field_name, cleaned_value)
case.save(update_fields=[field_name])
editing = False
value = getattr(case, field_name)
else:
value = getattr(case, field_name)
if editing and not can_edit:
editing = False
if field_name == "diagnostic_certainty":
display_value = case.get_diagnostic_certainty_display()
select_choices = [(str(choice.value), str(choice.label)) for choice in Case.CertaintyChoices]
else:
display_value = value
select_choices = []
endpoint_url = reverse("atlas:case_inline_field", kwargs={"pk": case.pk, "field_name": field_name})
if show_label:
endpoint_url_default = endpoint_url
endpoint_url_edit = f"{endpoint_url}?edit=1"
else:
endpoint_url_default = f"{endpoint_url}?show_label=0"
endpoint_url_edit = f"{endpoint_url}?edit=1&show_label=0"
html = render_to_string(
"atlas/partials/case_inline_field.html",
{
"case": case,
"field_name": field_name,
"field_label": field_cfg["label"],
"field_kind": field_cfg["kind"],
"value": value,
"display_value": display_value,
"editing": editing,
"show_label": show_label,
"can_edit": can_edit,
"error_message": error_message,
"suggestions": _get_case_inline_suggestions(case, field_name),
"select_choices": select_choices,
"endpoint_url_default": endpoint_url_default,
"endpoint_url_edit": endpoint_url_edit,
"container_id": f"case-inline-{field_name}",
},
request=request,
)
return HttpResponse(html)
@login_required @login_required
@require_POST @require_POST
def toggle_case_normal(request, pk): def toggle_case_normal(request, pk):
@@ -1744,10 +1933,11 @@ def case_search(request):
cases = cases_qs.order_by("title")[:30] cases = cases_qs.order_by("title")[:30]
multi = request.GET.get('multi') in ('1', 'true', 'True')
return render( return render(
request, request,
"atlas/partials/case_search_results.html", "atlas/partials/case_search_results.html",
{"cases": cases, "collection": collection, "recent_cases": recent_cases, "q": q}, {"cases": cases, "collection": collection, "recent_cases": recent_cases, "q": q, "multi_select": multi},
) )