Enhance case inline editing and search functionality with improved UI and new subspecialty handling

This commit is contained in:
Ross
2026-03-02 12:41:25 +00:00
parent b1ec4b173e
commit 2a1db54a17
6 changed files with 171 additions and 147 deletions
+44 -40
View File
@@ -103,9 +103,9 @@
{% endwith %}
{% 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>
hx-get="{% url 'atlas:case_inline_field' case.pk 'title' %}?edit=1&show_label=0"
hx-target="#case-inline-title"
hx-swap="outerHTML">quick edit</button>
{% endif %}
</div>
</h2>
@@ -212,13 +212,23 @@
<div class="float-end text-end">
{# Subspecialty badges (preserve any anchor returned by get_link) #}
{% if case.subspecialty.all %}
{% for sub in case.subspecialty.all %}
<span class="badge bg-secondary ms-1">{{ sub.get_link|safe }}</span>
{% endfor %}
{% else %}
<span class="text-muted me-2">No subspecialty</span>
{% endif %}
<div class="quick-edit-wrap d-inline-block align-middle">
<span id="case-inline-subspecialty">
{% if case.subspecialty.all %}
{% for sub in case.subspecialty.all %}
<span class="badge bg-secondary ms-1">{{ sub.get_link|safe }}</span>
{% endfor %}
{% else %}
<span class="text-muted me-2">No subspecialty</span>
{% endif %}
</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 'subspecialty' %}?edit=1&show_label=0"
hx-target="#case-inline-subspecialty"
hx-swap="outerHTML">quick edit</button>
{% endif %}
</div>
{# Pathological process badges #}
{% if case.pathological_process.all %}
@@ -232,9 +242,9 @@
<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>
hx-get="{% url 'atlas:case_inline_field' case.pk 'diagnostic_certainty' %}?edit=1&show_label=0"
hx-target="#case-inline-diagnostic_certainty"
hx-swap="outerHTML">quick edit</button>
{% endif %}
{% url 'atlas:case_inline_field' case.pk 'diagnostic_certainty' as diag_url %}
{% with endpoint_default=diag_url|add:"?show_label=0" endpoint_edit=diag_url|add:"?edit=1&show_label=0" %}
@@ -268,9 +278,9 @@
</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>
hx-get="{% url 'atlas:case_inline_field' case.pk 'description' %}?edit=1&show_label=0"
hx-target="#case-inline-description"
hx-swap="outerHTML">quick edit</button>
{% endif %}
</div>
@@ -288,19 +298,17 @@
<div class="card h-100">
<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"
<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>
hx-swap="outerHTML">quick edit</button>
{% endif %}
</div>
<div class="card-body">
<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>
{% url 'atlas:case_inline_field' case.pk 'history' as history_url %}
{% with endpoint_default=history_url|add:"?show_label=0" endpoint_edit=history_url|add:"?edit=1&show_label=0" %}
{% include 'atlas/partials/case_inline_field.html' with show_label=0 editing=False field_label="History" can_edit=can_edit endpoint_url_default=endpoint_default endpoint_url_edit=endpoint_edit container_id='case-inline-history' field_kind='textarea' value=case.history display_value=case.history field_name='history' error_message='' %}
{% endwith %}
</div>
</div>
</div>
@@ -308,19 +316,17 @@
<div class="card h-100">
<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"
<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>
hx-swap="outerHTML">quick edit</button>
{% endif %}
</div>
<div class="card-body">
<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>
{% url 'atlas:case_inline_field' case.pk 'discussion' as discussion_url %}
{% with endpoint_default=discussion_url|add:"?show_label=0" endpoint_edit=discussion_url|add:"?edit=1&show_label=0" %}
{% include 'atlas/partials/case_inline_field.html' with show_label=0 editing=False field_label="Discussion" can_edit=can_edit endpoint_url_default=endpoint_default endpoint_url_edit=endpoint_edit container_id='case-inline-discussion' field_kind='textarea' value=case.discussion display_value=case.discussion field_name='discussion' error_message='' %}
{% endwith %}
</div>
</div>
</div>
@@ -328,19 +334,17 @@
<div class="card h-100">
<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"
<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>
hx-swap="outerHTML">quick edit</button>
{% endif %}
</div>
<div class="card-body">
<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>
{% url 'atlas:case_inline_field' case.pk 'report' as report_url %}
{% with endpoint_default=report_url|add:"?show_label=0" endpoint_edit=report_url|add:"?edit=1&show_label=0" %}
{% include 'atlas/partials/case_inline_field.html' with show_label=0 editing=False field_label="Report" can_edit=can_edit endpoint_url_default=endpoint_default endpoint_url_edit=endpoint_edit container_id='case-inline-report' field_kind='textarea' value=case.report display_value=case.report field_name='report' error_message='' %}
{% endwith %}
</div>
</div>
</div>
@@ -0,0 +1,34 @@
{# Unified single case item used by search results and recent-created lists #}
<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="ms-2 d-flex align-items-center">
{% 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>
<a class="btn btn-sm btn-outline-secondary ms-2" href="{% url 'atlas:case_detail' case.pk %}" target="_blank" rel="noopener">View</a>
{% else %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_displaysets' case.pk %}">View</a>
{% endif %}
</div>
</div>
@@ -1,24 +1,16 @@
<div class="case-inline-field">
<div id="{{ container_id }}" 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 %}
{% if not editing %}
<button class="quick-edit-trigger quick-edit-button"
type="button"
hx-get="{{ endpoint_url_edit }}"
hx-target="#{{ container_id }}"
hx-swap="innerHTML">
hx-swap="outerHTML">
quick edit
</button>
{% endif %}
@@ -27,17 +19,21 @@
{% endif %}
{% if editing %}
<form hx-post="{{ endpoint_url_default }}"
<form hx-post="{{ endpoint_url_default }}"
hx-target="#{{ container_id }}"
hx-swap="innerHTML"
hx-swap="outerHTML"
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">
<select class="form-select" name="value" {% if multiple %}multiple{% endif %}>
{% for option_value, option_label in select_choices %}
<option value="{{ option_value }}" {% if value|stringformat:"s" == option_value %}selected{% endif %}>{{ option_label }}</option>
{% if multiple %}
<option value="{{ option_value }}" {% if option_value|stringformat:"s" in selected_values %}selected{% endif %}>{{ option_label }}</option>
{% else %}
<option value="{{ option_value }}" {% if value|stringformat:"s" == option_value %}selected{% endif %}>{{ option_label }}</option>
{% endif %}
{% endfor %}
</select>
{% else %}
@@ -64,8 +60,12 @@
</div>
{% endif %}
<div class="mt-2">
<div class="mt-2 d-flex gap-2">
<button class="btn btn-sm btn-primary" type="submit">Save</button>
<button class="btn btn-sm btn-outline-secondary" type="button"
hx-get="{{ endpoint_url_default }}"
hx-target="#{{ container_id }}"
hx-swap="outerHTML">Cancel</button>
</div>
</form>
{% else %}
@@ -2,38 +2,7 @@
{% if cases and cases|length > 0 %}
<div class="list-group mb-2">
{% for case in cases %}
<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="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>
{% include 'atlas/partials/_case_search_item.html' with case=case collection=collection %}
{% endfor %}
</div>
{% else %}
@@ -46,39 +15,7 @@
<div class="small text-muted mb-1">Recently created</div>
<div class="list-group">
{% for case in recent_cases %}
<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:100 }}{% endif %}</p>
</div>
<div class="ms-2 d-flex align-items-center">
{% 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>
<a class="btn btn-sm btn-outline-secondary ms-2" href="{% url 'atlas:case_detail' case.pk %}" target="_blank" rel="noopener">View</a>
{% else %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_displaysets' case.pk %}">View</a>
{% endif %}
</div>
</div>
{% include 'atlas/partials/_case_search_item.html' with case=case collection=collection %}
{% endfor %}
</div>
</div>
+18 -8
View File
@@ -192,9 +192,13 @@
<script>
// Handle selecting a case from the modal results to import selected series into it
document.body.addEventListener('click', async function (e) {
// Only handle clicks inside the case select modal results
// Only handle clicks inside the case select modal results, but ignore clicks on interactive controls inside the item
const anchor = e.target.closest('#case-search-results-modal .list-group-item, #case-search-results-modal .list-group-item-action');
if (!anchor) return;
if (e.target.closest('#case-search-results-modal .list-group-item a, #case-search-results-modal .list-group-item button, #case-search-results-modal .list-group-item form, #case-search-results-modal .list-group-item input')) {
// Let buttons/links/forms behave normally (e.g. View link)
return;
}
e.preventDefault();
// Determine case pk: prefer data-case-pk, else parse from href
let casePk = anchor.getAttribute('data-case-pk');
@@ -208,24 +212,30 @@
return;
}
// Gather selected series (same logic as import button)
const selected = Array.from(document.querySelectorAll('input[name="selection"]:checked'))
.filter(cb => !cb.disabled)
.map(cb => cb.value);
// Get a display title for confirmation
let caseTitle = anchor.querySelector('h6') ? anchor.querySelector('h6').textContent.trim() : null;
if (!caseTitle) caseTitle = `#${casePk}`;
// Confirm with the user before importing
const checkboxes = Array.from(document.querySelectorAll('input[name="selection"]:checked'));
const allCheckboxes = Array.from(document.querySelectorAll('input[name="selection"]'));
const selectable = allCheckboxes.filter(cb => !cb.disabled).map(cb => cb.value);
const selection = selected.length ? selected : selectable;
if (!selection.length) {
const selectionList = checkboxes.length ? checkboxes.map(cb => cb.value) : selectable;
if (!selectionList.length) {
alert('Please select at least one series to import into the case.');
return;
}
const confirmMsg = `Import ${selectionList.length} series into case "${caseTitle}" (ID ${casePk})?`;
if (!window.confirm(confirmMsg)) {
return;
}
// Build import URL template and replace trailing 0 with casePk
const importTemplate = "{% url 'api-1:import_dicoms_case' 0 %}";
const importUrl = importTemplate.replace(/0\/?$/, casePk + '/');
const form = new URLSearchParams();
for (const s of selection) form.append('selection', s);
for (const s of selectionList) form.append('selection', s);
const orderSeriesInput = document.querySelector('input[name="order-series"]:checked');
if (orderSeriesInput) form.append('order-series', orderSeriesInput.value);
+57 -18
View File
@@ -761,6 +761,7 @@ CASE_INLINE_FIELD_CONFIG = {
"discussion": {"label": "Discussion", "kind": "textarea"},
"report": {"label": "Report", "kind": "textarea"},
"diagnostic_certainty": {"label": "Diagnostic certainty", "kind": "select"},
"subspecialty": {"label": "Subspecialty", "kind": "select"},
}
@@ -883,33 +884,69 @@ def case_inline_field(request, pk, field_name):
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", "")
# Special-case handling for many-to-many `subspecialty` field
if field_name == "subspecialty":
if request.method == "POST":
if not can_edit:
return HttpResponse(status=403)
editing = True
# Accept multiple values via POST list
posted = request.POST.getlist("value")
ids = []
for v in posted:
try:
ids.append(int(v))
except Exception:
continue
# keep only valid existing ids
valid_ids = list(
Subspecialty.objects.filter(pk__in=ids).values_list("pk", flat=True)
)
case.subspecialty.set(valid_ids)
editing = False
value = case.subspecialty.all()
else:
setattr(case, field_name, cleaned_value)
case.save(update_fields=[field_name])
editing = False
value = getattr(case, field_name)
value = case.subspecialty.all()
if editing and not can_edit:
editing = False
else:
value = getattr(case, field_name)
if editing and not can_edit:
editing = False
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]
selected_values = []
multiple = False
elif field_name == "subspecialty":
# For many-to-many, provide choices and selected values
display_value = ", ".join([str(s) for s in case.subspecialty.all()])
select_choices = [(str(s.pk), str(s.name)) for s in Subspecialty.objects.all()]
selected_values = [str(s.pk) for s in case.subspecialty.all()]
multiple = True
else:
display_value = value
select_choices = []
selected_values = []
multiple = False
endpoint_url = reverse("atlas:case_inline_field", kwargs={"pk": case.pk, "field_name": field_name})
if show_label:
@@ -934,6 +971,8 @@ def case_inline_field(request, pk, field_name):
"error_message": error_message,
"suggestions": _get_case_inline_suggestions(case, field_name),
"select_choices": select_choices,
"selected_values": selected_values,
"multiple": multiple,
"endpoint_url_default": endpoint_url_default,
"endpoint_url_edit": endpoint_url_edit,
"container_id": f"case-inline-{field_name}",