Replace browser confirm() dialogs with custom modal; add live label preview on battery add form

- base.html: add CSS/HTML/JS for styled in-app confirmation modal (dark-mode compatible via CSS vars)
- device_list, battery_detail: convert onsubmit confirm() to declarative data-confirm attributes
- dashboard: convert bulk Delete/Install buttons to use modal helpers (submitWithAction pattern)
- app.py: pass brand_counts dict to battery_add template
- battery_add.html: show live "Will create: Brand 001 → Brand 003" preview as brand/quantity change
- tests: add two tests covering brand_counts server-side rendering
This commit is contained in:
2026-04-13 09:53:21 -05:00
parent 3c2b2dc389
commit 39b52a3fa4
7 changed files with 135 additions and 14 deletions
+7 -1
View File
@@ -95,8 +95,14 @@ def create_app(config_object="config"):
.filter(Battery.storage_location.isnot(None)) .filter(Battery.storage_location.isnot(None))
.distinct().order_by(Battery.storage_location).all() .distinct().order_by(Battery.storage_location).all()
] ]
brand_counts = {
r[0]: r[1]
for r in db.query(Battery.brand, func.count(Battery.id))
.group_by(Battery.brand).all()
}
return render_template("battery_add.html", form_count=1, brands=brands, return render_template("battery_add.html", form_count=1, brands=brands,
storage_locations=storage_locations) storage_locations=storage_locations,
brand_counts=brand_counts)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Battery — detail # Battery — detail
+59
View File
@@ -56,6 +56,13 @@
--count-retired: #64748b; --count-retired: #64748b;
} }
/* Confirmation modal */
#confirm-modal { display:none; position:fixed; inset:0; z-index:1000; background:rgba(0,0,0,.45); align-items:center; justify-content:center; }
#confirm-modal.open { display:flex; }
#confirm-modal-box { background:var(--bg-card); border-radius:8px; padding:1.5rem; max-width:400px; width:calc(100% - 2rem); box-shadow:0 8px 24px rgba(0,0,0,.25); }
#confirm-modal-msg { margin-bottom:1.25rem; color:var(--text-body); font-size:0.95rem; line-height:1.5; }
#confirm-modal-actions { display:flex; gap:0.75rem; justify-content:flex-end; }
/* ─── Dark mode variables ────────────────────────────────────────── */ /* ─── Dark mode variables ────────────────────────────────────────── */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
@@ -326,10 +333,62 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
<div id="confirm-modal" role="dialog" aria-modal="true">
<div id="confirm-modal-box">
<p id="confirm-modal-msg"></p>
<div id="confirm-modal-actions">
<button id="confirm-modal-cancel" class="btn btn-secondary">Cancel</button>
<button id="confirm-modal-ok" class="btn btn-danger">Confirm</button>
</div>
</div>
</div>
<script> <script>
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js'); navigator.serviceWorker.register('/sw.js');
} }
(function() {
var modal = document.getElementById('confirm-modal');
var msgEl = document.getElementById('confirm-modal-msg');
var okBtn = document.getElementById('confirm-modal-ok');
var cancelBtn = document.getElementById('confirm-modal-cancel');
var _cb = null;
window.showConfirm = function(msg, onOk, okLabel, okClass) {
msgEl.textContent = msg;
okBtn.textContent = okLabel || 'Confirm';
okBtn.className = 'btn ' + (okClass || 'btn-danger');
modal.classList.add('open');
_cb = onOk;
cancelBtn.focus();
};
function closeModal() { modal.classList.remove('open'); _cb = null; }
okBtn.addEventListener('click', function() {
var cb = _cb; closeModal(); if (cb) cb();
});
cancelBtn.addEventListener('click', closeModal);
modal.addEventListener('click', function(e) { if (e.target === modal) closeModal(); });
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && modal.classList.contains('open')) closeModal();
});
// Global handler: forms with data-confirm attribute
document.addEventListener('submit', function(e) {
var form = e.target;
var msg = form.dataset.confirm;
if (!msg || form.dataset.confirmed) return;
e.preventDefault();
var okLabel = form.dataset.confirmOk || 'Confirm';
var okClass = form.dataset.confirmClass || 'btn-danger';
window.showConfirm(msg, function() {
form.dataset.confirmed = '1';
form.submit();
}, okLabel, okClass);
});
}());
</script> </script>
</body> </body>
</html> </html>
+21
View File
@@ -27,6 +27,7 @@
<label for="count">Quantity</label> <label for="count">Quantity</label>
<input type="number" id="count" name="count" value="{{ form_count|default(1) }}" min="1" max="50"> <input type="number" id="count" name="count" value="{{ form_count|default(1) }}" min="1" max="50">
<small class="text-muted">Labels are auto-generated (e.g. Eneloop 001, Eneloop 002)</small> <small class="text-muted">Labels are auto-generated (e.g. Eneloop 001, Eneloop 002)</small>
<div id="label-preview" style="font-size:0.85rem;color:var(--link);margin-top:0.3rem;font-weight:500;min-height:1.2em;"></div>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -96,6 +97,8 @@
</div> </div>
<script> <script>
var brandCounts = {{ brand_counts|default({})|tojson }};
function metaSelectChanged(sel, inputId) { function metaSelectChanged(sel, inputId) {
var input = document.getElementById(inputId); var input = document.getElementById(inputId);
if (sel.value === '__new__') { if (sel.value === '__new__') {
@@ -106,8 +109,26 @@ function metaSelectChanged(sel, inputId) {
input.style.display = 'none'; input.style.display = 'none';
input.value = sel.value; input.value = sel.value;
} }
if (inputId === 'brand') updateLabelPreview();
} }
function updateLabelPreview() {
var brand = document.getElementById('brand').value.trim();
var count = parseInt(document.getElementById('count').value, 10) || 1;
var preview = document.getElementById('label-preview');
if (!brand) { preview.textContent = ''; return; }
var existing = brandCounts[brand] !== undefined ? brandCounts[brand] : 0;
var first = existing + 1;
var last = existing + count;
var pad = function(n) { return n.toString().padStart(3, '0'); };
preview.textContent = count === 1
? 'Will create: ' + brand + ' ' + pad(first)
: 'Will create: ' + brand + ' ' + pad(first) + ' \u2192 ' + brand + ' ' + pad(last);
}
document.getElementById('count').addEventListener('input', updateLabelPreview);
document.getElementById('brand').addEventListener('input', updateLabelPreview);
// Restore brand state on error re-render // Restore brand state on error re-render
(function () { (function () {
var input = document.getElementById('brand'); var input = document.getElementById('brand');
+2 -2
View File
@@ -114,7 +114,7 @@
<td data-label=""> <td data-label="">
<form class="inline" method="post" <form class="inline" method="post"
action="{{ url_for('battery_capacity_test_delete', battery_id=battery.id, test_id=t.id) }}" action="{{ url_for('battery_capacity_test_delete', battery_id=battery.id, test_id=t.id) }}"
onsubmit="return confirm('Delete this test record?')"> data-confirm="Delete this test record?" data-confirm-ok="Delete">
<button class="btn btn-sm btn-danger" type="submit">Delete</button> <button class="btn btn-sm btn-danger" type="submit">Delete</button>
</form> </form>
</td> </td>
@@ -172,7 +172,7 @@
<td data-label=""> <td data-label="">
<form class="inline" method="post" <form class="inline" method="post"
action="{{ url_for('battery_charge_log_delete', battery_id=battery.id, log_id=log.id) }}" action="{{ url_for('battery_charge_log_delete', battery_id=battery.id, log_id=log.id) }}"
onsubmit="return confirm('Delete this charge log entry?')"> data-confirm="Delete this charge log entry?" data-confirm-ok="Delete">
<button class="btn btn-sm btn-danger" type="submit">Delete</button> <button class="btn btn-sm btn-danger" type="submit">Delete</button>
</form> </form>
</td> </td>
+24 -9
View File
@@ -80,8 +80,8 @@
<span id="selected-count" style="font-size:0.85rem;color:#64748b;margin-right:0.25rem;"></span> <span id="selected-count" style="font-size:0.85rem;color:#64748b;margin-right:0.25rem;"></span>
<button class="btn btn-sm btn-warning" name="action" value="unassign" type="submit">Unassign</button> <button class="btn btn-sm btn-warning" name="action" value="unassign" type="submit">Unassign</button>
<button class="btn btn-sm btn-secondary" name="action" value="retire" type="submit">Retire</button> <button class="btn btn-sm btn-secondary" name="action" value="retire" type="submit">Retire</button>
<button class="btn btn-sm btn-danger" name="action" value="delete" type="submit" <button class="btn btn-sm btn-danger" name="action" value="delete" type="button"
onclick="return confirm('Permanently delete selected batteries?')">Delete</button> onclick="bulkActionConfirm(this, 'Permanently delete selected batteries?', 'Delete', 'btn-danger')">Delete</button>
<span style="display:flex;gap:0.35rem;align-items:center;flex-wrap:wrap;"> <span style="display:flex;gap:0.35rem;align-items:center;flex-wrap:wrap;">
<input type="hidden" name="field_name" id="bulk-field-name" value="storage_location"> <input type="hidden" name="field_name" id="bulk-field-name" value="storage_location">
<select id="bulk-field-select" onchange="updateBulkField(this)" <select id="bulk-field-select" onchange="updateBulkField(this)"
@@ -121,8 +121,8 @@
<option value="{{ d.id }}">{{ d.name }} ({{ d.installed_count() }}/{{ d.battery_slots }})</option> <option value="{{ d.id }}">{{ d.name }} ({{ d.installed_count() }}/{{ d.battery_slots }})</option>
{% endfor %} {% endfor %}
</select> </select>
<button class="btn btn-sm btn-primary" name="action" value="install_device" type="submit" <button class="btn btn-sm btn-primary" name="action" value="install_device" type="button"
onclick="return confirmInstallDevice()">Install in device</button> onclick="confirmInstallDevice(this)">Install in device</button>
</span> </span>
<span style="display:flex;gap:0.35rem;align-items:center;flex-wrap:wrap;"> <span style="display:flex;gap:0.35rem;align-items:center;flex-wrap:wrap;">
<input type="date" name="charged_date" id="bulk-charged-date" <input type="date" name="charged_date" id="bulk-charged-date"
@@ -278,21 +278,36 @@ function applyFilters() {
updateToolbar(); updateToolbar();
} }
function confirmInstallDevice() { function confirmInstallDevice(btn) {
var deviceSel = document.getElementById('bulk-device-select'); var deviceSel = document.getElementById('bulk-device-select');
if (!deviceSel.value) { deviceSel.focus(); return false; } if (!deviceSel.value) { deviceSel.focus(); return; }
var movers = Array.prototype.filter.call( var movers = Array.prototype.filter.call(
document.querySelectorAll('.row-cb:checked'), document.querySelectorAll('.row-cb:checked'),
function(cb) { return cb.closest('tr').dataset.status === 'installed'; } function(cb) { return cb.closest('tr').dataset.status === 'installed'; }
); );
if (movers.length > 0) { if (movers.length > 0) {
var n = movers.length; var n = movers.length;
return confirm( showConfirm(
n + ' selected batter' + (n === 1 ? 'y is' : 'ies are') + n + ' selected batter' + (n === 1 ? 'y is' : 'ies are') +
' already installed elsewhere. Unassign and move to the selected device?' ' already installed elsewhere. Unassign and move to the selected device?',
function() { submitWithAction(btn); },
'Move', 'btn-warning'
); );
} else {
submitWithAction(btn);
} }
return true; }
function bulkActionConfirm(btn, msg, okLabel, okClass) {
showConfirm(msg, function() { submitWithAction(btn); }, okLabel, okClass);
}
function submitWithAction(btn) {
var form = btn.form || document.getElementById('bulk-form');
var inp = document.createElement('input');
inp.type = 'hidden'; inp.name = btn.name; inp.value = btn.value;
form.appendChild(inp);
form.submit();
} }
function quickAssign(action, batteryId) { function quickAssign(action, batteryId) {
+4 -2
View File
@@ -59,12 +59,14 @@
<a class="btn btn-sm btn-secondary" href="{{ url_for('device_detail', device_id=d.id) }}">View</a> <a class="btn btn-sm btn-secondary" href="{{ url_for('device_detail', device_id=d.id) }}">View</a>
{% if d.installed_count() > 0 %} {% if d.installed_count() > 0 %}
<form class="inline" method="post" action="{{ url_for('device_unassign_all', device_id=d.id) }}" <form class="inline" method="post" action="{{ url_for('device_unassign_all', device_id=d.id) }}"
onsubmit="return confirm('Unassign all batteries from {{ d.name }}?')"> data-confirm="Unassign all batteries from {{ d.name }}?"
data-confirm-ok="Unassign" data-confirm-class="btn-warning">
<button class="btn btn-sm btn-warning" type="submit">Unassign All</button> <button class="btn btn-sm btn-warning" type="submit">Unassign All</button>
</form> </form>
{% endif %} {% endif %}
<form class="inline" method="post" action="{{ url_for('device_delete', device_id=d.id) }}" <form class="inline" method="post" action="{{ url_for('device_delete', device_id=d.id) }}"
onsubmit="return confirm('Delete {{ d.name }}? All installed batteries will be unassigned.');"> data-confirm="Delete {{ d.name }}? All installed batteries will be unassigned."
data-confirm-ok="Delete" data-confirm-class="btn-danger">
<button class="btn btn-sm btn-danger" type="submit">Delete</button> <button class="btn btn-sm btn-danger" type="submit">Delete</button>
</form> </form>
</td> </td>
+18
View File
@@ -41,6 +41,24 @@ def test_dashboard_loads(seeded_client):
assert b"BrandX 002" in resp.data assert b"BrandX 002" in resp.data
# ------------------------------------------------------------------ #
# Battery add — label preview data
# ------------------------------------------------------------------ #
def test_battery_add_get_renders(client):
resp = client.get("/battery/add")
assert resp.status_code == 200
assert b"brandCounts" in resp.data
def test_battery_add_brand_counts_reflects_existing(seeded_client):
# seeded_client has BrandX x2, BrandY x1
resp = seeded_client.get("/battery/add")
assert resp.status_code == 200
assert b'"BrandX": 2' in resp.data or b'"BrandX":2' in resp.data
assert b'"BrandY": 1' in resp.data or b'"BrandY":1' in resp.data
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Battery — bulk add # Battery — bulk add
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #