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>
268 lines
11 KiB
TypeScript
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
|
|
}
|
|
}
|
|
}
|