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:
1 parent
fd30049a32
commit
a67921ec74
10 files changed
+458
-116
No files matched your search
@@ -10,6 +10,7 @@ import {iProjekt, fetchProjekte, fetchProjektById} from "../models/projekt"
|
||||
import {iKontaktadresse, fetchKontaktadressen} from "../models/kontaktadresse"
|
||||
import {iTag, fetchTags} from "../models/tag"
|
||||
import {showServerError} from "./auth/authmessages"
|
||||
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
|
||||
|
||||
// Aktualisierungsintervall für die Dauer-Anzeige in Millisekunden.
|
||||
const DAUER_UPDATE_INTERVALL = 1000
|
||||
@@ -241,6 +242,26 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
||||
let returnProjektName: string = ""
|
||||
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
|
||||
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.
|
||||
@@ -264,6 +285,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
||||
}
|
||||
// Breadcrumb-Kette aus Route-Param parsen.
|
||||
breadcrumbs = parseBc(routeParams && routeParams.bc)
|
||||
guard.registerGuard()
|
||||
if (id !== -1) {
|
||||
// Gesprächsnotiz frisch vom Server holen.
|
||||
fetchCallLogById(id).then((result) => {
|
||||
@@ -307,6 +329,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
||||
},
|
||||
onremove: (vnode) => {
|
||||
if (timerId != null) { window.clearInterval(timerId) }
|
||||
guard.cleanup()
|
||||
},
|
||||
view: (vnode) => {
|
||||
// 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 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 formData = new FormData(form)
|
||||
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))
|
||||
}
|
||||
if (currentLog.id === -1) {
|
||||
createCallLogREST(gespeichertesLog).then(() => goBack()).catch((e) => showServerError(e))
|
||||
createCallLogREST(gespeichertesLog).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||
} else {
|
||||
updateCallLogREST(gespeichertesLog).then(() => goBack()).catch((e) => showServerError(e))
|
||||
updateCallLogREST(gespeichertesLog).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||
}
|
||||
return true
|
||||
}
|
||||
const cancel = (e: Event) => {
|
||||
goBack()
|
||||
}
|
||||
return m("div.w3-container.w3-auto.form-view-container",
|
||||
m("h1", currentLog.id === -1 ? "Neue Gesprächsnotiz anlegen" : "Gesprächsnotiz"),
|
||||
m(BreadcrumbBar, {
|
||||
breadcrumbs: breadcrumbs,
|
||||
current: currentLog.id === -1 ? "Neue Gesprächsnotiz" : "Gesprächsnotiz"
|
||||
current: currentLog.id === -1 ? "Neue Gesprächsnotiz" : "Gesprächsnotiz",
|
||||
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("input", {type: "hidden", name: "projekt_id", value: currentLog.projekt_id}),
|
||||
// Kunde und Projekt als Read-Only-Text, da voreingestellt.
|
||||
m("label", "Kunde",
|
||||
@@ -449,6 +447,7 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
||||
if (ka) {
|
||||
currentLog.name_ansprechpartner = ka.name
|
||||
currentLog.telefon = ka.telefon
|
||||
guard.markDirty()
|
||||
}
|
||||
}
|
||||
} as iSelect)),
|
||||
@@ -463,7 +462,8 @@ export const Call_Log: m.ClosureComponent = (vnode) => {
|
||||
colors: tagColors,
|
||||
placeholder: "kein Tag",
|
||||
modalTitle: "Tags auswählen",
|
||||
editTooltip: "Tags bearbeiten"
|
||||
editTooltip: "Tags bearbeiten",
|
||||
onchanged: () => { guard.markDirty() }
|
||||
} as iMultiSelect)),
|
||||
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")
|
||||
|
||||
@@ -6,6 +6,7 @@ import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "
|
||||
import {iKontakt, fetchKontakte, fetchKontaktById, createKontaktREST, updateKontaktREST} from "../models/kontakt"
|
||||
import {iKunde, fetchKundeById} from "../models/kunde"
|
||||
import {showServerError} from "./auth/authmessages"
|
||||
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
|
||||
|
||||
export const kontaktliste: m.ClosureComponent = (vnode) => {
|
||||
let treenodes: iTreenode[] = []
|
||||
@@ -150,6 +151,17 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
|
||||
}
|
||||
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
|
||||
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 {
|
||||
oninit: (vnode) => {
|
||||
@@ -157,6 +169,7 @@ export const Kontakt: 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) {
|
||||
// Kontaktdaten frisch vom Server holen, um zwischenzeitliche
|
||||
// Ä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 : ""
|
||||
}
|
||||
},
|
||||
onremove: (vnode) => {
|
||||
guard.cleanup()
|
||||
},
|
||||
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 formData = new FormData(form)
|
||||
const gespeicherterKontakt: iKontakt = {
|
||||
@@ -193,25 +201,23 @@ export const Kontakt: m.ClosureComponent = (vnode) => {
|
||||
notiz: (formData.get("notiz") as string) || ""
|
||||
}
|
||||
if (currentKontakt.id === -1) {
|
||||
createKontaktREST(gespeicherterKontakt).then(() => goBack()).catch((e) => showServerError(e))
|
||||
createKontaktREST(gespeicherterKontakt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||
} else {
|
||||
updateKontaktREST(gespeicherterKontakt).then(() => goBack()).catch((e) => showServerError(e))
|
||||
updateKontaktREST(gespeicherterKontakt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||
}
|
||||
return true
|
||||
}
|
||||
const cancel = (e: Event) => {
|
||||
goBack()
|
||||
}
|
||||
return m("div.w3-container.w3-auto.form-view-container",
|
||||
m("h1", currentKontakt.id === -1
|
||||
? "Neuer Kontakt für " + currentKontakt.kunden_name
|
||||
: "Kontakt-Stammblatt"),
|
||||
m(BreadcrumbBar, {
|
||||
breadcrumbs: breadcrumbs,
|
||||
current: currentKontakt.id === -1 ? "Neuer Kontakt" : "Kontakt-Stammblatt"
|
||||
current: currentKontakt.id === -1 ? "Neuer Kontakt" : "Kontakt-Stammblatt",
|
||||
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("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", "Name", m(Input, {name: "name", value: currentKontakt.name, type: "text", required: true} as iInput)),
|
||||
|
||||
@@ -6,6 +6,7 @@ import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "
|
||||
import {iKontaktadresse, fetchKontaktadressen, fetchKontaktadresseById, createKontaktadresseREST, updateKontaktadresseREST} from "../models/kontaktadresse"
|
||||
import {iProjekt, fetchProjektById} from "../models/projekt"
|
||||
import {showServerError} from "./auth/authmessages"
|
||||
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
|
||||
|
||||
export const kontaktadresseliste: m.ClosureComponent = (vnode) => {
|
||||
let treenodes: iTreenode[] = []
|
||||
@@ -196,6 +197,16 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
|
||||
}
|
||||
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
|
||||
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 {
|
||||
oninit: (vnode) => {
|
||||
@@ -203,6 +214,7 @@ export const Kontaktadresse: 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) {
|
||||
// Kontaktadresse frisch vom Server holen.
|
||||
fetchKontaktadresseById(id).then((result) => {
|
||||
@@ -214,15 +226,10 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
|
||||
currentKontaktadresse.projekt_name = routeParams && routeParams.projekt_name ? routeParams.projekt_name : ""
|
||||
}
|
||||
},
|
||||
onremove: (vnode) => {
|
||||
guard.cleanup()
|
||||
},
|
||||
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 formData = new FormData(form)
|
||||
const gespeicherteKontaktadresse: iKontaktadresse = {
|
||||
@@ -242,15 +249,12 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
|
||||
notiz: (formData.get("notiz") as string) || ""
|
||||
}
|
||||
if (currentKontaktadresse.id === -1) {
|
||||
createKontaktadresseREST(gespeicherteKontaktadresse).then(() => goBack()).catch((e) => showServerError(e))
|
||||
createKontaktadresseREST(gespeicherteKontaktadresse).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||
} else {
|
||||
updateKontaktadresseREST(gespeicherteKontaktadresse).then(() => goBack()).catch((e) => showServerError(e))
|
||||
updateKontaktadresseREST(gespeicherteKontaktadresse).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||
}
|
||||
return true
|
||||
}
|
||||
const cancel = (e: Event) => {
|
||||
goBack()
|
||||
}
|
||||
return m("div.w3-container.w3-auto.form-view-container",
|
||||
m("h1", currentKontaktadresse.id === -1
|
||||
? "Neue Kontaktadresse für " + currentKontaktadresse.projekt_name
|
||||
@@ -259,10 +263,11 @@ export const Kontaktadresse: m.ClosureComponent = (vnode) => {
|
||||
breadcrumbs: breadcrumbs,
|
||||
current: currentKontaktadresse.id === -1
|
||||
? "Neue Kontaktadresse"
|
||||
: "Kontaktadresse-Stammblatt"
|
||||
: "Kontaktadresse-Stammblatt",
|
||||
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("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", "Name", m(Input, {name: "name", value: currentKontaktadresse.name, type: "text", required: true} as iInput)),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -6,6 +6,7 @@ import {BreadcrumbBar, iBreadcrumb, iBreadcrumbBar, parseBc, serializeBc} from "
|
||||
import {iProjekt, fetchProjekte, fetchProjektById, createProjektREST, updateProjektREST} from "../models/projekt"
|
||||
import {iKunde, fetchKundeById} from "../models/kunde"
|
||||
import {showServerError} from "./auth/authmessages"
|
||||
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
|
||||
|
||||
export const projektliste: m.ClosureComponent = (vnode) => {
|
||||
let treenodes: iTreenode[] = []
|
||||
@@ -194,6 +195,16 @@ export const Projekt: m.ClosureComponent = (vnode) => {
|
||||
}
|
||||
// Breadcrumb-Kette der Vorgänger-Ansichten (aus Route-Param bc).
|
||||
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 {
|
||||
oninit: (vnode) => {
|
||||
@@ -201,6 +212,7 @@ export const Projekt: 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) {
|
||||
// Projektdaten frisch vom Server holen, um zwischenzeitliche
|
||||
// Ä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 : ""
|
||||
}
|
||||
},
|
||||
onremove: (vnode) => {
|
||||
guard.cleanup()
|
||||
},
|
||||
view: (vnode) => {
|
||||
const submit = (form: HTMLFormElement): boolean => {
|
||||
const formData = new FormData(form)
|
||||
@@ -226,24 +241,13 @@ export const Projekt: m.ClosureComponent = (vnode) => {
|
||||
verantwortlicher: (formData.get("verantwortlicher") 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) {
|
||||
createProjektREST(gespeichertesProjekt).then(() => m.route.set("/projektliste", routeParams)).catch((e) => showServerError(e))
|
||||
createProjektREST(gespeichertesProjekt).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||
} 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
|
||||
}
|
||||
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",
|
||||
m("div", {style: {display: 'flex', 'align-items': 'center', 'justify-content': 'space-between'}},
|
||||
m("h1", currentProjekt.id === -1
|
||||
@@ -252,21 +256,22 @@ export const Projekt: m.ClosureComponent = (vnode) => {
|
||||
currentProjekt.id !== -1
|
||||
? [
|
||||
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("button.w3-button.w3-theme",
|
||||
{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")
|
||||
]
|
||||
: null
|
||||
),
|
||||
m(BreadcrumbBar, {
|
||||
breadcrumbs: breadcrumbs,
|
||||
current: currentProjekt.id === -1 ? "Neues Projekt" : "Projekt-Stammblatt"
|
||||
current: currentProjekt.id === -1 ? "Neues Projekt" : "Projekt-Stammblatt",
|
||||
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("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", "Zweck", m(Input, {name: "zweck", value: currentProjekt.zweck, type: "text"} as iInput)),
|
||||
|
||||
+21
-11
@@ -5,6 +5,7 @@ import {Treenode, iTreenode, Treetable, iTreetable, TopbarButton, iTopbarButton}
|
||||
import {ModalType, ModalView, iModalView} from "./widgets/Modal"
|
||||
import {gridMountpoints} from "./mainlayout"
|
||||
import {showServerError} from "./auth/authmessages"
|
||||
import {guardedNavigate, DirtyMaskHelper} from "./widgets/dirtyguard"
|
||||
import {iTag, iTagWithChildren, fetchTags, fetchTagHierarchy, fetchTagById, createTagREST, updateTagREST, deleteTagREST} from "../models/tag"
|
||||
|
||||
const colorPalette: string[] = [
|
||||
@@ -78,18 +79,22 @@ export const Tag: m.ClosureComponent = () => {
|
||||
is_parent: false,
|
||||
}
|
||||
|
||||
const setColor = (color: string) => {
|
||||
currentTag.color = color
|
||||
}
|
||||
|
||||
// Liste aller Parent-Tags fuer die Eltern-Auswahl (nur Parent-Tags
|
||||
// koennen uebergeordnet sein)
|
||||
let allParentTags: iTag[] = []
|
||||
// DirtyMaskHelper kapselt den isDirty/Guard-Boilerplate.
|
||||
const guard = new DirtyMaskHelper("/taglist")
|
||||
|
||||
const setColor = (color: string) => {
|
||||
currentTag.color = color
|
||||
guard.markDirty()
|
||||
}
|
||||
|
||||
return {
|
||||
oninit: (vnode) => {
|
||||
const routeParams = m.route.param()
|
||||
const id = routeParams && routeParams.id ? parseInt(routeParams.id) : -1
|
||||
guard.registerGuard()
|
||||
if (id !== -1) {
|
||||
// Bestehenden Tag frisch vom Server laden
|
||||
fetchTagById(id).then((result) => {
|
||||
@@ -113,6 +118,9 @@ export const Tag: m.ClosureComponent = () => {
|
||||
allParentTags = result.filter(t => t.id !== currentTag.id && t.is_parent)
|
||||
}).catch((e) => showServerError(e))
|
||||
},
|
||||
onremove: (vnode) => {
|
||||
guard.cleanup()
|
||||
},
|
||||
view: (vnode) => {
|
||||
const submit = (form: HTMLFormElement): boolean => {
|
||||
const formData = new FormData(form)
|
||||
@@ -125,19 +133,16 @@ export const Tag: m.ClosureComponent = () => {
|
||||
is_parent: currentTag.is_parent,
|
||||
}
|
||||
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 {
|
||||
updateTagREST(gespeicherterTag).then(() => m.route.set("/taglist")).catch((e) => showServerError(e))
|
||||
updateTagREST(gespeicherterTag).then(() => guard.navigateAfterSave()).catch((e) => { showServerError(e); guard.markDirty() })
|
||||
}
|
||||
return true
|
||||
}
|
||||
const cancel = (e: Event) => {
|
||||
m.route.set("/taglist")
|
||||
}
|
||||
return m("div.w3-container.w3-auto.form-view-container",
|
||||
m("h1", currentTag.id === -1 ? "Neuen Tag erstellen" : "Tag bearbeiten"),
|
||||
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", "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'},
|
||||
onclick: (e: Event) => {
|
||||
currentTag.is_parent = (e.target as HTMLInputElement).checked
|
||||
guard.markDirty()
|
||||
}
|
||||
}),
|
||||
"Nur zur Gruppierung, kann nicht zugeordnet werden"
|
||||
@@ -159,7 +165,11 @@ export const Tag: m.ClosureComponent = () => {
|
||||
|
||||
// Eltern-Tag-Auswahl
|
||||
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 --"),
|
||||
allParentTags.map(tag =>
|
||||
m("option", {value: tag.id, selected: currentTag.parent_id === tag.id}, tag.name)
|
||||
|
||||
@@ -10,10 +10,14 @@ export interface iBreadcrumb {
|
||||
|
||||
/** Attribute der BreadcrumbBar: die Vorgänger-Kette (ancestors) und das
|
||||
* 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 {
|
||||
breadcrumbs: iBreadcrumb[]
|
||||
current: string
|
||||
onnavigate?: (route: string, params: any, e?: Event) => void
|
||||
}
|
||||
|
||||
/** Parst den Route-Param `bc` (JSON-String) in ein Breadcrumb-Array.
|
||||
@@ -56,13 +60,21 @@ export const BreadcrumbBar: m.ClosureComponent<iBreadcrumbBar> = () => {
|
||||
'white-space': 'nowrap'
|
||||
},
|
||||
title: "Zurück zu " + crumb.label,
|
||||
onclick: () => {
|
||||
onclick: (e: MouseEvent) => {
|
||||
const subChain = crumbs.slice(0, i)
|
||||
const params: any = {...crumb.params}
|
||||
const bc = serializeBc(subChain)
|
||||
if (bc) params.bc = bc
|
||||
// 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))
|
||||
})
|
||||
// Aktuelle Ansicht als letztes, nicht klickbares Glied (fett).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,13 @@ export interface iForm {
|
||||
stickyButtons?: boolean
|
||||
/** Die Funktion gibt das HTMLFormElement zurück, welches in der Form-Componente angelegt wird */
|
||||
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) => {
|
||||
@@ -29,6 +36,9 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
||||
}
|
||||
function onchanged (e:Event) {
|
||||
isDirty = true
|
||||
if (vnode.attrs.onDirtyChange != undefined) {
|
||||
vnode.attrs.onDirtyChange(true)
|
||||
}
|
||||
if (vnode.attrs.onchanged != undefined) {
|
||||
vnode.attrs.onchanged(e)
|
||||
}
|
||||
@@ -37,6 +47,9 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
||||
e.preventDefault()
|
||||
if (vnode.attrs.onsubmit(form) == true) {
|
||||
isDirty = false
|
||||
if (vnode.attrs.onDirtyChange != undefined) {
|
||||
vnode.attrs.onDirtyChange(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
function registerFormElement (f:HTMLFormElement) {
|
||||
@@ -46,8 +59,11 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
||||
}
|
||||
function cancel(e:Event) {
|
||||
e.preventDefault()
|
||||
if (canCloseForm() == true) {
|
||||
isDirty = false
|
||||
// Der Dirty-Check beim Abbrechen erfolgt jetzt durch den
|
||||
// Navigations-Guard der Maske (dirtyguard.ts), da oncancel die
|
||||
// Navigation auslöst. Die Form selbst fragt hier nicht mehr nach,
|
||||
// um eine doppelte Bestätigung zu vermeiden. isDirty wird nicht
|
||||
// zurückgesetzt, damit der Guard den aktuellen State sieht.
|
||||
if (vnode.attrs.oncancel != undefined) {
|
||||
vnode.attrs.oncancel(e)
|
||||
}
|
||||
@@ -55,13 +71,12 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
||||
history.back()
|
||||
}
|
||||
}
|
||||
else {
|
||||
e['redraw'] = false
|
||||
}
|
||||
}
|
||||
function reset(e:Event) {
|
||||
if (canCloseForm("reset")) {
|
||||
isDirty = false
|
||||
if (vnode.attrs.onDirtyChange != undefined) {
|
||||
vnode.attrs.onDirtyChange(false)
|
||||
}
|
||||
// Hier passiert nichts.
|
||||
// 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
|
||||
@@ -109,6 +124,10 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
||||
for (let index = 0; index < elements.length; index++) {
|
||||
const element = elements[index];
|
||||
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)
|
||||
}
|
||||
@@ -131,6 +150,10 @@ export const Form:m.ClosureComponent<iForm> = (vnode) => {
|
||||
for (let index = 0; index < elements.length; index++) {
|
||||
const element = elements[index];
|
||||
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)
|
||||
}
|
||||
|
||||
+20
-11
@@ -11,6 +11,7 @@ import { Call_Log, call_log_liste } from "./Components/call_log"
|
||||
import { Kontakt, kontaktliste } from "./Components/kontakt"
|
||||
import { Kontaktadresse, kontaktadresseliste } from "./Components/kontaktadresse"
|
||||
import { Tag, TagList } from "./Components/tags"
|
||||
import { guardedNavigate } from "./Components/widgets/dirtyguard"
|
||||
|
||||
|
||||
function healthcheck () {
|
||||
@@ -118,21 +119,29 @@ const knopf: m.Component = {
|
||||
}
|
||||
const menu: m.ClosureComponent = () => {
|
||||
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 {
|
||||
oninit: (vnode) => {
|
||||
showProjektBerechtigungen = false
|
||||
},
|
||||
view: (vnode) => {
|
||||
return m(menubar, [
|
||||
m("a.w3-button.w3-bar-item", {href: "#!/kundenliste"}, "Kunden"),
|
||||
m("a.w3-button.w3-bar-item", {href: "#!/projektliste"}, "Projekte"),
|
||||
m("a.w3-button.w3-bar-item", {href: "#!/calllogliste"}, "Gesprächsnotizen"),
|
||||
menuItem("/kundenliste", "Kunden"),
|
||||
menuItem("/projektliste", "Projekte"),
|
||||
menuItem("/calllogliste", "Gesprächsnotizen"),
|
||||
m("div.w3-dropdown-hover",
|
||||
m("button.w3-button", "Optionen"),
|
||||
m("div.w3-dropdown-content.w3-bar-block.w3-card-4",
|
||||
m("a.w3-button.w3-bar-item", {href: "#!/taglist"}, "Tags"),
|
||||
m("a.w3-button.w3-bar-item", {href: "#!/default"}, "Datenexport"),
|
||||
m("a.w3-button.w3-bar-item", {href: "#!/default"}, "Datenimport"),
|
||||
menuItem("/taglist", "Tags"),
|
||||
menuItem("/default", "Datenexport"),
|
||||
menuItem("/default", "Datenimport"),
|
||||
// Projekt-Berechtigungen nur für SysAdmin/AppAdmin sichtbar.
|
||||
// Als Toggle-Button realisiert, da verschachtelte w3-dropdown-hover
|
||||
// innerhalb eines offenen Dropdowns nicht zuverlässig funktionieren.
|
||||
@@ -147,10 +156,10 @@ const menu: m.ClosureComponent = () => {
|
||||
showProjektBerechtigungen
|
||||
? m("div.w3-bar-block",
|
||||
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"),
|
||||
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")
|
||||
)
|
||||
: null
|
||||
@@ -162,9 +171,9 @@ const menu: m.ClosureComponent = () => {
|
||||
m("div.w3-dropdown-hover.w3-right",
|
||||
m("button.w3-button", "System"),
|
||||
m("div.w3-dropdown-content.w3-bar-block.w3-card-4",
|
||||
m("a.w3-button.w3-bar-item", {href: "#!/login"}, "Login"),
|
||||
m("a.w3-button.w3-bar-item", {href: "#!/changepassword"}, "Kennwort ändern"),
|
||||
m("a.w3-button.w3-bar-item", {href: "#!/usermanagement"}, "Verwalten"),
|
||||
menuItem("/login", "Login"),
|
||||
menuItem("/changepassword", "Kennwort ändern"),
|
||||
menuItem("/usermanagement", "Verwalten"),
|
||||
)
|
||||
)
|
||||
])
|
||||
|
||||
Reference in new issue
Block a user