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:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
Reference in New Issue
Block a user