Refactor templates and views for improved structure and maintainability
This commit is contained in:
@@ -21,7 +21,7 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">My cases / collections</h5>
|
||||
<p>View my <a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">cases</a>.</p>
|
||||
<p>View my <a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">cases</a> or <a href="{% url 'atlas:collection_view' %}?author={{ request.user.id }}">my collections</a>.</p>
|
||||
<p>
|
||||
<a href="{% url 'atlas:user_uploads' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-cloud-arrow-up me-1"></i> Uploads awaiting import
|
||||
@@ -38,6 +38,7 @@
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li><a href="{% url 'atlas:user_collections' %}">Collections to view / take</a></li>
|
||||
<li><a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">My cases</a></li>
|
||||
<li><a href="{% url 'atlas:collection_view' %}?author={{ request.user.id }}">My collections</a></li>
|
||||
<li><a href="{% url 'atlas:user_uploads' %}">Uploads awaiting import</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -7,95 +7,152 @@
|
||||
{% endblock %}
|
||||
{% block js %}
|
||||
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
|
||||
{{form.media}}
|
||||
{{form.media}}
|
||||
|
||||
<script type="text/javascript">
|
||||
</script>
|
||||
<script type="text/javascript">
|
||||
</script>
|
||||
|
||||
<!-- {{ form.media }} -->
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Edit Exam Collection <small class="text-muted">{{ object.name }}</small></h2>
|
||||
|
||||
<h2>Edit Exam Collection {{object.name}}</h2>
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Edit Exam Collection <small class="text-muted">{{ object.name }}</small></h2>
|
||||
<form action="" method="post" enctype="multipart/form-data" id="examcollection-form" class="needs-validation" novalidate>
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger">{{ form.non_field_errors }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form action="" method="post" enctype="multipart/form-data" id="examcollection-form" class="needs-validation" novalidate>
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger">{{ form.non_field_errors }}</div>
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
<style>
|
||||
/* Improve visual separation of form sections for this collection form */
|
||||
#examcollection-form .mb-3 {
|
||||
padding: .75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: .375rem;
|
||||
background: rgba(255,255,255,0.02);
|
||||
border-left: 3px solid rgba(255,255,255,0.03);
|
||||
}
|
||||
#examcollection-form .mb-3:nth-child(odd) {
|
||||
background: rgba(255,255,255,0.015);
|
||||
}
|
||||
#examcollection-form .form-section-divider {
|
||||
height: 1px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
margin: .5rem 0 1rem 0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
#examcollection-form .form-section-title {
|
||||
font-size: .95rem;
|
||||
margin-bottom: .5rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-light);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form|crispy }}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Insert subtle dividers between logical groups (.mb-3 blocks)
|
||||
(function(){
|
||||
var form = document.getElementById('examcollection-form');
|
||||
if (!form) return;
|
||||
// find direct .mb-3 children inside the form
|
||||
var groups = Array.from(form.querySelectorAll(':scope > .mb-3'));
|
||||
if (groups.length <= 1) return;
|
||||
for (var i = 1; i < groups.length; i++) {
|
||||
var divider = document.createElement('div');
|
||||
divider.className = 'form-section-divider';
|
||||
groups[i].parentNode.insertBefore(divider, groups[i]);
|
||||
#examcollection-form .mb-3 {
|
||||
padding: .75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: .375rem;
|
||||
background: rgba(255,255,255,0.02);
|
||||
border-left: 3px solid rgba(255,255,255,0.03);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
#examcollection-form .mb-3:nth-child(odd) {
|
||||
background: rgba(255,255,255,0.015);
|
||||
}
|
||||
#examcollection-form .form-section-divider {
|
||||
height: 1px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
margin: .5rem 0 1rem 0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
#examcollection-form .form-section-title {
|
||||
font-size: .95rem;
|
||||
margin-bottom: .5rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-light);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1" aria-hidden="true"></i> Save</button>
|
||||
<a href="{% url 'generic:examcollection_list' %}" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1" aria-hidden="true"></i> Back to collections</a>
|
||||
<button type="reset" class="btn btn-light">Reset</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mb-3">
|
||||
<div class="row gy-3">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-2">
|
||||
<label for="id_name" class="form-label requiredField">Name <span class="text-danger">*</span></label>
|
||||
{{ form.name }}
|
||||
{% if form.name.errors %}
|
||||
<div class="invalid-feedback d-block">{{ form.name.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="mb-2">
|
||||
<label for="id_date" class="form-label">Date</label>
|
||||
{{ form.date }}
|
||||
{% if form.date.errors %}
|
||||
<div class="invalid-feedback d-block">{{ form.date.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 d-flex align-items-center">
|
||||
<div class="form-check mb-2">
|
||||
{{ form.archive }}
|
||||
<label for="id_archive" class="form-check-label ms-2">Archive</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-3" />
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-2">Authors</h5>
|
||||
<div>
|
||||
{{ form.author }}
|
||||
{% if form.author.errors %}
|
||||
<div class="invalid-feedback d-block">{{ form.author.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-cols-1 row-cols-md-2 g-3">
|
||||
{% for label, field_name, bound in exam_groups %}
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title small mb-2">{{ label }}</h5>
|
||||
<div>
|
||||
{{ bound }}
|
||||
{% if bound.errors %}
|
||||
<div class="invalid-feedback d-block">{{ bound.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Insert subtle dividers between logical groups (.mb-3 blocks)
|
||||
(function(){
|
||||
var form = document.getElementById('examcollection-form');
|
||||
if (!form) return;
|
||||
// find direct .mb-3 children inside the form
|
||||
var groups = Array.from(form.querySelectorAll(':scope > .mb-3'));
|
||||
if (groups.length <= 1) return;
|
||||
for (var i = 1; i < groups.length; i++) {
|
||||
var divider = document.createElement('div');
|
||||
divider.className = 'form-section-divider';
|
||||
groups[i].parentNode.insertBefore(divider, groups[i]);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1" aria-hidden="true"></i> Save</button>
|
||||
<a href="{% url 'generic:examcollection_list' %}" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1" aria-hidden="true"></i> Back to collections</a>
|
||||
<button type="reset" class="btn btn-light">Reset</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
<script>
|
||||
// Simple client-side bootstrap validation
|
||||
(function () {
|
||||
'use strict'
|
||||
var forms = document.querySelectorAll('.needs-validation')
|
||||
Array.prototype.slice.call(forms).forEach(function (form) {
|
||||
form.addEventListener('submit', function (event) {
|
||||
if (!form.checkValidity()) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
form.classList.add('was-validated')
|
||||
}, false)
|
||||
})
|
||||
})()
|
||||
</script>
|
||||
(function () {
|
||||
'use strict'
|
||||
var forms = document.querySelectorAll('.needs-validation')
|
||||
Array.prototype.slice.call(forms).forEach(function (form) {
|
||||
form.addEventListener('submit', function (event) {
|
||||
if (!form.checkValidity()) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
form.classList.add('was-validated')
|
||||
}, false)
|
||||
})
|
||||
})()
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,7 +1,7 @@
|
||||
{% if results %}
|
||||
<ul class="list-unstyled mb-0" id="user_search_results_list_{{ field }}">
|
||||
{% for item in results %}
|
||||
<li class="py-1 d-flex justify-content-between align-items-center" data-user-id="{{ item.id }}" data-user-text="{{ item.text|escapejs }}" data-user-grade="{{ item.grade|default_if_none:''|escapejs }}">
|
||||
<li class="py-1 d-flex justify-content-between align-items-center" data-user-id="{{ item.id }}" data-user-text="{{ item.text|escape }}" data-user-grade="{{ item.grade|default_if_none:''|escape }}">
|
||||
<div class="flex-grow-1">
|
||||
{{ item.text|escape }}
|
||||
{% if item.grade %}
|
||||
@@ -9,7 +9,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-primary user-add-btn" data-user-id="{{ item.id }}" data-user-text="{{ item.text|escapejs }}" data-user-grade="{{ item.grade|default_if_none:''|escapejs }}">Add</button>
|
||||
<button type="button" class="btn btn-sm btn-primary user-add-btn" data-user-id="{{ item.id }}" data-user-text="{{ item.text|escape }}" data-user-grade="{{ item.grade|default_if_none:''|escape }}">Add</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
@@ -5490,11 +5490,38 @@ class ExamCollectionEdit(UpdateView, AuthorRequiredMixin):
|
||||
model = ExamCollection
|
||||
form_class = ExamCollectionForm
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
# Ensure we have a bound form to extract bound fields
|
||||
form = kwargs.get('form') or self.get_form()
|
||||
groups = []
|
||||
for label, field_name, model in getattr(self.form_class, 'GROUP_TYPES', []):
|
||||
try:
|
||||
bound = form[field_name]
|
||||
except Exception:
|
||||
bound = None
|
||||
groups.append((label, field_name, bound))
|
||||
context['exam_groups'] = groups
|
||||
return context
|
||||
|
||||
|
||||
class ExamCollectionCreate(CreateView, AuthorRequiredMixin):
|
||||
model = ExamCollection
|
||||
form_class = ExamCollectionForm
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
form = kwargs.get('form') or self.get_form()
|
||||
groups = []
|
||||
for label, field_name, model in getattr(self.form_class, 'GROUP_TYPES', []):
|
||||
try:
|
||||
bound = form[field_name]
|
||||
except Exception:
|
||||
bound = None
|
||||
groups.append((label, field_name, bound))
|
||||
context['exam_groups'] = groups
|
||||
return context
|
||||
|
||||
|
||||
class ExamCollectionClone(CreateView, AuthorRequiredMixin):
|
||||
model = ExamCollection
|
||||
|
||||
+76
-2
@@ -33,7 +33,7 @@ class UserSearchSelectMultipleWidget:
|
||||
options_html = ""
|
||||
selected_html = ""
|
||||
for u in users:
|
||||
options_html += f'<option value="{u.pk}" selected>{u.get_full_name() or u.username} - {u.email}</option>'
|
||||
# determine grade_text first, then include data-user-grade attribute on option
|
||||
grade_text = ""
|
||||
try:
|
||||
up = getattr(u, "userprofile", None)
|
||||
@@ -42,6 +42,8 @@ class UserSearchSelectMultipleWidget:
|
||||
grade_text = getattr(g, "name", str(g)) if g is not None else ""
|
||||
except Exception:
|
||||
grade_text = ""
|
||||
safe_grade_attr = (grade_text.replace('"', '"') if grade_text else '')
|
||||
options_html += f'<option value="{u.pk}" selected data-user-grade="{safe_grade_attr}">{u.get_full_name() or u.username} - {u.email}</option>'
|
||||
|
||||
# Build the selected item HTML incrementally to avoid accidental
|
||||
# mixing of boolean values into string concatenation (was causing
|
||||
@@ -147,6 +149,16 @@ class UserSearchSelectMultipleWidget:
|
||||
timeout = setTimeout(() => fetchResults(search.value), 300);
|
||||
}});
|
||||
|
||||
// Prevent Enter from submitting the surrounding form; run the search instead
|
||||
search.addEventListener('keydown', function(e) {{
|
||||
if (e.key === 'Enter' || e.keyCode === 13) {{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
clearTimeout(timeout);
|
||||
fetchResults(search.value);
|
||||
}}
|
||||
}});
|
||||
|
||||
gradeSelect && gradeSelect.addEventListener('change', function(e) {{
|
||||
// re-run search with same query when grade changes
|
||||
clearTimeout(timeout);
|
||||
@@ -172,7 +184,16 @@ class UserSearchSelectMultipleWidget:
|
||||
if (!btn) return;
|
||||
const id = btn.getAttribute('data-user-id');
|
||||
const text = btn.getAttribute('data-user-text') || btn.textContent.trim();
|
||||
const grade = btn.getAttribute('data-user-grade') || '';
|
||||
// Try button attribute, then list-item dataset, then visible badge text
|
||||
let grade = btn.getAttribute('data-user-grade') || '';
|
||||
if (!grade) {{
|
||||
const li = btn.closest('li[data-user-id]');
|
||||
if (li && li.dataset && li.dataset.userGrade) grade = li.dataset.userGrade;
|
||||
else {{
|
||||
const badge = btn.closest('li') ? btn.closest('li').querySelector('.badge') : null;
|
||||
if (badge) grade = badge.textContent.trim();
|
||||
}}
|
||||
}}
|
||||
addUserToField(widgetFieldName, id, text, grade);
|
||||
}});
|
||||
|
||||
@@ -226,6 +247,49 @@ class UserSearchSelectMultipleWidget:
|
||||
addUserToField(fieldName, id, text, grade);
|
||||
}});
|
||||
}}
|
||||
|
||||
// Initialize visible selected-list from any pre-existing <option selected> entries
|
||||
(function initSelectedListFromOptions() {{
|
||||
try {{
|
||||
const sel = document.getElementById('{field_id}');
|
||||
const listEl = document.getElementById('{selected_list_id}');
|
||||
if (!sel || !listEl) return;
|
||||
for (let i=0;i<sel.options.length;i++) {{
|
||||
const opt = sel.options[i];
|
||||
const id = opt.value;
|
||||
if (!id) continue;
|
||||
const liId = 'selected_user_' + '{self.name}' + '_' + id;
|
||||
const existingLi = document.getElementById(liId);
|
||||
const grade = opt.getAttribute('data-user-grade') || '';
|
||||
|
||||
if (existingLi) {{
|
||||
// If server-rendered li exists but has no badge, add one from the option attribute
|
||||
const hasBadge = existingLi.querySelector('.badge');
|
||||
if (!hasBadge && grade) {{
|
||||
const span = existingLi.querySelector('span');
|
||||
if (span) {{
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'badge bg-secondary ms-2';
|
||||
badge.textContent = grade;
|
||||
span.appendChild(badge);
|
||||
}}
|
||||
}}
|
||||
continue;
|
||||
}}
|
||||
|
||||
// build li from option when not rendered server-side
|
||||
const text = opt.textContent || opt.innerText || opt.text;
|
||||
const li = document.createElement('li');
|
||||
li.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||
li.id = liId;
|
||||
const span = document.createElement('span');
|
||||
span.innerHTML = text + (grade ? ' <span class="badge bg-secondary ms-2">' + grade + '</span>' : '');
|
||||
const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-danger ms-2'; btn.innerText = 'Remove';
|
||||
btn.addEventListener('click', function() {{ removeUserFromField('{self.name}', id); }});
|
||||
li.appendChild(span); li.appendChild(btn); listEl.appendChild(li);
|
||||
}}
|
||||
}} catch (e) {{ /* ignore init errors */ }}
|
||||
}})();
|
||||
}})();
|
||||
</script>
|
||||
</div>
|
||||
@@ -481,6 +545,16 @@ class ExamSearchSelectMultipleWidget:
|
||||
timeout = setTimeout(() => fetchResults(search.value), 300);
|
||||
}});
|
||||
|
||||
// Prevent Enter from submitting the surrounding form; run the search instead
|
||||
search.addEventListener('keydown', function(e) {{
|
||||
if (e.key === 'Enter' || e.keyCode === 13) {{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
clearTimeout(timeout);
|
||||
fetchResults(search.value);
|
||||
}}
|
||||
}});
|
||||
|
||||
// Toggle help panel visibility
|
||||
if (helpBtn && helpPanel) {{
|
||||
helpBtn.addEventListener('click', function(e) {{
|
||||
|
||||
Reference in New Issue
Block a user