Bootstrap 5 & remove jQuery (#661)

* build: move frontend assets to Bootstrap 5.3 and Tom Select

* feat: migrate templates and scripts to Bootstrap 5, dropping jQuery

Templates
- Rename Bootstrap 4 classes and data attributes (data-bs-*, ms-/me-/ps-/pe-,
  text-start/end, form-group -> mb-3, input-group wrappers removed, badges,
  custom controls -> form-check/form-select, media -> d-flex, btn-close, ...)
- Dark mode now uses Bootstrap 5.3 colour modes (data-bs-theme on <html>),
  set from <head> to avoid a flash of the light theme
- Remove the Bootstrap 4 /bootstrap/ demo page
- Contextual table classes (table-success etc.) still work as plain
  backgrounds for the rigboard key

JavaScript
- Remove jQuery: src.js/interaction.js and every inline template script are
  rewritten in plain JS on top of small helpers (helpers.js, bundled with
  Bootstrap into jpop.js so inline scripts can use them)
- Replace bootstrap-select/ajax-bootstrap-select with Tom Select. The remote
  search now sends the `q` parameter and reads `text`, which is what the
  API actually uses (the old picker sent `term` and read `label`)
- HTML fragments are fetched with an X-Requested-With header so views keep
  rendering their modal variants, and fetched scripts are re-executed
- Remove the Bootstrap 4 plugin script includes

* fix: stop rigboard header buttons stretching to full width under Bootstrap 5

* fix: restore dark-mode brand colours and contextual table colours under Bootstrap 5

* fix: brighten dark-mode links and make coloured card outlines visible

* fix: calendar layout and grid borders, navbar search button width, guard missing modal links

* fix: readable text in dark-mode Tom Select inputs and placeholders, restore form-control column widths

* build: split the Dockerfile into cached stages with prod and dev targets

* deploy: make compose.yml development-only with live reload, and document it

* fix: keep badge text colour for links inside badges

* fix: restore table-bordered grid lines in dark mode

* fix: include Bootstrap's progress styles and keep progress labels readable
This commit is contained in:
Joe Banks
2026-10-02 16:28:11 +01:00
committed by GitHub
parent 08171767a0
commit a29384a314
124 changed files with 1538 additions and 4137 deletions

View File

@@ -361,7 +361,7 @@ class TrainingLevel(models.Model, RevisionMixin):
else:
icon = "".join([w[0] for w in str(self).split()])
return mark_safe(
f"<span class='badge badge-{self.department_colour} badge-pill' data-toggle='tooltip' title='{str(self)}'>{icon}</span>"
f"<span class='badge text-bg-{self.department_colour} rounded-pill' data-bs-toggle='tooltip' title='{str(self)}'>{icon}</span>"
)

View File

@@ -15,25 +15,16 @@
{% block js %}
{{ block.super }}
<script src="{% static 'js/autocompleter.js' %}"></script>
<script src="{% static 'js/tooltip.js' %}"></script>
{% endblock %}
{% block content %}
{% if form.errors %}
{% include 'form_errors.html' %}
<script src="{% static 'js/autocompleter.js' %}"></script>
<script>
//Has to be done here or the pickers disappear on modal error
$('document').ready(function(){
$(document).find(".selectpicker").selectpicker().each(function(){initPicker($(this))});
});
</script>
{% endif %}
<form id="requirement-form" action="{{ form.action|default:request.path }}" method="post">{% csrf_token %}
{% render_field form.level|attr:'hidden' value=form.level.initial %}
{% include 'partials/item_field.html' %}
<div class="form-group form-row">
<div class="mb-3 row g-2">
<label for="depth" class="col-sm-2 col-form-label">Depth</label>
{% render_field form.depth|add_class:'form-control col-sm'|attr:'required' %}
</div>
@@ -44,7 +35,7 @@
{% endblock %}
{% block footer %}
<div class="col-sm-12 text-right pr-0">
<div class="col-sm-12 text-end pe-0">
<button type="submit" class="btn btn-primary" title="Save" form="requirement-form"><span class="fas fa-save align-middle"></span> <span class="d-none d-sm-inline align-middle">Save</span></button>
</div>
{% endblock %}

View File

@@ -10,7 +10,7 @@
{% if user.is_authenticated %}
<li class="nav-item"><a class="nav-link" href="/">Home</a></li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle text-info" href="#" id="navbarDropdownMy" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<a class="nav-link dropdown-toggle text-info" href="#" id="navbarDropdownMy" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
My Record
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdownMy">
@@ -21,7 +21,7 @@
</div>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownLists" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownLists" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Lists
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdownLists">

View File

@@ -13,30 +13,22 @@
{% endblock %}
{% block js %}
<script src="{% static 'js/tooltip.js' %}"></script>
{% endblock %}
{% block content %}
<script src="{% static 'js/autocompleter.js' %}"></script>
<script>
//Has to be done here or the pickers disappear on modal error
$('document').ready(function(){
$(document).find(".selectpicker").selectpicker().each(function(){initPicker($(this))});
});
</script>
<form role="form" action="{{ form.action|default:request.path }}" method="POST" id="add_record_form">
{% include 'form_errors.html' %}
{% csrf_token %}
{% render_field form.trainee|attr:'hidden' value=form.trainee.initial %}
{% include 'partials/item_field.html' %}
<div class="form-group form-row">
<div class="mb-3 row g-2">
<label for="depth" class="col-sm-2 col-form-label">Depth</label>
{% render_field form.depth|add_class:'form-control custom-select col-sm-8' %}
{% render_field form.depth|add_class:'form-control form-select col-sm-8' %}
</div>
<div class="form-group form-row">
<div class="mb-3 row g-2">
{% include 'partials/supervisor_field.html' %}
</div>
<div class="form-group form-row">
<div class="mb-3 row g-2">
<label for="date" class="col-sm-2 col-form-label">Training Date</label>
<div class="col-sm-8">
{% with training_date=object.date|date:"Y-m-d" %}
@@ -45,14 +37,14 @@
</div>
{% button 'today' id='id_date' %}
</div>
<div class="form-group form-row">
<div class="mb-3 row g-2">
<label for="id_notes" class="col-sm-2 col-form-label">Notes</label>
<div class="col-sm-8">
{% render_field form.notes|add_class:'form-control' rows=3 %}
</div>
</div>
{% if not is_ajax %}
<div class="col-sm-12 text-right pr-0">
<div class="col-sm-12 text-end pe-0">
{% button 'submit' %}
</div>
{% endif %}
@@ -60,7 +52,7 @@
{% endblock %}
{% block footer %}
<div class="col-sm-12 text-right pr-0">
<div class="col-sm-12 text-end pe-0">
<button type="submit" class="btn btn-primary" title="Save" form="add_record_form"><span class="fas fa-save align-middle"></span> <span class="d-none d-sm-inline align-middle">Save</span></button>
</div>
{% endblock %}

View File

@@ -3,25 +3,25 @@
{% load button from filters %}
{% block content %}
<div class="col-12 text-right py-2 pr-0">
<div class="col-12 text-end py-2 pe-0">
{% button 'print' 'item_list_export' %}
</div>
<div id="accordion">
{% for category in categories %}
<div class="card">
<div class="card-header" id="heading{{forloop.counter}}">
<button class="btn btn-link collapsed" data-toggle="collapse" data-target="#collapse{{forloop.counter}}" aria-expanded="true" aria-controls="collapse{{forloop.counter}}">
<button class="btn btn-link collapsed" data-bs-toggle="collapse" data-bs-target="#collapse{{forloop.counter}}" aria-expanded="true" aria-controls="collapse{{forloop.counter}}">
{{ category }}
</button>
</div>
<div id="collapse{{forloop.counter}}" class="collapse" aria-labelledby="heading{{forloop.counter}}" data-parent="#accordion">
<div id="collapse{{forloop.counter}}" class="collapse" aria-labelledby="heading{{forloop.counter}}" data-bs-parent="#accordion">
<div class="card-body">
<div class="list-group list-group-flush">
{% for item in category.items.all %}
<li class="list-group-item {% if not item.active%}text-warning{%endif%}">{{ item }} <a href="{% url 'item_qualification' item.pk %}" class="btn btn-info float-right"><span class="fas fa-user"></span> Qualified Users</a>
<li class="list-group-item {% if not item.active%}text-warning{%endif%}">{{ item }} <a href="{% url 'item_qualification' item.pk %}" class="btn btn-info float-end"><span class="fas fa-user"></span> Qualified Users</a>
<br><small>{{ item.description }}</small>
{% if item.prerequisites.exists %}
<div class="ml-3 font-italic">
<div class="ms-3 fst-italic">
<p class="text-info mb-0">Competency Assessment Prerequisites:</p>
<ul>
{% for p in item.prerequisites.all %}

View File

@@ -6,12 +6,6 @@
{% load static %}
{% block js %}
<script src="{% static "js/tooltip.js" %}"></script>
<script>
$(function () {
$('[data-toggle="tooltip"]').tooltip();
});
</script>
{% endblock %}
{% block content %}

View File

@@ -16,28 +16,15 @@
{% endblock %}
{% block js %}
<script src="{% static 'js/autocompleter.js' %}"></script>
<script src="{% static 'js/tooltip.js' %}"></script>
<script>
$('document').ready(function(){
$('#requirement_button').click(function (e) {
e.preventDefault();
var url = $(this).attr("href");
$.ajax({
url: url,
success: function(){
$link = $(this);
// Anti modal inception
if ($link.parents('#modal').length === 0) {
modaltarget = $link.data('target');
modalobject = "";
$('#modal').load(url, function (e) {
$('#modal').modal();
$(".selectpicker").selectpicker().each(function(){initPicker($(this))});
});
}
}
});
ready(function(){
var link = document.getElementById('requirement_button');
if (!link) { return; }
link.addEventListener('click', function (e) {
e.preventDefault();
modaltarget = undefined;
modalobject = "";
loadModal(this.getAttribute("href"));
});
});
</script>
@@ -45,7 +32,7 @@
{% block content %}
{% if request.user.is_supervisor %}
<div class="col-sm-12 text-right pr-0">
<div class="col-sm-12 text-end pe-0">
<a type="button" class="btn btn-success mb-3" href="{% url 'add_requirement' pk=object.pk %}" id="requirement_button">
<span class="fas fa-plus"></span> Add New Requirement
</a>
@@ -58,7 +45,7 @@
</div>
</div>
<div class="card">
<div class="card-header"><h4 class="card-title">Level Requirements</h4> {% if u.pk != request.user.pk %}<h5 class="card-subtitle font-italic">for {{ u }}</h5>{% endif %}</div>
<div class="card-header"><h4 class="card-title">Level Requirements</h4> {% if u.pk != request.user.pk %}<h5 class="card-subtitle fst-italic">for {{ u }}</h5>{% endif %}</div>
<div class="table-responsive card-body">
<table class="table">
<thead>
@@ -70,7 +57,7 @@
</thead>
<tbody class="table-body">
{% for level in object.prerequisite_levels.all %}
<tr data-toggle="collapse" data-target="#{{level.pk}}" style="cursor: pointer;"><th colspan="3" class="text-center font-italic" data-toggle="collapse" data-target="#level_{{level.pk}}">{{level}} (prerequisite)</th></tr>
<tr data-bs-toggle="collapse" data-bs-target="#{{level.pk}}" style="cursor: pointer;"><th colspan="3" class="text-center fst-italic" data-bs-toggle="collapse" data-bs-target="#level_{{level.pk}}">{{level}} (prerequisite)</th></tr>
<tr id="level_{{level.pk}}" class="collapse">
<td><ul class="list-unstyled">{% for req in level.started_requirements %}<li>{{ req.item }} {% user_has_qualification u req.item 0 %}</li>{% endfor %}</ul></td>
<td><ul class="list-unstyled">{% for req in level.complete_requirements %}<li>{{ req.item }} {% user_has_qualification u req.item 1 %}</li>{% endfor %}</ul></td>
@@ -135,7 +122,7 @@
</div>
</div>
<div class="row">
<div class="col text-right">
<div class="col text-end">
{% include 'partials/last_edited.html' with target="traininglevel_history" %}
</div>
</div>

View File

@@ -1,6 +1,6 @@
<div class="form-group form-row">
<div class="mb-3 row g-2">
<label for="item_id" class="col col-form-label">Item</label>
<select name="item" id="item_id" class="selectpicker col-sm-10 px-0" data-live-search="true" data-sourceurl="{% url 'api_secure' model='training_item' %}?fields=display_id,description&filters=active" required>
<select name="item" id="item_id" class="ts-select col-sm-10 px-0" data-live-search="true" data-sourceurl="{% url 'api_secure' model='training_item' %}?fields=display_id,description&filters=active" required>
{% if item %}
<option value="{{form.item.value}}" selected>{{item}}</option>
{% endif %}

View File

@@ -1,5 +1,5 @@
<label for="supervisor" class="col-sm-2 col-form-label">Supervisor</label>
<select name="supervisor" id="supervisor_id" class="selectpicker col-sm-10" data-live-search="true"
<select name="supervisor" id="supervisor_id" class="ts-select col-sm-10" data-live-search="true"
data-sourceurl="{% url 'api_secure' model='profile' %}?fields=first_name,last_name,initials" required
data-noclear="true">
{% if supervisor %}

View File

@@ -16,9 +16,7 @@
{% block js %}
{{ block.super }}
<script src="{% static 'js/autocompleter.js' %}"></script>
<script src="{% static 'js/interaction.js' %}"></script>
<script src="{% static 'js/tooltip.js' %}"></script>
{% endblock %}
{% block content %}
@@ -28,31 +26,31 @@
{% include 'form_errors.html' %}
{% csrf_token %}
<h3>People</h3>
<div class="form-group row" id="supervisor_group">
<div class="mb-3 row" id="supervisor_group">
{% include 'partials/supervisor_field.html' %}
</div>
<div class="form-group row" id="trainees_group">
<div class="mb-3 row" id="trainees_group">
<label for="trainees_id" class="col-sm-2">Select Attendees</label>
<select multiple name="trainees" id="trainees_id" class="selectpicker col-sm-10" data-live-search="true" data-sourceurl="{% url 'api_secure' model='profile' %}?fields=first_name,last_name,initials" data-noclear="true">
<select multiple name="trainees" id="trainees_id" class="ts-select col-sm-10" data-live-search="true" data-sourceurl="{% url 'api_secure' model='profile' %}?fields=first_name,last_name,initials" data-noclear="true">
</select>
</div>
<h3>Training Items</h3>
{% for depth in depths %}
<div class="form-group row" id="{{depth.0}}">
<label for="selectpicker" class="col-sm-2 text-{% colour_from_depth depth.0 %} py-1">{{ depth.1 }} Items</label>
<select multiple name="items_{{depth.0}}" id="items_{{depth.0}}_id" class="selectpicker col-sm-10 px-0" data-live-search="true" data-sourceurl="{% url 'api_secure' model='training_item' %}?fields=display_id,description&filters=active" data-noclear="true">
<div class="mb-3 row" id="{{depth.0}}">
<label for="ts-select" class="col-sm-2 text-{% colour_from_depth depth.0 %} py-1">{{ depth.1 }} Items</label>
<select multiple name="items_{{depth.0}}" id="items_{{depth.0}}_id" class="ts-select col-sm-10 px-0" data-live-search="true" data-sourceurl="{% url 'api_secure' model='training_item' %}?fields=display_id,description&filters=active" data-noclear="true">
</select>
</div>
{% endfor %}
<h3>Session Information</h3>
<div class="form-group row">
<div class="mb-3 row">
{% include 'partials/form_field.html' with field=form.date col='col-sm-6' %}
{% button 'today' id='id_date' %}
</div>
<div class="form-group">
<div class="mb-3">
{% include 'partials/form_field.html' with field=form.notes %}
</div>
<div class="col-sm-12 text-right my-3">
<div class="col-sm-12 text-end my-3">
{% button 'submit' %}
</div>
</form>

View File

@@ -14,35 +14,23 @@
{% endblock %}
{% block js %}
<script src="{% static 'js/tooltip.js' %}"></script>
<script>
$('document').ready(function(){
$('#add_record').click(function (e) {
e.preventDefault();
var url = $(this).attr("href");
$.ajax({
url: url,
success: function(){
$link = $(this);
// Anti modal inception
if ($link.parents('#modal').length === 0) {
modaltarget = $link.data('target');
modalobject = "";
$('#modal').load(url, function (e) {
$('#modal').modal();
//$(".selectpicker").selectpicker().each(function(){initPicker($(this))});
});
}
}
ready(function(){
var link = document.getElementById('add_record');
if (!link) { return; }
link.addEventListener('click', function (e) {
e.preventDefault();
modaltarget = undefined;
modalobject = "";
loadModal(this.getAttribute("href"));
});
});
});
</script>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-sm-12 text-right">
<div class="col-sm-12 text-end">
<div class="btn-group">
{% include 'partials/add_qualification.html' %}
<a href="{% url 'trainee_item_detail' object.pk %}" class="btn btn-info"><span class="fas fa-info-circle"></span> View Detailed Record</a>
@@ -72,16 +60,17 @@
{{ level.description|markdown }}
</div>
<div class="card-footer">
<div class="progress">
<div class="progress-bar progress-bar-striped" role="progressbar" style="width: {{completion}}%" aria-valuenow="{{completion}}" aria-valuemin="0" aria-valuemax="100">{{completion}}% complete</div>
<div class="progress" style="height: 1.5rem;">
<div class="progress-bar progress-bar-striped" role="progressbar" style="width: {{completion}}%" aria-valuenow="{{completion}}" aria-valuemin="0" aria-valuemax="100">{% if completion >= 30 %}{{completion}}% complete{% endif %}</div>
</div>
{% if completion < 30 %}<small class="text-body-secondary">{{completion}}% complete</small>{% endif %}
{% if completion == 100 %}
<br>
{% confirm_button request.user object level as cb %}
{% if cb %}
<div class="d-flex justify-content-between">{{ cb }}</div>
{% else %}
<p class="font-italic pt-2 pb-0">Missing prerequisite level(s)</p>
<p class="fst-italic pt-2 pb-0">Missing prerequisite level(s)</p>
{% endif %}
{% endif %}
</div>
@@ -90,7 +79,7 @@
</div>
</div>
<div class="row">
<div class="col text-right">
<div class="col text-end">
{% include 'partials/last_edited.html' with target="trainee_history" %}
</div>
</div>

View File

@@ -16,33 +16,22 @@
{% endblock %}
{% block js %}
<script src="{% static 'js/tooltip.js' %}"></script>
<script>
$('document').ready(function(){
$('#edit').click(function (e) {
e.preventDefault();
var url = $(this).attr("href");
$.ajax({
url: url,
success: function(){
$link = $(this);
// Anti modal inception
if ($link.parents('#modal').length === 0) {
modaltarget = $link.data('target');
modalobject = "";
$('#modal').load(url, function (e) {
$('#modal').modal();
});
}
}
ready(function(){
var link = document.getElementById('edit');
if (!link) { return; }
link.addEventListener('click', function (e) {
e.preventDefault();
modaltarget = undefined;
modalobject = "";
loadModal(this.getAttribute("href"));
});
});
});
</script>
{% endblock %}
{% block content %}
<p class="text-muted text-right">Search by supervisor name, item name or item ID</p>{% include 'partials/list_search.html' %}
<p class="text-muted text-end">Search by supervisor name, item name or item ID</p>{% include 'partials/list_search.html' %}
<div class="row pt-2">
<div class="col">
<div class="table-responsive">
@@ -82,7 +71,7 @@
</div>
</div>
<div class="row">
<div class="col text-right">
<div class="col text-end">
{% include 'partials/last_edited.html' with target="trainee_history" object=trainee %}
</div>
</div>

View File

@@ -10,23 +10,17 @@
{% load static %}
{% block js %}
<script src="{% static "js/tooltip.js" %}"></script>
<script>
$(function () {
$('[data-toggle="tooltip"]').tooltip();
});
</script>
{% endblock %}
{% block content %}
<form method="GET" class="ml-auto w-25 d-flex flex-column justify-content-end">
<form method="GET" class="ms-auto w-25 d-flex flex-column justify-content-end">
{% csrf_token %}
<div class="input-group">
<input type="search" name="q" placeholder="Search" value="{{ request.GET.q }}"
class="form-control" id="id_search_text"/>
<span class="input-group-append">{% button 'search' id="id_search" %}</span>
{% button 'search' id="id_search" %}
</div>
<button type="submit" class="btn btn-primary mt-2 {% if request.GET.is_supervisor %}active{%endif%}" data-toggle="button" aria-pressed="{% if request.GET.is_supervisor %}true{%endif%}" name="is_supervisor" value="{% if request.GET.is_supervisor %}{% else %}True{% endif %}">
<button type="submit" class="btn btn-primary mt-2 {% if request.GET.is_supervisor %}active{%endif%}" data-bs-toggle="button" aria-pressed="{% if request.GET.is_supervisor %}true{%endif%}" name="is_supervisor" value="{% if request.GET.is_supervisor %}{% else %}True{% endif %}">
Only Supervisors
</button>
</form>

View File

@@ -4,7 +4,7 @@
<div class="alert alert-danger" role="alert">
<p>Are you sure you wish to delete {{ page_title }}</p>
<div class="text-right">
<div class="text-end">
<form action="{{ action_link }}" method="post">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'level_detail' object.level.pk %}"/>

View File

@@ -47,7 +47,7 @@ def get_levels_of_depth(trainee, level):
@register.simple_tag
def confirm_button(user, trainee, level):
if level.user_has_requirements(trainee):
string = "<span class='badge badge-warning p-2'>Awaiting Confirmation</span>"
string = "<span class='badge text-bg-warning p-2'>Awaiting Confirmation</span>"
if models.Trainee.objects.get(pk=user.pk).is_supervisor or user.has_perm(
"training.add_traininglevelqualification"
):