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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user