feat(isdirty): Navigations-Guard gegen ungespeichertes Verlassen von Bearbeitungsmasken

Zentraler DirtyGuard (dirtyguard.ts) mit DirtyMaskHelper-Klasse und
3-Button-Bestätigungs-Modal (Speichern/Verwerden/Abbrechen). Form meldet
Dirty-State via onDirtyChange nach oben; input-Event ergänzt change für
frühzeitiges Dirty-Marking. Alle App-Navigationen (Breadcrumb, Menü,
In-Masken-Buttons, Abbrechen) laufen über guardedNavigate. Auto-Redraw
bei dirty per e.redraw=false unterbunden, damit DOM-Eingaben erhalten
bleiben (Input-Widget hat kein Live-Binding).

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
gg committed 2026-09-19 18:21:58 +02:00
1 parent fd30049a32
commit a67921ec74
10 files changed
+462 -120

No files matched your search

+13 -8
View File
@@ -5,6 +5,7 @@ import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton}
import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb"
import {iKunde, fetchKunden, fetchKundeById, createKundeREST, updateKundeREST} from "../models/kunde"
import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
export const kunde: m.ClosureComponent = (vnode) => {
let currentKunde: iKunde = {
@@ -17,6 +18,8 @@ export const kunde: m.ClosureComponent = (vnode) => {
}
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = []
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
const guard = new DirtyMaskHelper("/kundenliste")
return {
oninit: (vnode) => {
@@ -24,6 +27,7 @@ export const kunde: m.ClosureComponent = (vnode) => {
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
// Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc)
guard.registerGuard()
if (id !== -1) {
// Kundendaten frisch vom Server holen, um zwischenzeitliche
// Änderungen anderer Nutzer nicht zu überschreiben.
@@ -32,6 +36,9 @@ export const kunde: m.ClosureComponent = (vnode) => {
}).catch((e) => showServerError(e))
}
},
onremove: (vnode) => {
guard.cleanup()
},
view: (vnode) => {
const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form)
@@ -44,30 +51,28 @@ export const kunde: m.ClosureComponent = (vnode) => {
land: (formData.get("land") as string) || ""
}
if (currentKunde.id === -1) {
createKundeREST(gespeicherterKunde).then(() => m.route.set("/kundenliste")).catch((e) => showServerError(e))
createKundeREST(gespeicherterKunde).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} else {
updateKundeREST(gespeicherterKunde).then(() => m.route.set("/kundenliste")).catch((e) => showServerError(e))
updateKundeREST(gespeicherterKunde).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
}
return true
}
const cancel = (e: Event) => {
m.route.set("/kundenliste")
}
return m("div.w3-container.w3-auto.form-view-container",
m("div", {style: {display: 'flex', 'align-items': 'center', 'justify-content': 'space-between'}},
m("h1", currentKunde.id === -1 ? "Neuen Kunden anlegen" : "Kundenstammblatt"),
currentKunde.id !== -1
? m("button.w3-button.w3-theme",
{onclick: () => m.route.set("/kontaktliste", {kunden_id: currentKunde.id})},
{onclick: (e: MouseEvent) => guardedNavigate("/kontaktliste", {kunden_id: currentKunde.id}, e)},
m("i.material-icons", "contact_page"), " Kontakte")
: null
),
m(BreadcrumbBar, {
breadcrumbs: breadcrumbs,
current: currentKunde.id === -1 ? "Neuer Kunde" : "Kundenstammblatt"
current: currentKunde.id === -1 ? "Neuer Kunde" : "Kundenstammblatt",
onnavigate: guardedNavigate
} as iBreadcrumbBar),
m("div", {style: {flex: '1', 'min-height': '0'}},
m(Form, {showButtons: true, onsubmit: submit, oncancel: cancel} as iForm, [
m(Form, guard.formAttrs(submit) as iForm, [
m("label", "Kundenname", m(Input, {name: "kundenname", value: currentKunde.name, type: "text", required: true} as iInput)),
m("label", "Straße", m(Input, {name: "strasse", value: currentKunde.strasse, type: "text"} as iInput)),
m("label", "PLZ", m(Input, {name: "plz", value: currentKunde.plz, type: "text"} as iInput)),