Add case inline editing functionality and search widget improvements
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# package marker for atlas templatetags
|
||||
@@ -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,
|
||||
}
|
||||
@@ -549,6 +549,7 @@ urlpatterns = [
|
||||
# path("unchecked/", views.unchecked_list, name="unchecked_list"),
|
||||
# path("verified/<int:pk>/", views.verified_detail, name="verified_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>/normal_form/", views.case_normal_form, name="case_normal_form"),
|
||||
path("case/<int:pk>/create_normal/", views.create_case_normal, name="case_create_normal"),
|
||||
|
||||
+191
-1
@@ -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
|
||||
@require_POST
|
||||
def toggle_case_normal(request, pk):
|
||||
@@ -1744,10 +1933,11 @@ def case_search(request):
|
||||
|
||||
cases = cases_qs.order_by("title")[:30]
|
||||
|
||||
multi = request.GET.get('multi') in ('1', 'true', 'True')
|
||||
return render(
|
||||
request,
|
||||
"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},
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user