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">
<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">
<button class="btn btn-sm btn-secondary" _="on click remove #add-case-form">Close</button>
+157 -10
View File
@@ -24,8 +24,91 @@
{% endfor %}
{% 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 %}">
<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 %}
<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/>
@@ -144,8 +227,22 @@
{% endfor %}
{% endif %}
{# Diagnostic certainty as a badge #}
Diagnostic certainty: <span class="badge bg-info text-dark ms-2">{{ case.get_diagnostic_certainty_display }}</span>
{# Diagnostic certainty quick inline editor #}
<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 #}
<div class="mt-1">
{% include "atlas/partials/_normal_toggle.html" %}
@@ -163,7 +260,21 @@
></div>
</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>
<b>Presentation:</b>
@@ -177,25 +288,61 @@
<div class="row g-3 mb-3">
<div class="col-12 col-md-12">
<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="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 class="col-12 col-md-12">
<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="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 class="col-12 col-md-12">
<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="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>
@@ -2,39 +2,52 @@
<div class="list-group">
{% if cases %}
{% for case in cases %}
<a href="{% url 'atlas:case_detail' case.pk %}" class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<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>
<div class="list-group-item d-flex justify-content-between align-items-center" data-case-pk="{{ case.pk }}">
<div class="flex-fill me-3">
<div class="d-flex w-100 justify-content-between">
<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>
<div class="mb-1">
{# show up to three series modalities/examinations if present #}
{% for s in case.series.all|slice:":3" %}
{% 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 %}">
{% 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>
{% endif %}
{% empty %}
<small class="text-muted">No series</small>
{% endfor %}
</div>
<div class="mb-1">
{# show up to three series modalities/examinations if present #}
{% for s in case.series.all|slice:":3" %}
{% 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 %}">
{% 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>
{% endif %}
{% empty %}
<small class="text-muted">No series</small>
{% endfor %}
</div>
<p class="mb-1 text-truncate">{{ case.description }}</p>
</a>
<p class="mb-1 text-truncate">{{ case.description }}</p>
</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 %}
{% else %}
<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 %}
<div class="list-group mb-2">
{% for case in cases %}
<a href="{% url 'atlas:case_detail' case.pk %}" class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">{{ case.title }}</h6>
<small class="text-muted">{{ case.created_date|date:"Y-m-d" }}</small>
<div class="list-group-item d-flex justify-content-between align-items-center" data-case-pk="{{ case.pk }}">
<div class="flex-fill me-3">
<div class="d-flex w-100 justify-content-between">
<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 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 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>
<p class="mb-1 text-truncate">{% if case.description %}{{ case.description|truncatechars:140 }}{% endif %}</p>
</a>
</div>
{% endfor %}
</div>
{% else %}
@@ -1,45 +1,47 @@
<div class="case-search-widget">
<label for="case-search-input" class="form-label">Search cases</label>
<input id="case-search-input" name="q" class="form-control" type="search"
{% with input_id=input_id|default:'case-search-input' target_id=target_id|default:'case-search-results' %}
<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..."
hx-get="{% url 'atlas:case_search' %}"
hx-include="#case-search-input"
hx-vals='{"collection": "{{ collection.pk }}"}'
hx-target="#case-search-results"
hx-include="#{{ input_id }}"
{% if collection %}hx-vals='{"collection": "{{ collection.pk }}"}'{% endif %}
hx-target="#{{ target_id }}"
hx-trigger="keyup changed delay:400ms"
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 #}
{% include 'atlas/partials/case_search_results.html' with cases=cases recent_cases=recent_cases collection=collection %}
</div>
{% endwith %}
</div>
<script>
(function(){
// Robust fallback: ensure repeated searches always work by using htmx.ajax when available.
try {
var input = document.getElementById('case-search-input');
var input = document.getElementById('{{ input_id|default:"case-search-input" }}');
if (!input) return;
var timer = null;
input.addEventListener('input', function(){
if (timer) clearTimeout(timer);
timer = setTimeout(function(){
try {
try {
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') {
window.htmx.ajax('GET', url, { target: '#case-search-results', swap: 'innerHTML' });
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML' });
} else {
// fallback to fetch
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;
}).catch(function(e){ console.error('case search fetch error', e); });
}
} catch(e) { console.error(e); }
}, 400);
});
// No multi-select action bar; items provide per-item Add buttons.
} catch(e) { console.error('case search widget init error', e); }
})();
</script>
+2 -12
View File
@@ -1,4 +1,5 @@
{% extends 'atlas/base.html' %}
{% load case_widgets %}
{% block content %}
<h2>Uploaded dicoms</h2>
@@ -178,18 +179,7 @@
</div>
<div class="modal-body">
<div class="mb-2">
<label for="case-search-input-modal" class="form-label">Search cases</label>
<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>
{% case_search_widget collection=None input_id='case-search-input-modal' target_id='case-search-results-modal' %}
</div>
</div>
<div class="modal-footer">