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 = { 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 { return { showbuttons: true, onsubmit: onsubmit, oncancel: this.cancel, onDirtyChange: this.onDirtyChange, onformcreate: this.setForm } } }