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

+31 -31
View File
@@ -10,6 +10,7 @@ import {iProjekt, fetchProjekte, fetchProjektById} from "../models/projekt"
import {iKontaktadresse, fetchKontaktadressen} from "../models/kontaktadresse" import {iKontaktadresse, fetchKontaktadressen} from "../models/kontaktadresse"
import {iTag, fetchTags} from "../models/tag" import {iTag, fetchTags} from "../models/tag"
import {showServerError} from "./auth/authmessages" import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
// Aktualisierungsintervall für die Dauer-Anzeige in Millisekunden. // Aktualisierungsintervall für die Dauer-Anzeige in Millisekunden.
const DAUER_UPDATE_INTERVALL = 1000 const DAUER_UPDATE_INTERVALL = 1000
@@ -241,6 +242,26 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
let returnProjektName: string = "" let returnProjektName: string = ""
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc). // Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = [] let breadcrumbs: iBreadcrumb[] = []
// Route-Params für die Rückkehr zur Notizliste; als Closure an den
// DirtyMaskHelper übergeben (greift auf fromListe, returnProjektId,
// currentLog, breadcrumbs zu).
const backParams = (): any => {
const params: any = {}
if (fromListe) {
if (returnProjektId !== null) {
params.projekt_id = returnProjektId
params.projekt_name = returnProjektName
}
} else if (currentLog.projekt_id !== -1) {
params.projekt_id = currentLog.projekt_id
params.projekt_name = currentLog.projekt_name
}
const listBc = serializeBc(breadcrumbs.slice(0, -1))
if (listBc) params.bc = listBc
return params
}
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
const guard = new DirtyMaskHelper("/calllogliste", backParams)
/** /**
* Formatiert verstrichene Sekunden als mm:ss. * Formatiert verstrichene Sekunden als mm:ss.
@@ -264,6 +285,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
} }
// Breadcrumb-Kette aus Route-Param parsen. // Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc) breadcrumbs = parseBc(routeParams && routeParams.bc)
guard.registerGuard()
if (id !== -1) { if (id !== -1) {
// Gesprächsnotiz frisch vom Server holen. // Gesprächsnotiz frisch vom Server holen.
fetchCallLogById(id).then((result) => { fetchCallLogById(id).then((result) => {
@@ -307,6 +329,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
}, },
onremove: (vnode) => { onremove: (vnode) => {
if (timerId != null) { window.clearInterval(timerId) } if (timerId != null) { window.clearInterval(timerId) }
guard.cleanup()
}, },
view: (vnode) => { view: (vnode) => {
// kunden_id aus dem geladenen Projekt ableiten (fuer abhaengiges // kunden_id aus dem geladenen Projekt ableiten (fuer abhaengiges
@@ -343,29 +366,6 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
}) })
const tagColors: Map<number, string> = new Map(tagListe.map((t: iTag) => [t.id, t.color] as [number, string])) const tagColors: Map<number, string> = new Map(tagListe.map((t: iTag) => [t.id, t.color] as [number, string]))
const goBack = () => {
const params: any = {}
if (fromListe) {
// Rückkehr in den Listenkontext, aus dem die Maske
// geöffnet wurde. Ohne return_projekt_id ungefiltert.
if (returnProjektId !== null) {
params.projekt_id = returnProjektId
params.projekt_name = returnProjektName
}
} else if (currentLog.projekt_id !== -1) {
// Fallback: Maske wurde nicht aus der Liste geöffnet
// (z.B. addCallLog aus projekt.ts/kontaktadresse.ts).
params.projekt_id = currentLog.projekt_id
params.projekt_name = currentLog.projekt_name
}
// Breadcrumb-Kette ohne das letzte Glied (die Notizliste
// selbst) an die Liste durchreichen, damit diese die
// Vorgänger-Ansichten wieder als Leiste anzeigen kann.
const listBc = serializeBc(breadcrumbs.slice(0, -1))
if (listBc) params.bc = listBc
m.route.set("/calllogliste", params)
}
const submit = (form: HTMLFormElement): boolean => { const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form) const formData = new FormData(form)
const gespeichertesLog: iCallLog = { const gespeichertesLog: iCallLog = {
@@ -385,23 +385,21 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
tag_ids: formData.getAll("tag_ids").map((v) => parseInt(v as string)).filter((n) => !isNaN(n)) tag_ids: formData.getAll("tag_ids").map((v) => parseInt(v as string)).filter((n) => !isNaN(n))
} }
if (currentLog.id === -1) { if (currentLog.id === -1) {
createCallLogREST(gespeichertesLog).then(() => goBack()).catch((e) => showServerError(e)) createCallLogREST(gespeichertesLog).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} else { } else {
updateCallLogREST(gespeichertesLog).then(() => goBack()).catch((e) => showServerError(e)) updateCallLogREST(gespeichertesLog).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} }
return true return true
} }
const cancel = (e: Event) => {
goBack()
}
return m("div.w3-container.w3-auto.form-view-container", return m("div.w3-container.w3-auto.form-view-container",
m("h1", currentLog.id === -1 ? "Neue Gesprächsnotiz anlegen" : "Gesprächsnotiz"), m("h1", currentLog.id === -1 ? "Neue Gesprächsnotiz anlegen" : "Gesprächsnotiz"),
m(BreadcrumbBar, { m(BreadcrumbBar, {
breadcrumbs: breadcrumbs, breadcrumbs: breadcrumbs,
current: currentLog.id === -1 ? "Neue Gesprächsnotiz" : "Gesprächsnotiz" current: currentLog.id === -1 ? "Neue Gesprächsnotiz" : "Gesprächsnotiz",
onnavigate: guardedNavigate
} as iBreadcrumbBar), } as iBreadcrumbBar),
m("div", {style: {flex: '1', 'min-height': '0'}}, 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("input", {type: "hidden", name: "projekt_id", value: currentLog.projekt_id}), m("input", {type: "hidden", name: "projekt_id", value: currentLog.projekt_id}),
// Kunde und Projekt als Read-Only-Text, da voreingestellt. // Kunde und Projekt als Read-Only-Text, da voreingestellt.
m("label", "Kunde", m("label", "Kunde",
@@ -449,6 +447,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
if (ka) { if (ka) {
currentLog.name_ansprechpartner = ka.name currentLog.name_ansprechpartner = ka.name
currentLog.telefon = ka.telefon currentLog.telefon = ka.telefon
guard.markDirty()
} }
} }
} as iSelect)), } as iSelect)),
@@ -463,7 +462,8 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
colors: tagColors, colors: tagColors,
placeholder: "kein Tag", placeholder: "kein Tag",
modalTitle: "Tags auswählen", modalTitle: "Tags auswählen",
editTooltip: "Tags bearbeiten" editTooltip: "Tags bearbeiten",
onchanged: () => { guard.markDirty() }
} as iMultiSelect)), } as iMultiSelect)),
m("label", "Wiedervorlage Datum", m(Input, {name: "wiedervorlage_datum", value: currentLog.wiedervorlage_datum, type: "date"} as iInput)), m("label", "Wiedervorlage Datum", m(Input, {name: "wiedervorlage_datum", value: currentLog.wiedervorlage_datum, type: "date"} as iInput)),
m("label", m(Input, {name: "erledigt", type: "checkbox", value: currentLog.erledigt} as iInput), "Erledigt") m("label", m(Input, {name: "erledigt", type: "checkbox", value: currentLog.erledigt} as iInput), "Erledigt")
+21 -15
View File
@@ -6,6 +6,7 @@ import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "
import {iKontakt, fetchKontakte, fetchKontaktById, createKontaktREST, updateKontaktREST} from "../models/kontakt" import {iKontakt, fetchKontakte, fetchKontaktById, createKontaktREST, updateKontaktREST} from "../models/kontakt"
import {iKunde, fetchKundeById} from "../models/kunde" import {iKunde, fetchKundeById} from "../models/kunde"
import {showServerError} from "./auth/authmessages" import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
export const kontaktliste: m.ClosureComponent = (vnode) => { export const kontaktliste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = [] let treenodes: iTreenode[] = []
@@ -150,6 +151,17 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
} }
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc). // Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = [] let breadcrumbs: iBreadcrumb[] = []
// Route-Params für die Rückkehr zur Kontaktliste; als Closure an den
// DirtyMaskHelper übergeben, da dieser die maskenspezifischen Variablen
// (currentKontakt, breadcrumbs) nicht kennt.
const backParams = (): any => {
const listBc = serializeBc(breadcrumbs.slice(0, -1))
const params: any = {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name}
if (listBc) params.bc = listBc
return params
}
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
const guard = new DirtyMaskHelper("/kontaktliste", backParams)
return { return {
oninit: (vnode) => { oninit: (vnode) => {
@@ -157,6 +169,7 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1 const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
// Breadcrumb-Kette aus Route-Param parsen. // Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc) breadcrumbs = parseBc(routeParams && routeParams.bc)
guard.registerGuard()
if (id !== -1) { if (id !== -1) {
// Kontaktdaten frisch vom Server holen, um zwischenzeitliche // Kontaktdaten frisch vom Server holen, um zwischenzeitliche
// Änderungen anderer Nutzer nicht zu überschreiben. // Änderungen anderer Nutzer nicht zu überschreiben.
@@ -170,15 +183,10 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
currentKontakt.kunden_name = routeParams && routeParams.kunden_name ? routeParams.kunden_name : "" currentKontakt.kunden_name = routeParams && routeParams.kunden_name ? routeParams.kunden_name : ""
} }
}, },
onremove: (vnode) => {
guard.cleanup()
},
view: (vnode) => { view: (vnode) => {
// Rückkehr zur Kontaktliste; das bc ohne das letzte Glied (die
// Kontaktliste selbst) durchreichen.
const goBack = () => {
const listBc = serializeBc(breadcrumbs.slice(0, -1))
const routeParams: any = {kunden_id: currentKontakt.kunden_id, kunden_name: currentKontakt.kunden_name}
if (listBc) routeParams.bc = listBc
m.route.set("/kontaktliste", routeParams)
}
const submit = (form: HTMLFormElement): boolean => { const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form) const formData = new FormData(form)
const gespeicherterKontakt: iKontakt = { const gespeicherterKontakt: iKontakt = {
@@ -193,25 +201,23 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
notiz: (formData.get("notiz") as string) || "" notiz: (formData.get("notiz") as string) || ""
} }
if (currentKontakt.id === -1) { if (currentKontakt.id === -1) {
createKontaktREST(gespeicherterKontakt).then(() => goBack()).catch((e) => showServerError(e)) createKontaktREST(gespeicherterKontakt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} else { } else {
updateKontaktREST(gespeicherterKontakt).then(() => goBack()).catch((e) => showServerError(e)) updateKontaktREST(gespeicherterKontakt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} }
return true return true
} }
const cancel = (e: Event) => {
goBack()
}
return m("div.w3-container.w3-auto.form-view-container", return m("div.w3-container.w3-auto.form-view-container",
m("h1", currentKontakt.id === -1 m("h1", currentKontakt.id === -1
? "Neuer Kontakt für " + currentKontakt.kunden_name ? "Neuer Kontakt für " + currentKontakt.kunden_name
: "Kontakt-Stammblatt"), : "Kontakt-Stammblatt"),
m(BreadcrumbBar, { m(BreadcrumbBar, {
breadcrumbs: breadcrumbs, breadcrumbs: breadcrumbs,
current: currentKontakt.id === -1 ? "Neuer Kontakt" : "Kontakt-Stammblatt" current: currentKontakt.id === -1 ? "Neuer Kontakt" : "Kontakt-Stammblatt",
onnavigate: guardedNavigate
} as iBreadcrumbBar), } as iBreadcrumbBar),
m("div", {style: {flex: '1', 'min-height': '0'}}, 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("input", {type: "hidden", name: "kunden_id", value: currentKontakt.kunden_id}), m("input", {type: "hidden", name: "kunden_id", value: currentKontakt.kunden_id}),
m("label", "Anrede", m(Input, {name: "anrede", value: currentKontakt.anrede, type: "text"} as iInput)), m("label", "Anrede", m(Input, {name: "anrede", value: currentKontakt.anrede, type: "text"} as iInput)),
m("label", "Name", m(Input, {name: "name", value: currentKontakt.name, type: "text", required: true} as iInput)), m("label", "Name", m(Input, {name: "name", value: currentKontakt.name, type: "text", required: true} as iInput)),
+20 -15
View File
@@ -6,6 +6,7 @@ import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "
import {iKontaktadresse, fetchKontaktadressen, fetchKontaktadresseById, createKontaktadresseREST, updateKontaktadresseREST} from "../models/kontaktadresse" import {iKontaktadresse, fetchKontaktadressen, fetchKontaktadresseById, createKontaktadresseREST, updateKontaktadresseREST} from "../models/kontaktadresse"
import {iProjekt, fetchProjektById} from "../models/projekt" import {iProjekt, fetchProjektById} from "../models/projekt"
import {showServerError} from "./auth/authmessages" import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
export const kontaktadresseliste: m.ClosureComponent = (vnode) => { export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = [] let treenodes: iTreenode[] = []
@@ -196,6 +197,16 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
} }
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc). // Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = [] let breadcrumbs: iBreadcrumb[] = []
// Route-Params für die Rückkehr zur Kontaktadressenliste; als Closure
// an den DirtyMaskHelper übergeben.
const backParams = (): any => {
const listBc = serializeBc(breadcrumbs.slice(0, -1))
const params: any = {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name}
if (listBc) params.bc = listBc
return params
}
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
const guard = new DirtyMaskHelper("/kontaktadresseliste", backParams)
return { return {
oninit: (vnode) => { oninit: (vnode) => {
@@ -203,6 +214,7 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1 const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
// Breadcrumb-Kette aus Route-Param parsen. // Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc) breadcrumbs = parseBc(routeParams && routeParams.bc)
guard.registerGuard()
if (id !== -1) { if (id !== -1) {
// Kontaktadresse frisch vom Server holen. // Kontaktadresse frisch vom Server holen.
fetchKontaktadresseById(id).then((result) => { fetchKontaktadresseById(id).then((result) => {
@@ -214,15 +226,10 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
currentKontaktadresse.projekt_name = routeParams && routeParams.projekt_name ? routeParams.projekt_name : "" currentKontaktadresse.projekt_name = routeParams && routeParams.projekt_name ? routeParams.projekt_name : ""
} }
}, },
onremove: (vnode) => {
guard.cleanup()
},
view: (vnode) => { view: (vnode) => {
// Rückkehr zur Kontaktadressenliste; das bc ohne das letzte
// Glied (die Kontaktadressenliste selbst) durchreichen.
const goBack = () => {
const listBc = serializeBc(breadcrumbs.slice(0, -1))
const routeParams: any = {projekt_id: currentKontaktadresse.projekt_id, projekt_name: currentKontaktadresse.projekt_name}
if (listBc) routeParams.bc = listBc
m.route.set("/kontaktadresseliste", routeParams)
}
const submit = (form: HTMLFormElement): boolean => { const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form) const formData = new FormData(form)
const gespeicherteKontaktadresse: iKontaktadresse = { const gespeicherteKontaktadresse: iKontaktadresse = {
@@ -242,15 +249,12 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
notiz: (formData.get("notiz") as string) || "" notiz: (formData.get("notiz") as string) || ""
} }
if (currentKontaktadresse.id === -1) { if (currentKontaktadresse.id === -1) {
createKontaktadresseREST(gespeicherteKontaktadresse).then(() => goBack()).catch((e) => showServerError(e)) createKontaktadresseREST(gespeicherteKontaktadresse).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} else { } else {
updateKontaktadresseREST(gespeicherteKontaktadresse).then(() => goBack()).catch((e) => showServerError(e)) updateKontaktadresseREST(gespeicherteKontaktadresse).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} }
return true return true
} }
const cancel = (e: Event) => {
goBack()
}
return m("div.w3-container.w3-auto.form-view-container", return m("div.w3-container.w3-auto.form-view-container",
m("h1", currentKontaktadresse.id === -1 m("h1", currentKontaktadresse.id === -1
? "Neue Kontaktadresse für " + currentKontaktadresse.projekt_name ? "Neue Kontaktadresse für " + currentKontaktadresse.projekt_name
@@ -259,10 +263,11 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
breadcrumbs: breadcrumbs, breadcrumbs: breadcrumbs,
current: currentKontaktadresse.id === -1 current: currentKontaktadresse.id === -1
? "Neue Kontaktadresse" ? "Neue Kontaktadresse"
: "Kontaktadresse-Stammblatt" : "Kontaktadresse-Stammblatt",
onnavigate: guardedNavigate
} as iBreadcrumbBar), } as iBreadcrumbBar),
m("div", {style: {flex: '1', 'min-height': '0'}}, 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("input", {type: "hidden", name: "projekt_id", value: currentKontaktadresse.projekt_id}), m("input", {type: "hidden", name: "projekt_id", value: currentKontaktadresse.projekt_id}),
m("label", "Anrede", m(Input, {name: "anrede", value: currentKontaktadresse.anrede, type: "text"} as iInput)), m("label", "Anrede", m(Input, {name: "anrede", value: currentKontaktadresse.anrede, type: "text"} as iInput)),
m("label", "Name", m(Input, {name: "name", value: currentKontaktadresse.name, type: "text", required: true} as iInput)), m("label", "Name", m(Input, {name: "name", value: currentKontaktadresse.name, type: "text", required: true} as iInput)),
+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 {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "./widgets/breadcrumb"
import {iKunde, fetchKunden, fetchKundeById, createKundeREST, updateKundeREST} from "../models/kunde" import {iKunde, fetchKunden, fetchKundeById, createKundeREST, updateKundeREST} from "../models/kunde"
import {showServerError} from "./auth/authmessages" import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
export const kunde: m.ClosureComponent = (vnode) => { export const kunde: m.ClosureComponent = (vnode) => {
let currentKunde: iKunde = { let currentKunde: iKunde = {
@@ -17,6 +18,8 @@ export const kunde: m.ClosureComponent = (vnode) => {
} }
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc). // Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = [] let breadcrumbs: iBreadcrumb[] = []
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
const guard = new DirtyMaskHelper("/kundenliste")
return { return {
oninit: (vnode) => { oninit: (vnode) => {
@@ -24,6 +27,7 @@ export const kunde: m.ClosureComponent = (vnode) => {
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1 const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
// Breadcrumb-Kette aus Route-Param parsen. // Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc) breadcrumbs = parseBc(routeParams && routeParams.bc)
guard.registerGuard()
if (id !== -1) { if (id !== -1) {
// Kundendaten frisch vom Server holen, um zwischenzeitliche // Kundendaten frisch vom Server holen, um zwischenzeitliche
// Änderungen anderer Nutzer nicht zu überschreiben. // Änderungen anderer Nutzer nicht zu überschreiben.
@@ -32,6 +36,9 @@ export const kunde: m.ClosureComponent = (vnode) => {
}).catch((e) => showServerError(e)) }).catch((e) => showServerError(e))
} }
}, },
onremove: (vnode) => {
guard.cleanup()
},
view: (vnode) => { view: (vnode) => {
const submit = (form: HTMLFormElement): boolean => { const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form) const formData = new FormData(form)
@@ -44,30 +51,28 @@ export const kunde: m.ClosureComponent = (vnode) => {
land: (formData.get("land") as string) || "" land: (formData.get("land") as string) || ""
} }
if (currentKunde.id === -1) { 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 { } 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 return true
} }
const cancel = (e: Event) => {
m.route.set("/kundenliste")
}
return m("div.w3-container.w3-auto.form-view-container", return m("div.w3-container.w3-auto.form-view-container",
m("div", {style: {display: 'flex', 'align-items': 'center', 'justify-content': 'space-between'}}, m("div", {style: {display: 'flex', 'align-items': 'center', 'justify-content': 'space-between'}},
m("h1", currentKunde.id === -1 ? "Neuen Kunden anlegen" : "Kundenstammblatt"), m("h1", currentKunde.id === -1 ? "Neuen Kunden anlegen" : "Kundenstammblatt"),
currentKunde.id !== -1 currentKunde.id !== -1
? m("button.w3-button.w3-theme", ? 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") m("i.material-icons", "contact_page"), " Kontakte")
: null : null
), ),
m(BreadcrumbBar, { m(BreadcrumbBar, {
breadcrumbs: breadcrumbs, breadcrumbs: breadcrumbs,
current: currentKunde.id === -1 ? "Neuer Kunde" : "Kundenstammblatt" current: currentKunde.id === -1 ? "Neuer Kunde" : "Kundenstammblatt",
onnavigate: guardedNavigate
} as iBreadcrumbBar), } as iBreadcrumbBar),
m("div", {style: {flex: '1', 'min-height': '0'}}, 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", "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", "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)), m("label", "PLZ", m(Input, {name: "plz", value: currentKunde.plz, type: "text"} as iInput)),
+22 -17
View File
@@ -6,6 +6,7 @@ import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "
import {iProjekt, fetchProjekte, fetchProjektById, createProjektREST, updateProjektREST} from "../models/projekt" import {iProjekt, fetchProjekte, fetchProjektById, createProjektREST, updateProjektREST} from "../models/projekt"
import {iKunde, fetchKundeById} from "../models/kunde" import {iKunde, fetchKundeById} from "../models/kunde"
import {showServerError} from "./auth/authmessages" import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
export const projektliste: m.ClosureComponent = (vnode) => { export const projektliste: m.ClosureComponent = (vnode) => {
let treenodes: iTreenode[] = [] let treenodes: iTreenode[] = []
@@ -194,6 +195,16 @@ export const Projekt: m.ClosureComponent = (vnode) => {
} }
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc). // Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
let breadcrumbs: iBreadcrumb[] = [] let breadcrumbs: iBreadcrumb[] = []
// Route-Params für die Rückkehr zur Projektliste; als Closure an den
// DirtyMaskHelper übergeben.
const backParams = (): any => {
const listBc = serializeBc(breadcrumbs.slice(0, -1))
const params: any = {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name}
if (listBc) params.bc = listBc
return params
}
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
const guard = new DirtyMaskHelper("/projektliste", backParams)
return { return {
oninit: (vnode) => { oninit: (vnode) => {
@@ -201,6 +212,7 @@ export const Projekt: m.ClosureComponent = (vnode) => {
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1 const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
// Breadcrumb-Kette aus Route-Param parsen. // Breadcrumb-Kette aus Route-Param parsen.
breadcrumbs = parseBc(routeParams && routeParams.bc) breadcrumbs = parseBc(routeParams && routeParams.bc)
guard.registerGuard()
if (id !== -1) { if (id !== -1) {
// Projektdaten frisch vom Server holen, um zwischenzeitliche // Projektdaten frisch vom Server holen, um zwischenzeitliche
// Änderungen anderer Nutzer nicht zu überschreiben. // Änderungen anderer Nutzer nicht zu überschreiben.
@@ -214,6 +226,9 @@ export const Projekt: m.ClosureComponent = (vnode) => {
currentProjekt.kunden_name = routeParams && routeParams.kunden_name ? routeParams.kunden_name : "" currentProjekt.kunden_name = routeParams && routeParams.kunden_name ? routeParams.kunden_name : ""
} }
}, },
onremove: (vnode) => {
guard.cleanup()
},
view: (vnode) => { view: (vnode) => {
const submit = (form: HTMLFormElement): boolean => { const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form) const formData = new FormData(form)
@@ -226,24 +241,13 @@ export const Projekt: m.ClosureComponent = (vnode) => {
verantwortlicher: (formData.get("verantwortlicher") as string) || "", verantwortlicher: (formData.get("verantwortlicher") as string) || "",
startdatum: (formData.get("startdatum") as string) || "" startdatum: (formData.get("startdatum") as string) || ""
} }
// Bei Rückkehr zur Projektliste das bc ohne das letzte
// Glied (die Projektliste selbst) durchreichen.
const listBc = serializeBc(breadcrumbs.slice(0, -1))
const routeParams: any = {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name}
if (listBc) routeParams.bc = listBc
if (currentProjekt.id === -1) { if (currentProjekt.id === -1) {
createProjektREST(gespeichertesProjekt).then(() => m.route.set("/projektliste", routeParams)).catch((e) => showServerError(e)) createProjektREST(gespeichertesProjekt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} else { } else {
updateProjektREST(gespeichertesProjekt).then(() => m.route.set("/projektliste", routeParams)).catch((e) => showServerError(e)) updateProjektREST(gespeichertesProjekt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} }
return true return true
} }
const cancel = (e: Event) => {
const listBc = serializeBc(breadcrumbs.slice(0, -1))
const routeParams: any = {kunden_id: currentProjekt.kunden_id, kunden_name: currentProjekt.kunden_name}
if (listBc) routeParams.bc = listBc
m.route.set("/projektliste", routeParams)
}
return m("div.w3-container.w3-auto.form-view-container", return m("div.w3-container.w3-auto.form-view-container",
m("div", {style: {display: 'flex', 'align-items': 'center', 'justify-content': 'space-between'}}, m("div", {style: {display: 'flex', 'align-items': 'center', 'justify-content': 'space-between'}},
m("h1", currentProjekt.id === -1 m("h1", currentProjekt.id === -1
@@ -252,21 +256,22 @@ export const Projekt: m.ClosureComponent = (vnode) => {
currentProjekt.id !== -1 currentProjekt.id !== -1
? [ ? [
m("button.w3-button.w3-theme", m("button.w3-button.w3-theme",
{onclick: () => m.route.set("/kontaktadresseliste", {projekt_id: currentProjekt.id})}, {onclick: (e: MouseEvent) => guardedNavigate("/kontaktadresseliste", {projekt_id: currentProjekt.id}, e)},
m("i.material-icons", "contact_page"), " Kontaktadressen"), m("i.material-icons", "contact_page"), " Kontaktadressen"),
m("button.w3-button.w3-theme", m("button.w3-button.w3-theme",
{style: {'margin-left': '4px'}, {style: {'margin-left': '4px'},
onclick: () => m.route.set("/projektuserliste", {projekt_id: currentProjekt.id})}, onclick: (e: MouseEvent) => guardedNavigate("/projektuserliste", {projekt_id: currentProjekt.id}, e)},
m("i.material-icons", "group"), " Nutzerberechtigungen") m("i.material-icons", "group"), " Nutzerberechtigungen")
] ]
: null : null
), ),
m(BreadcrumbBar, { m(BreadcrumbBar, {
breadcrumbs: breadcrumbs, breadcrumbs: breadcrumbs,
current: currentProjekt.id === -1 ? "Neues Projekt" : "Projekt-Stammblatt" current: currentProjekt.id === -1 ? "Neues Projekt" : "Projekt-Stammblatt",
onnavigate: guardedNavigate
} as iBreadcrumbBar), } as iBreadcrumbBar),
m("div", {style: {flex: '1', 'min-height': '0'}}, 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("input", {type: "hidden", name: "kunden_id", value: currentProjekt.kunden_id}), m("input", {type: "hidden", name: "kunden_id", value: currentProjekt.kunden_id}),
m("label", "Projektname", m(Input, {name: "projektname", value: currentProjekt.projektname, type: "text", required: true} as iInput)), m("label", "Projektname", m(Input, {name: "projektname", value: currentProjekt.projektname, type: "text", required: true} as iInput)),
m("label", "Zweck", m(Input, {name: "zweck", value: currentProjekt.zweck, type: "text"} as iInput)), m("label", "Zweck", m(Input, {name: "zweck", value: currentProjekt.zweck, type: "text"} as iInput)),
+21 -11
View File
@@ -5,6 +5,7 @@ import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton}
import {ModalType, ModalView, iModalView} from "./widgets/Modal" import {ModalType, ModalView, iModalView} from "./widgets/Modal"
import {gridMountpoints} from "./mainlayout" import {gridMountpoints} from "./mainlayout"
import {showServerError} from "./auth/authmessages" import {showServerError} from "./auth/authmessages"
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
import {iTag, iTagWithChildren, fetchTags, fetchTagHierarchy, fetchTagById, createTagREST, updateTagREST, deleteTagREST} from "../models/tag" import {iTag, iTagWithChildren, fetchTags, fetchTagHierarchy, fetchTagById, createTagREST, updateTagREST, deleteTagREST} from "../models/tag"
const colorPalette: string[] = [ const colorPalette: string[] = [
@@ -78,18 +79,22 @@ export const Tag: m.ClosureComponent = () => {
is_parent: false, is_parent: false,
} }
const setColor = (color: string) => {
currentTag.color = color
}
// Liste aller Parent-Tags fuer die Eltern-Auswahl (nur Parent-Tags // Liste aller Parent-Tags fuer die Eltern-Auswahl (nur Parent-Tags
// koennen uebergeordnet sein) // koennen uebergeordnet sein)
let allParentTags: iTag[] = [] let allParentTags: iTag[] = []
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
const guard = new DirtyMaskHelper("/taglist")
const setColor = (color: string) => {
currentTag.color = color
guard.markDirty()
}
return { return {
oninit: (vnode) => { oninit: (vnode) => {
const routeParams = m.route.param() const routeParams = m.route.param()
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1 const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
guard.registerGuard()
if (id !== -1) { if (id !== -1) {
// Bestehenden Tag frisch vom Server laden // Bestehenden Tag frisch vom Server laden
fetchTagById(id).then((result) => { fetchTagById(id).then((result) => {
@@ -113,6 +118,9 @@ export const Tag: m.ClosureComponent = () => {
allParentTags = result.filter(t => t.id !== currentTag.id && t.is_parent) allParentTags = result.filter(t => t.id !== currentTag.id && t.is_parent)
}).catch((e) => showServerError(e)) }).catch((e) => showServerError(e))
}, },
onremove: (vnode) => {
guard.cleanup()
},
view: (vnode) => { view: (vnode) => {
const submit = (form: HTMLFormElement): boolean => { const submit = (form: HTMLFormElement): boolean => {
const formData = new FormData(form) const formData = new FormData(form)
@@ -125,19 +133,16 @@ export const Tag: m.ClosureComponent = () => {
is_parent: currentTag.is_parent, is_parent: currentTag.is_parent,
} }
if (currentTag.id === -1) { if (currentTag.id === -1) {
createTagREST(gespeicherterTag).then(() => m.route.set("/taglist")).catch((e) => showServerError(e)) createTagREST(gespeicherterTag).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} else { } else {
updateTagREST(gespeicherterTag).then(() => m.route.set("/taglist")).catch((e) => showServerError(e)) updateTagREST(gespeicherterTag).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
} }
return true return true
} }
const cancel = (e: Event) => {
m.route.set("/taglist")
}
return m("div.w3-container.w3-auto.form-view-container", return m("div.w3-container.w3-auto.form-view-container",
m("h1", currentTag.id === -1 ? "Neuen Tag erstellen" : "Tag bearbeiten"), m("h1", currentTag.id === -1 ? "Neuen Tag erstellen" : "Tag bearbeiten"),
m("div", {style: {flex: '1', 'min-height': '0'}}, 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", "Tag-Name", m(Input, {name: "name", value: currentTag.name, type: "text", required: true, oninput: (e: Event) => { currentTag.name = (e.target as HTMLInputElement).value }} as iInput)), m("label", "Tag-Name", m(Input, {name: "name", value: currentTag.name, type: "text", required: true, oninput: (e: Event) => { currentTag.name = (e.target as HTMLInputElement).value }} as iInput)),
m("label", "Beschreibung", m(Input, {name: "description", value: currentTag.description, type: "text", oninput: (e: Event) => { currentTag.description = (e.target as HTMLInputElement).value }} as iInput)), m("label", "Beschreibung", m(Input, {name: "description", value: currentTag.description, type: "text", oninput: (e: Event) => { currentTag.description = (e.target as HTMLInputElement).value }} as iInput)),
@@ -151,6 +156,7 @@ export const Tag: m.ClosureComponent = () => {
style: {'margin-right': '8px'}, style: {'margin-right': '8px'},
onclick: (e: Event) => { onclick: (e: Event) => {
currentTag.is_parent = (e.target as HTMLInputElement).checked currentTag.is_parent = (e.target as HTMLInputElement).checked
guard.markDirty()
} }
}), }),
"Nur zur Gruppierung, kann nicht zugeordnet werden" "Nur zur Gruppierung, kann nicht zugeordnet werden"
@@ -159,7 +165,11 @@ export const Tag: m.ClosureComponent = () => {
// Eltern-Tag-Auswahl // Eltern-Tag-Auswahl
m("label", "Übergeordneter Tag (optional)"), m("label", "Übergeordneter Tag (optional)"),
m("select.w3-select", {name: "parent_id", value: currentTag.parent_id || ""}, m("select.w3-select", {
name: "parent_id",
value: currentTag.parent_id || "",
onchange: () => { guard.markDirty() }
},
m("option", {value: ""}, "-- Kein übergeordneter Tag --"), m("option", {value: ""}, "-- Kein übergeordneter Tag --"),
allParentTags.map(tag => allParentTags.map(tag =>
m("option", {value: tag.id, selected: currentTag.parent_id === tag.id}, tag.name) m("option", {value: tag.id, selected: currentTag.parent_id === tag.id}, tag.name)
+15 -3
View File
@@ -10,10 +10,14 @@ export interface iBreadcrumb {
/** Attribute der BreadcrumbBar: die Vorgänger-Kette (ancestors) und das /** Attribute der BreadcrumbBar: die Vorgänger-Kette (ancestors) und das
* Label der aktuellen Ansicht (wird als letztes, nicht klickbares Glied * Label der aktuellen Ansicht (wird als letztes, nicht klickbares Glied
* angezeigt). Ist breadcrumbs leer, wird keine Leiste gerendert. */ * angezeigt). Ist breadcrumbs leer, wird keine Leiste gerendert.
* onnavigate (optional): Navigations-Callback, der statt m.route.set
* verwendet wird — typischerweise guardedNavigate aus dirtyguard.ts,
* so dass bei ungespeicherten Änderungen das Bestätigungs-Modal erscheint. */
export interface iBreadcrumbBar { export interface iBreadcrumbBar {
breadcrumbs: iBreadcrumb[] breadcrumbs: iBreadcrumb[]
current: string current: string
onnavigate?: (route: string, params: any, e?: Event) => void
} }
/** Parst den Route-Param `bc` (JSON-String) in ein Breadcrumb-Array. /** Parst den Route-Param `bc` (JSON-String) in ein Breadcrumb-Array.
@@ -56,12 +60,20 @@ export const BreadcrumbBar: m.ClosureComponent<iBreadcrumbBar> = () => {
'white-space': 'nowrap' 'white-space': 'nowrap'
}, },
title: "Zurück zu " + crumb.label, title: "Zurück zu " + crumb.label,
onclick: () => { onclick: (e: MouseEvent) => {
const subChain = crumbs.slice(0, i) const subChain = crumbs.slice(0, i)
const params: any = {...crumb.params} const params: any = {...crumb.params}
const bc = serializeBc(subChain) const bc = serializeBc(subChain)
if (bc) params.bc = bc if (bc) params.bc = bc
m.route.set(crumb.route, params) // Navigation über den Guard, so dass bei dirty-Maske
// das Bestätigungs-Modal erscheint. Event wird
// durchgereicht, damit guardedNavigate den auto-redraw
// unterbinden kann.
if (vnode.attrs.onnavigate) {
vnode.attrs.onnavigate(crumb.route, params, e)
} else {
m.route.set(crumb.route, params)
}
} }
}, crumb.label)) }, crumb.label))
}) })
+267
View File
@@ -0,0 +1,267 @@
import m from "mithril"
import { gridMountpoints } from "../mainlayout"
import type { iForm } from "./form"
/** Registrierungsschnittstelle für eine Bearbeitungsmaske. Die Maske
* registriert in oninit einen Guard und entfernt ihn in onremove wieder.
* So weiß der zentrale Navigations-Guard, ob die aktuelle Ansicht ungespeicherte
* Änderungen enthält und wie das Speichern auszulösen ist. */
export interface DirtyGuard {
/** Liefert true, wenn die Maske ungespeicherte Änderungen enthält. */
isDirty: () => boolean
/** Triggert das Speichern des Formulars (form.requestSubmit()). Bei
* Angabe einer Ziel-Route navigiert die Maske nach erfolgreichem Speichern
* dorthin statt zum Standard-Ziel. Die Maske muss dafür die Ziel-Route
* zwischenspeichern und im Erfolgs-.then der REST-Aufrufs auswerten. */
save: (targetRoute?: string, targetParams?: any) => void
}
// Aktiv registrierter Guard (max. einer, da nur eine Maske sichtbar ist).
let guard: DirtyGuard | null = null
/** Wird von einer Bearbeitungsmaske in oninit aufgerufen, um den Guard
* zu aktivieren. */
export function registerDirtyGuard(opts: DirtyGuard): void {
guard = opts
}
/** Wird von einer Bearbeitungsmaske in onremove aufgerufen, um den Guard
* zu deaktivieren. Ein evtl. noch offenes Bestätigungs-Modal wird geschlossen. */
export function clearDirtyGuard(): void {
guard = null
m.mount(gridMountpoints.modalhook, null)
}
/** Liefert true, wenn aktuell ein Guard registriert ist. */
export function hasDirtyGuard(): boolean {
return guard != null
}
/** Zentraler Einstieg für alle App-internen Navigationen. Ist die aktive
* Maske dirty, wird das 3-Button-Bestätigungs-Modal geöffnet; andernfalls
* wird direkt navigiert. Alle m.route.set-Aufrufe, die aus einer
* Bearbeitungsmaske heraus führen, sollten diese Funktion verwenden.
*
* Der optionale Event-Parameter e wird bei dirty auf e.redraw = false
* gesetzt, damit Mithril keinen auto-redraw der Maske ausführt und die
* eingegebenen Formularwerte im DOM erhalten bleiben — das Input-Widget
* hat kein Live-Binding, ein Redraw würde die Felder mit den (noch
* unveränderten) Model-Werten überschreiben. */
export function guardedNavigate(route: string, params?: any, e?: Event): void {
if (guard && guard.isDirty()) {
if (e) e['redraw'] = false
showDirtyConfirmModal(route, params)
return
}
m.route.set(route, params)
}
/** Hilfsfunktion für Masken: triggert das Formular-Submit. Die Maske übergibt
* ihre Form-Referenz (über onformcreate) und einen Callback, der die
* Ziel-Route für die Navigation nach erfolgreichem Speichern zwischenspeichert. */
export function submitForm(form: HTMLFormElement | null): void {
if (!form) return
if (typeof form.requestSubmit === "function") {
form.requestSubmit()
} else {
// Fallback für ältere Browser.
form.dispatchEvent(new Event("submit", {cancelable: true, bubbles: true}))
}
}
// ---- 3-Button-Bestätigungs-Modal ----------------------------------------
interface iDirtyConfirmModal {
route: string
params: any
}
const DirtyConfirmModal: m.Component<iDirtyConfirmModal> = {
view: (vnode) => {
const close = () => { m.mount(gridMountpoints.modalhook, null) }
const onSave = (e: MouseEvent) => {
// Auto-Redraw unterbinden, damit die darunter liegende Maske
// nicht neu gerendert wird und die eingegebenen Formularwerte
// im DOM erhalten bleiben — das Input-Widget hat kein
// Live-Binding, ein Redraw würde die Felder mit den (noch
// unveränderten) Model-Werten überschreiben. Das Modal wird
// durch close() direkt per m.mount entfernt.
e['redraw'] = false
close()
if (guard) {
guard.save(vnode.attrs.route, vnode.attrs.params)
}
}
const onDiscard = (e: MouseEvent) => {
// Redraw unterbinden; m.route.set löst den Komponentenwechsel
// ohnehin aus.
e['redraw'] = false
close()
m.route.set(vnode.attrs.route, vnode.attrs.params)
}
const onCancel = (e: MouseEvent) => {
// Redraw unterbinden — die Maske bleibt unverändert, die
// Eingaben des Nutzers dürfen nicht überschrieben werden.
e['redraw'] = false
close()
}
return m("div.w3-modal", {style: {display: 'block'}},
m("div.w3-modal-content.w3-card-4", {style: {width: '440px', top: '20%'}},
m("header.w3-container.w3-theme",
m("h3", "Ungespeicherte Änderungen")),
m("div.w3-container.w3-theme-l1",
m("p", "Die Maske enthält ungespeicherte Änderungen."),
m("p", "Wie möchten Sie verfahren?")),
m("footer.w3-container.w3-theme-l1.w3-padding-16",
m("div.w3-bar",
m("button.w3-button.w3-theme.w3-right",
{style: {'margin-left': '4px'}, onclick: onDiscard},
"Verwerfen"),
m("button.w3-button.w3-theme.w3-right",
{style: {'margin-left': '4px'}, onclick: onSave},
"Speichern"),
m("button.w3-button.w3-theme-l2.w3-right",
{style: {'margin-left': '4px'}, onclick: onCancel},
"Abbrechen")
)
)
)
)
}
}
function showDirtyConfirmModal(route: string, params: any): void {
m.mount(gridMountpoints.modalhook, {
view: () => { return m(DirtyConfirmModal, {route: route, params: params} as iDirtyConfirmModal) }
})
}
// ---- DirtyMaskHelper: kapselt den isDirty/Guard-Boilerplate ---------------
/** Hilfsklasse für Bearbeitungsmasken. Kapselt den gesamten isDirty/Guard-
* Boilerplate, der sonst in jeder Maske identisch wiederkehrt: Dirty-State,
* Form-Referenz, pendingTarget (Ziel-Route nach Speichern aus dem Modal),
* Guard-Registrierung/-Cleanup, navigateAfterSave, cancel und die
* Form-Attribute.
*
* Maskenspezifisch bleiben: die DTO-Erzeugung und create/update-Aufrufe im
* submit (die Maske ruft danach guard.navigateAfterSave() bzw.
* guard.markDirty() auf), die backParams()-Closure, In-Masken-Buttons und
* der oninit-Daten-Fetch.
*
* Verwendung in einer ClosureComponent:
* ```ts
* export const maske: m.ClosureComponent = (vnode) => {
* const guard = new DirtyMaskHelper("/zielroute", () => ({...backParams}))
* return {
* oninit: (v) => { guard.registerGuard(); // fetch ...
* },
* onremove: (v) => { guard.cleanup() },
* view: (v) => {
* const submit = (form) => {
* // DTO bauen, createX, guard.navigateAfterSave, guard.markDirty
* return true
* }
* return m(Form, guard.formAttrs(submit) as iForm, [...])
* }
* }
* }
* ``` */
export class DirtyMaskHelper {
private isDirty = false
private formRef: HTMLFormElement | null = null
private pendingTarget: {route: string, params?: any} | null = null
private readonly defaultRoute: string
private readonly backParams: () => any
/** @param defaultRoute Standard-Ziel nach Save/Cancel (ohne pendingTarget).
* @param backParams liefert die Route-Params für das Standard-Ziel;
* optional, da einige Masken (z.B. kunde, tag) keine Params benötigen. */
constructor(defaultRoute: string, backParams?: () => any) {
this.defaultRoute = defaultRoute
this.backParams = backParams || (() => ({}))
}
/** In oninit aufrufen. Weist diese DirtyMaskHelper-Instanz der
* zentralen statischen Modul-Variablen `guard` (siehe
* registerDirtyGuard) zu, so dass alle Konsumenten — guardedNavigate,
* das 3-Button-Modal, hasDirtyGuard — über diesen einen Slot auf den
* Guard zugreifen, der zur gerade aktiven Maske "gehört". Die
* übergebenen Closures binden this lexikalisch an diese Instanz, so
* dass isDirty() und save() live auf den Instanz-State zugreifen.
*
* Registrierung bewusst nicht im Konstruktor: der Konstruktor läuft
* zum Erzeugungszeitpunkt der ClosureComponent, nicht zum
* Lebenszyklus-Start (oninit). Würde er registrieren, könnte eine
* neue Instanz den zentralen Slot überschreiben, bevor onremove der
* alten Maske aufgeräumt hat — die alte würde dann den Guard der
* neuen Instanz löschen. Mit registerGuard in oninit gilt:
* erst räumt die alte Maske auf (cleanup in onremove), dann
* registriert die neue. */
registerGuard(): void {
registerDirtyGuard({isDirty: () => this.isDirty, save: this.save})
}
/** In onremove aufrufen — deregistriert den Guard und schließt ein
* evtl. offenes Bestätigungs-Modal. */
cleanup(): void {
clearDirtyGuard()
}
/** Als onDirtyChange an die Form übergeben (wird von der Form bei
* Eingabe-Änderungen aufgerufen). */
onDirtyChange = (dirty: boolean) => { this.isDirty = dirty }
/** Bei Custom-Widget-Änderungen aufrufen (Select onchanged etc.), die
* kein change/input-Event auf der Form auslösen. */
markDirty = () => { this.isDirty = true }
/** Als onformcreate an die Form übergeben (speichert die Form-Referenz
* für das programmtische Submit aus dem Bestätigungs-Modal). */
setForm = (f: HTMLFormElement) => { this.formRef = f }
/** Nach erfolgreichem Speichern aufrufen (im .then der REST-Calls).
* Gibt es ein pendingTarget (Speichern aus dem Bestätigungs-Modal),
* wird dorthin navigiert; sonst zum Standard-Ziel. */
navigateAfterSave = () => {
if (this.pendingTarget) {
const t = this.pendingTarget
this.pendingTarget = null
m.route.set(t.route, t.params)
} else {
m.route.set(this.defaultRoute, this.backParams())
}
}
/** Als oncancel an die Form übergeben. Navigiert über den Guard,
* so dass bei dirty die Bestätigungs-Modal erscheint. Das Event
* wird durchgereicht, damit guardedNavigate den auto-redraw
* unterbinden kann. */
cancel = (e?: Event) => {
guardedNavigate(this.defaultRoute, this.backParams(), e)
}
/** Vom Navigations-Guard aufgerufen, wenn der Nutzer im Bestätigungs-
* Modal "Speichern" wählt. Speichert die Ziel-Route und triggert das
* Form-Submit; die Maske navigiert nach erfolgreichem Speichern über
* navigateAfterSave() dorthin. */
private save = (targetRoute?: string, targetParams?: any) => {
this.pendingTarget = targetRoute ? {route: targetRoute, params: targetParams} : null
submitForm(this.formRef)
}
/** Liefert das Form-Attribut-Objekt für m(Form, ...). Der onsubmit-
* Callback ist maskenspezifisch (DTO-Erzeugung + create/update) und
* muss von der Maske übergeben werden; alle anderen Attribute
* (showButtons, oncancel, onDirtyChange, onformcreate) werden von
* der Hilfsklasse gesetzt. */
formAttrs(onsubmit: (form: HTMLFormElement) => boolean): Partial<iForm> {
return {
showbuttons: true,
onsubmit: onsubmit,
oncancel: this.cancel,
onDirtyChange: this.onDirtyChange,
onformcreate: this.setForm
}
}
}
+32 -9
View File
@@ -12,6 +12,13 @@ export interface iForm {
stickyButtons?: boolean stickyButtons?: boolean
/** Die Funktion gibt das HTMLFormElement zurück, welches in der Form-Componente angelegt wird */ /** Die Funktion gibt das HTMLFormElement zurück, welches in der Form-Componente angelegt wird */
onformcreate?: (form:HTMLFormElement) => void onformcreate?: (form:HTMLFormElement) => void
/** Callback bei Änderung des Dirty-State. Wird mit true aufgerufen,
* sobald der Nutzer Eingaben vornimmt, und mit false, nachdem das
* Formular erfolgreich gespeichert oder zurückgesetzt wurde. Die
* umgebende Maske nutzt dies, um ihren eigenen isDirty-State mit dem
* der Form synchron zu halten und so den Navigations-Guard
* (dirtyguard.ts) zu speisen. */
onDirtyChange?: (dirty: boolean) => void
} }
export const Form:m.ClosureComponent<iForm> = (vnode) => { export const Form:m.ClosureComponent<iForm> = (vnode) => {
@@ -29,6 +36,9 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
} }
function onchanged (e:Event) { function onchanged (e:Event) {
isDirty = true isDirty = true
if (vnode.attrs.onDirtyChange != undefined) {
vnode.attrs.onDirtyChange(true)
}
if (vnode.attrs.onchanged != undefined) { if (vnode.attrs.onchanged != undefined) {
vnode.attrs.onchanged(e) vnode.attrs.onchanged(e)
} }
@@ -37,6 +47,9 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
e.preventDefault() e.preventDefault()
if (vnode.attrs.onsubmit(form) == true) { if (vnode.attrs.onsubmit(form) == true) {
isDirty = false isDirty = false
if (vnode.attrs.onDirtyChange != undefined) {
vnode.attrs.onDirtyChange(false)
}
} }
} }
function registerFormElement (f:HTMLFormElement) { function registerFormElement (f:HTMLFormElement) {
@@ -46,22 +59,24 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
} }
function cancel(e:Event) { function cancel(e:Event) {
e.preventDefault() e.preventDefault()
if (canCloseForm() == true) { // Der Dirty-Check beim Abbrechen erfolgt jetzt durch den
isDirty = false // Navigations-Guard der Maske (dirtyguard.ts), da oncancel die
if (vnode.attrs.oncancel != undefined) { // Navigation auslöst. Die Form selbst fragt hier nicht mehr nach,
vnode.attrs.oncancel(e) // um eine doppelte Bestätigung zu vermeiden. isDirty wird nicht
} // zurückgesetzt, damit der Guard den aktuellen State sieht.
else { if (vnode.attrs.oncancel != undefined) {
history.back() vnode.attrs.oncancel(e)
}
} }
else { else {
e['redraw'] = false history.back()
} }
} }
function reset(e:Event) { function reset(e:Event) {
if (canCloseForm("reset")) { if (canCloseForm("reset")) {
isDirty = false isDirty = false
if (vnode.attrs.onDirtyChange != undefined) {
vnode.attrs.onDirtyChange(false)
}
// Hier passiert nichts. // Hier passiert nichts.
// Da mithril nach jedem User-Event (wie z.B. einem 'Click') ein redraw() ausführt, wird hier die Form // Da mithril nach jedem User-Event (wie z.B. einem 'Click') ein redraw() ausführt, wird hier die Form
// neu gezeichnet und zwar mit den ursprünglichen Daten aus den zu grundeliegenden Datenmodel, das ja // neu gezeichnet und zwar mit den ursprünglichen Daten aus den zu grundeliegenden Datenmodel, das ja
@@ -109,6 +124,10 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
for (let index = 0; index < elements.length; index++) { for (let index = 0; index < elements.length; index++) {
const element = elements[index]; const element = elements[index];
element.addEventListener("change", onchanged) element.addEventListener("change", onchanged)
// 'input' feuert schon während des Tippens (vor
// Blur), so dass die Maske dirty wird, auch wenn
// der Nutzer sofort eine Breadcrumb klickt.
element.addEventListener("input", onchanged)
} }
registerFormElement(form) registerFormElement(form)
} }
@@ -131,6 +150,10 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
for (let index = 0; index < elements.length; index++) { for (let index = 0; index < elements.length; index++) {
const element = elements[index]; const element = elements[index];
element.addEventListener("change", onchanged) element.addEventListener("change", onchanged)
// 'input' feuert schon während des Tippens (vor
// Blur), so dass die Maske dirty wird, auch wenn
// der Nutzer sofort eine Breadcrumb klickt.
element.addEventListener("input", onchanged)
} }
registerFormElement(form) registerFormElement(form)
} }
+20 -11
View File
@@ -11,6 +11,7 @@ import { Call_Log, call_log_liste } from "./Components/call_log"
import { Kontakt, kontaktliste } from "./Components/kontakt" import { Kontakt, kontaktliste } from "./Components/kontakt"
import { Kontaktadresse, kontaktadresseliste } from "./Components/kontaktadresse" import { Kontaktadresse, kontaktadresseliste } from "./Components/kontaktadresse"
import { Tag, TagList } from "./Components/tags" import { Tag, TagList } from "./Components/tags"
import { guardedNavigate } from "./Components/widgets/dirtyguard"
function healthcheck () { function healthcheck () {
@@ -118,21 +119,29 @@ const knopf: m.Component = {
} }
const menu: m.ClosureComponent = () => { const menu: m.ClosureComponent = () => {
let showProjektBerechtigungen = false let showProjektBerechtigungen = false
// Menü-Eintrag, der die Navigation über den Dirty-Guard leitet, so dass
// aus einer Bearbeitungsmaske heraus ungespeicherte Änderungen abgefragt
// werden, bevor die Ansicht wechselt.
const menuItem = (route: string, label: string) => {
return m("a.w3-button.w3-bar-item",
{href: "#", onclick: (e: Event) => {e.preventDefault(); guardedNavigate(route, undefined, e)}},
label)
}
return { return {
oninit: (vnode) => { oninit: (vnode) => {
showProjektBerechtigungen = false showProjektBerechtigungen = false
}, },
view: (vnode) => { view: (vnode) => {
return m(menubar, [ return m(menubar, [
m("a.w3-button.w3-bar-item", {href: "#!/kundenliste"}, "Kunden"), menuItem("/kundenliste", "Kunden"),
m("a.w3-button.w3-bar-item", {href: "#!/projektliste"}, "Projekte"), menuItem("/projektliste", "Projekte"),
m("a.w3-button.w3-bar-item", {href: "#!/calllogliste"}, "Gesprächsnotizen"), menuItem("/calllogliste", "Gesprächsnotizen"),
m("div.w3-dropdown-hover", m("div.w3-dropdown-hover",
m("button.w3-button", "Optionen"), m("button.w3-button", "Optionen"),
m("div.w3-dropdown-content.w3-bar-block.w3-card-4", m("div.w3-dropdown-content.w3-bar-block.w3-card-4",
m("a.w3-button.w3-bar-item", {href: "#!/taglist"}, "Tags"), menuItem("/taglist", "Tags"),
m("a.w3-button.w3-bar-item", {href: "#!/default"}, "Datenexport"), menuItem("/default", "Datenexport"),
m("a.w3-button.w3-bar-item", {href: "#!/default"}, "Datenimport"), menuItem("/default", "Datenimport"),
// Projekt-Berechtigungen nur für SysAdmin/AppAdmin sichtbar. // Projekt-Berechtigungen nur für SysAdmin/AppAdmin sichtbar.
// Als Toggle-Button realisiert, da verschachtelte w3-dropdown-hover // Als Toggle-Button realisiert, da verschachtelte w3-dropdown-hover
// innerhalb eines offenen Dropdowns nicht zuverlässig funktionieren. // innerhalb eines offenen Dropdowns nicht zuverlässig funktionieren.
@@ -147,10 +156,10 @@ const menu: m.ClosureComponent = () => {
showProjektBerechtigungen showProjektBerechtigungen
? m("div.w3-bar-block", ? m("div.w3-bar-block",
m("a.w3-button.w3-bar-item", m("a.w3-button.w3-bar-item",
{href: "#!/projektuserliste", style: {'padding-left': '32px'}}, {href: "#", style: {'padding-left': '32px'}, onclick: (e: Event) => {e.preventDefault(); guardedNavigate("/projektuserliste", undefined, e)}},
"Projekt-Zugriffe"), "Projekt-Zugriffe"),
m("a.w3-button.w3-bar-item", m("a.w3-button.w3-bar-item",
{href: "#!/userprojektliste", style: {'padding-left': '32px'}}, {href: "#", style: {'padding-left': '32px'}, onclick: (e: Event) => {e.preventDefault(); guardedNavigate("/userprojektliste", undefined, e)}},
"Nutzer-Zugriffe") "Nutzer-Zugriffe")
) )
: null : null
@@ -162,9 +171,9 @@ const menu: m.ClosureComponent = () => {
m("div.w3-dropdown-hover.w3-right", m("div.w3-dropdown-hover.w3-right",
m("button.w3-button", "System"), m("button.w3-button", "System"),
m("div.w3-dropdown-content.w3-bar-block.w3-card-4", m("div.w3-dropdown-content.w3-bar-block.w3-card-4",
m("a.w3-button.w3-bar-item", {href: "#!/login"}, "Login"), menuItem("/login", "Login"),
m("a.w3-button.w3-bar-item", {href: "#!/changepassword"}, "Kennwort ändern"), menuItem("/changepassword", "Kennwort ändern"),
m("a.w3-button.w3-bar-item", {href: "#!/usermanagement"}, "Verwalten"), menuItem("/usermanagement", "Verwalten"),
) )
) )
]) ])