Files
kbcrm/Client/Components/widgets/dirtyguard.ts
T
gg a67921ec74 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>
2026-09-19 18:21:58 +02:00

268 lines
11 KiB
TypeScript

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
}
}
}