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
+462
-120
No files matched your search
@@ -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")
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
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
@@ -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"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
])
|
])
|
||||||
|
|||||||
Reference in new issue
Block a user