App-weite Warnung bei ungespeicherten Aenderungen + Options-UI-Fix + Cache-Busting
1. Generischer Ja/Nein-Dialog bei ungespeicherten Aenderungen (kein Opt-in pro Seite noetig): initUnsavedChangesGuard() erkennt automatisch jedes Formular mit echten Eingabefeldern, das seit dem Laden der Seite veraendert wurde, und fragt beim Klick auf einen Link (z.B. Sidebar- Navigation), ob zuerst gespeichert werden soll -- eigener Dialog (confirmSaveDiscard) statt des bestehenden confirmAction (dort ist "Abbrechen" = Bleiben, hier gibt es bewusst nur die zwei angefragten Optionen: speichern oder verwerfen, beide verlassen die Seite). "Ja, speichern" sendet das Formular ganz normal ab (echter POST) und merkt sich das eigentliche Ziel in sessionStorage, um danach automatisch dorthin weiterzuleiten. Nachtrag noch in dieser Session gefixt: ein normales Speichern ueber den Seiten-eigenen Button loeste zusaetzlich die native Browser-Warnung (beforeunload) aus, obwohl die Aenderung bereits gespeichert wurde -- navigatingAway-Flag wird jetzt bei jedem Submit eines beobachteten Formulars (eigener Button ODER eigener Ja/Nein-Dialog) gesetzt, die generische Browser-Warnung erscheint dadurch nur noch bei tatsaechlich ungefragter Navigation (Tab schliessen, Reload, Adresszeile). Live per Playwright in drei Szenarien verifiziert (direktes Speichern, Ja via eigenem Dialog, Nein via eigenem Dialog) -- kein Browser-Popup in den ersten beiden Faellen, korrektes Redirect zum urspruenglichen Ziel nach "Ja". 2. DHCP-Options-Dropdown (Reservierungs-Dialog) verschiebt ein neu ausgewaehltes Options-Feld jetzt direkt unter das Dropdown (in einen .poe-added-options-Container) statt es nur an seiner urspruenglichen Stelle in der bis zu 57 Eintraege langen Liste einzublenden -- lag dort oft ausserhalb des sichtbaren Modal-Ausschnitts und wirkte, als waere nichts passiert. Bereits gesetzte Overrides stehen von Anfang an im selben Container, weitere haengen sich beim Auswaehlen unten an. 3. Cache-Busting fuer app.js/style.css (asset_url()-Helper, haengt die Aenderungszeit der Datei als ?v=... an) -- ein im Browser gecachtes, veraltetes app.js konnte nach einem Update sonst den Eindruck erwecken, eine neue Funktion wuerde "nicht funktionieren", obwohl der Code auf dem Server laengst aktuell war. Alle drei live auf der Test-VM deployt und per Playwright verifiziert, keine Fehler im journalctl-Log. (Testartefakt aufgeraeumt: der echte Host-Hostname wurde durch die Playwright-Tests versehentlich auf "directsave-test"/"navsave-test" geaendert und wieder auf POETEST zurueckgesetzt.)
This commit is contained in:
@@ -378,6 +378,22 @@ def _ordered_nav_items():
|
||||
return items
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_asset_version():
|
||||
"""Cache-Busting für statische Dateien (CSS/JS): hängt die letzte
|
||||
Änderungszeit der Datei als ?v=... an, damit Browser nach einem Update
|
||||
(z.B. app.js) nicht stillschweigend eine veraltete, gecachte Version
|
||||
weiterverwenden — sonst können neue Funktionen scheinbar "nicht
|
||||
funktionieren", obwohl der Code auf dem Server längst aktuell ist."""
|
||||
def asset_url(filename):
|
||||
try:
|
||||
mtime = int(os.path.getmtime(os.path.join(app.static_folder, filename)))
|
||||
except OSError:
|
||||
mtime = 0
|
||||
return f"{url_for('static', filename=filename)}?v={mtime}"
|
||||
return {"asset_url": asset_url}
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_nav():
|
||||
if not current_user.is_authenticated:
|
||||
|
||||
@@ -154,6 +154,136 @@
|
||||
openModal("confirm-modal");
|
||||
};
|
||||
|
||||
/* ---------------- Ja/Nein-Dialog für ungespeicherte Änderungen ---------------- */
|
||||
/* Eigener Dialog statt confirmAction (dort ist "Abbrechen" = beim Bleiben,
|
||||
hier gibt es bewusst nur die zwei angefragten Optionen: speichern oder
|
||||
verwerfen — beide verlassen die Seite, nur "Abbrechen"/Escape/Backdrop
|
||||
bricht das Verlassen selbst ab und lässt die Seite unverändert offen. */
|
||||
function ensureSaveDiscardModal() {
|
||||
if (document.getElementById("save-discard-modal")) return;
|
||||
const html = `
|
||||
<div class="modal-overlay" id="save-discard-modal">
|
||||
<div class="modal" style="max-width:420px;">
|
||||
<div class="modal-header">
|
||||
<h3>Ungespeicherte Änderungen</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-dim" id="save-discard-message"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" id="save-discard-no">Nein, verwerfen</button>
|
||||
<button type="button" class="btn btn-primary" id="save-discard-yes">Ja, speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.insertAdjacentHTML("beforeend", html);
|
||||
document.querySelectorAll("#save-discard-modal [data-close-modal]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => closeModal(btn));
|
||||
});
|
||||
document.getElementById("save-discard-modal").addEventListener("click", (e) => {
|
||||
if (e.target.id === "save-discard-modal") closeModal(e.target);
|
||||
});
|
||||
}
|
||||
|
||||
window.confirmSaveDiscard = function (message, onSave, onDiscard) {
|
||||
ensureSaveDiscardModal();
|
||||
document.getElementById("save-discard-message").innerText = message;
|
||||
const yesBtn = document.getElementById("save-discard-yes");
|
||||
const noBtn = document.getElementById("save-discard-no");
|
||||
const freshYes = yesBtn.cloneNode(true);
|
||||
yesBtn.parentNode.replaceChild(freshYes, yesBtn);
|
||||
const freshNo = noBtn.cloneNode(true);
|
||||
noBtn.parentNode.replaceChild(freshNo, noBtn);
|
||||
freshYes.addEventListener("click", () => { closeModal(freshYes); onSave(); });
|
||||
freshNo.addEventListener("click", () => { closeModal(freshNo); onDiscard(); });
|
||||
openModal("save-discard-modal");
|
||||
};
|
||||
|
||||
/* ---------------- Warnung bei ungespeicherten Änderungen ---------------- */
|
||||
/* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten
|
||||
Eingabefeldern (nicht nur versteckten Aktions-Feldern wie bei Löschen/
|
||||
Toggle-Buttons) seit dem Laden der Seite verändert wurde, und fragt vor
|
||||
dem Verlassen (Klick auf einen Link, z.B. in der Navigation) per Ja/Nein,
|
||||
ob zuerst gespeichert werden soll — statt Änderungen stillschweigend zu
|
||||
verwerfen. "Ja" sendet das Formular ganz normal ab (echter POST, kein
|
||||
AJAX-Nachbau nötig) und merkt sich das eigentliche Ziel in
|
||||
sessionStorage, um nach dem Speichern automatisch dorthin
|
||||
weiterzuleiten, statt dass der Link ein zweites Mal angeklickt werden
|
||||
muss. Für Browser-eigene Navigation (Tab schließen, Reload, Adresszeile)
|
||||
gibt es zusätzlich beforeunload — dort erlaubt der Browser aus
|
||||
Sicherheitsgründen aber nur eine generische Warnung, kein eigenes
|
||||
Ja/Nein/Speichern-Dialogfeld. */
|
||||
const PENDING_NAV_KEY = "poe-pending-nav-after-save";
|
||||
|
||||
function serializeForm(form) {
|
||||
return new URLSearchParams(new FormData(form)).toString();
|
||||
}
|
||||
|
||||
function initUnsavedChangesGuard() {
|
||||
const pendingNav = sessionStorage.getItem(PENDING_NAV_KEY);
|
||||
if (pendingNav) {
|
||||
sessionStorage.removeItem(PENDING_NAV_KEY);
|
||||
window.location.href = pendingNav;
|
||||
return;
|
||||
}
|
||||
|
||||
const trackable = [];
|
||||
document.querySelectorAll("form[method='post' i]").forEach((form) => {
|
||||
if (form.hasAttribute("data-no-unsaved-guard")) return;
|
||||
const fields = form.querySelectorAll("input:not([type=hidden]):not([type=submit]):not([type=button]), select, textarea");
|
||||
if (!fields.length) return; // reine Aktions-Formulare (Löschen/Toggle) ohne Eingabefeld
|
||||
trackable.push({ form, initial: serializeForm(form) });
|
||||
});
|
||||
if (!trackable.length) return;
|
||||
|
||||
// Verhindert die native Browser-Warnung (beforeunload), wenn die Seite
|
||||
// ohnehin schon bewusst verlassen wird — entweder weil eines der
|
||||
// beobachteten Formulare ganz normal abgeschickt wurde (eigener
|
||||
// "Speichern"/"Anlegen"-Button auf der Seite) oder weil unser eigener
|
||||
// Ja/Nein-Dialog die Navigation ausgelöst hat. Ohne das würde beim
|
||||
// normalen Speichern zusätzlich zum eigentlichen Erfolg immer noch die
|
||||
// generische Browser-Meldung aufpoppen — die soll ausschließlich dann
|
||||
// erscheinen, wenn tatsächlich UNGEFRAGT navigiert wird (Tab schließen,
|
||||
// Reload, Adresszeile), nicht bei einem bewussten Speichern.
|
||||
let navigatingAway = false;
|
||||
trackable.forEach(({ form }) => {
|
||||
form.addEventListener("submit", () => { navigatingAway = true; });
|
||||
});
|
||||
|
||||
function dirtyForm() {
|
||||
return trackable.find(({ form, initial }) => serializeForm(form) !== initial) || null;
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const link = e.target.closest("a[href]");
|
||||
if (!link || link.target === "_blank" || link.href.startsWith("javascript:") || link.hasAttribute("data-no-unsaved-guard")) return;
|
||||
const dirty = dirtyForm();
|
||||
if (!dirty) return;
|
||||
e.preventDefault();
|
||||
const href = link.href;
|
||||
window.confirmSaveDiscard(
|
||||
"Es gibt ungespeicherte Änderungen auf dieser Seite. Vor dem Verlassen speichern?",
|
||||
() => {
|
||||
navigatingAway = true;
|
||||
sessionStorage.setItem(PENDING_NAV_KEY, href);
|
||||
dirty.form.requestSubmit ? dirty.form.requestSubmit() : dirty.form.submit();
|
||||
},
|
||||
() => {
|
||||
navigatingAway = true;
|
||||
window.location.href = href;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", (e) => {
|
||||
if (!navigatingAway && dirtyForm()) {
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* Intercept forms/buttons marked with data-confirm="message" */
|
||||
function initConfirmables() {
|
||||
document.querySelectorAll("form[data-confirm]").forEach((form) => {
|
||||
@@ -205,17 +335,21 @@
|
||||
Feld (siehe settings_dhcp.html, "+ Option hinzufügen"-Auswahl bei
|
||||
Reservierungen) — so sind nur tatsächlich genutzte Options-Felder
|
||||
sichtbar, statt jede einzeln immer als leeres Eingabefeld anzuzeigen.
|
||||
Scrollt das neue Feld zusätzlich in den sichtbaren Bereich und fokussiert
|
||||
es — bei bis zu ~60 möglichen Optionen kann das neu eingeblendete Feld
|
||||
sonst weit unterhalb des sichtbaren Modal-Ausschnitts liegen und wirkt
|
||||
dann so, als wäre gar nichts passiert. */
|
||||
Verschiebt das Feld dafür direkt unter das Dropdown (in den Container
|
||||
mit der Klasse "poe-added-options", der im Markup direkt danach steht)
|
||||
statt es nur an seiner ursprünglichen Stelle einzublenden — bei bis zu
|
||||
~60 möglichen Optionen läge diese sonst weit unterhalb des sichtbaren
|
||||
Modal-Ausschnitts und es wirkte so, als wäre gar nichts passiert. Jede
|
||||
weitere Auswahl hängt ihr Feld unten an, sodass alle bereits
|
||||
hinzugefügten Options sichtbar aufgereiht bleiben. */
|
||||
window.poeShowOptionField = function (select) {
|
||||
const id = select.value;
|
||||
if (id) {
|
||||
const field = document.getElementById(id);
|
||||
if (field) {
|
||||
const container = select.nextElementSibling;
|
||||
if (field && container && container.classList.contains("poe-added-options")) {
|
||||
container.appendChild(field);
|
||||
field.style.display = "";
|
||||
field.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const input = field.querySelector("input");
|
||||
if (input) input.focus();
|
||||
}
|
||||
@@ -407,6 +541,7 @@
|
||||
initCheckTimer();
|
||||
initRunCheckNow();
|
||||
initLeaseCountdowns();
|
||||
initUnsavedChangesGuard();
|
||||
initSortableTables();
|
||||
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme));
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ title or "PoE Manager" }}</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='images/logo.png') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<link rel="stylesheet" href="{{ asset_url('css/style.css') }}">
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
@@ -147,7 +147,7 @@
|
||||
<script type="application/json" id="flashed-data">{{ messages|tojson }}</script>
|
||||
{% endwith %}
|
||||
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
<script src="{{ asset_url('js/app.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="post">
|
||||
<form method="post" data-no-unsaved-guard>
|
||||
<div class="field">
|
||||
<label for="username">Benutzername</label>
|
||||
<input type="text" id="username" name="username" autocomplete="username" required autofocus>
|
||||
|
||||
@@ -421,7 +421,7 @@
|
||||
Nur Options mit gesetztem Override sind sichtbar — über das Dropdown weitere hinzufügen.
|
||||
Leerer Wert übernimmt den globalen Wert (bzw. entfernt den Override).
|
||||
</p>
|
||||
<select onchange="poeShowOptionField(this)" style="margin-bottom:14px;">
|
||||
<select onchange="poeShowOptionField(this)">
|
||||
<option value="">+ Option hinzufügen…</option>
|
||||
{% for d in option_defs %}
|
||||
{% if not option_values.get(d.id, {}).get(r.mac) %}
|
||||
@@ -429,13 +429,31 @@
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
<!-- Neu ausgewählte Felder landen hier direkt unter dem Dropdown
|
||||
(siehe poeShowOptionField in app.js), statt irgendwo in der
|
||||
u.U. langen Liste unten sichtbar zu werden. Bereits gesetzte
|
||||
Overrides stehen von Anfang an hier drin. -->
|
||||
<div class="poe-added-options" style="margin:10px 0;">
|
||||
{% for d in option_defs %}
|
||||
{% set current = option_values.get(d.id, {}).get(r.mac, '') %}
|
||||
{% if current %}
|
||||
<div class="field" id="opt-field-{{ d.id }}-{{ loop.index }}-{{ r.mac }}">
|
||||
<label>{{ d.name }} <span class="text-faint">(Code {{ d.code }})</span></label>
|
||||
<input type="text" name="opt_{{ d.id }}" value="{{ current }}"
|
||||
placeholder="{{ option_values.get(d.id, {}).get('', '(kein globaler Wert)') }}">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% for d in option_defs %}
|
||||
{% set current = option_values.get(d.id, {}).get(r.mac, '') %}
|
||||
<div class="field" id="opt-field-{{ d.id }}-{{ loop.index }}-{{ r.mac }}" {% if not current %}style="display:none;"{% endif %}>
|
||||
{% if not current %}
|
||||
<div class="field" id="opt-field-{{ d.id }}-{{ loop.index }}-{{ r.mac }}" style="display:none;">
|
||||
<label>{{ d.name }} <span class="text-faint">(Code {{ d.code }})</span></label>
|
||||
<input type="text" name="opt_{{ d.id }}" value="{{ current }}"
|
||||
<input type="text" name="opt_{{ d.id }}" value=""
|
||||
placeholder="{{ option_values.get(d.id, {}).get('', '(kein globaler Wert)') }}">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
||||
Reference in New Issue
Block a user