"use client"; import { LoaderCircle, Save } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; type FormSaveButtonProps = { formId?: string; formIds?: string[]; formSelector?: string; formSelectors?: string[]; label?: string; }; function serializeForm(form: HTMLFormElement) { return JSON.stringify( Array.from(new FormData(form).entries()).map(([key, value]) => [ key, value instanceof File ? `${value.name}:${value.size}:${value.type}` : value, ]), ); } export function FormSaveButton({ formId, formIds, formSelector, formSelectors, label = "Speichern", }: FormSaveButtonProps) { const pathname = usePathname(); const router = useRouter(); const searchParams = useSearchParams(); const [activeFormId, setActiveFormId] = useState(formId ?? null); const [isDirty, setIsDirty] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const baselineRef = useRef>(new Map()); const activeFormIdRef = useRef(formId ?? null); const pendingSubmissionRef = useRef<{ formId: string; originUrl: string; } | null>(null); const currentUrl = `${pathname}?${searchParams.toString()}`; useEffect(() => { if (!formId && !formIds?.length && !formSelector && !formSelectors?.length) { activeFormIdRef.current = null; pendingSubmissionRef.current = null; setActiveFormId(null); setIsDirty(false); setIsSubmitting(false); } }, [formId, formIds, formSelector, formSelectors]); useEffect(() => { const formsById = [formId, ...(formIds ?? [])] .filter((value): value is string => Boolean(value)) .map((id) => document.getElementById(id)) .filter((form): form is HTMLFormElement => form instanceof HTMLFormElement); const formsBySelector = [formSelector, ...(formSelectors ?? [])] .filter((value): value is string => Boolean(value)) .flatMap((selector) => Array.from(document.querySelectorAll(selector))) .filter((form): form is HTMLFormElement => form instanceof HTMLFormElement); const forms = Array.from(new Map([...formsById, ...formsBySelector].map((form) => [form.id, form])).values()); if (forms.length === 0) { baselineRef.current.clear(); activeFormIdRef.current = formId ?? null; setActiveFormId(formId ?? null); setIsDirty(false); setIsSubmitting(false); return undefined; } const availableIds = forms.map((form) => form.id).filter(Boolean); const fallbackFormId = availableIds[0] ?? null; const readDirtyState = (nextActiveFormId: string | null) => { if (!nextActiveFormId) { setIsDirty(false); return; } const nextForm = forms.find((form) => form.id === nextActiveFormId); if (!nextForm) { setIsDirty(false); return; } setIsDirty(serializeForm(nextForm) !== baselineRef.current.get(nextActiveFormId)); }; const syncBaseline = (form: HTMLFormElement) => { baselineRef.current.set(form.id, serializeForm(form)); readDirtyState(form.id === activeFormIdRef.current ? form.id : activeFormIdRef.current ?? fallbackFormId); setIsSubmitting(false); }; const handleFormActivity = (form: HTMLFormElement) => { activeFormIdRef.current = form.id; setActiveFormId(form.id); setIsSubmitting(false); setIsDirty(serializeForm(form) !== baselineRef.current.get(form.id)); }; const handleSubmit = (form: HTMLFormElement, event: SubmitEvent) => { if (pendingSubmissionRef.current) { event.preventDefault(); event.stopPropagation(); return; } activeFormIdRef.current = form.id; pendingSubmissionRef.current = { formId: form.id, originUrl: currentUrl, }; setActiveFormId(form.id); setIsSubmitting(true); setIsDirty(false); }; for (const form of forms) { if (!form.id) { continue; } syncBaseline(form); const onFocusIn = () => handleFormActivity(form); const onInput = () => handleFormActivity(form); const onChange = () => handleFormActivity(form); const onReset = () => syncBaseline(form); const onSubmit = (event: Event) => handleSubmit(form, event as SubmitEvent); form.addEventListener("focusin", onFocusIn); form.addEventListener("input", onInput); form.addEventListener("change", onChange); form.addEventListener("reset", onReset); form.addEventListener("submit", onSubmit); (form as HTMLFormElement & { __saveButtonHandlers?: { onFocusIn: () => void; onInput: () => void; onChange: () => void; onReset: () => void; onSubmit: (event: Event) => void; }; }).__saveButtonHandlers = { onFocusIn, onInput, onChange, onReset, onSubmit }; } const nextActive = activeFormIdRef.current && availableIds.includes(activeFormIdRef.current) ? activeFormIdRef.current : fallbackFormId; activeFormIdRef.current = nextActive; setActiveFormId(nextActive); readDirtyState(nextActive); return () => { for (const form of forms) { const handlers = (form as HTMLFormElement & { __saveButtonHandlers?: { onFocusIn: () => void; onInput: () => void; onChange: () => void; onReset: () => void; onSubmit: (event: Event) => void; }; }).__saveButtonHandlers; if (!handlers) { continue; } form.removeEventListener("focusin", handlers.onFocusIn); form.removeEventListener("input", handlers.onInput); form.removeEventListener("change", handlers.onChange); form.removeEventListener("reset", handlers.onReset); form.removeEventListener("submit", handlers.onSubmit); delete ( form as HTMLFormElement & { __saveButtonHandlers?: { onFocusIn: () => void; onInput: () => void; onChange: () => void; onReset: () => void; onSubmit: (event: Event) => void; }; } ).__saveButtonHandlers; } }; }, [currentUrl, formId, formIds, formSelector, formSelectors, pathname, searchParams]); useEffect(() => { const pendingSubmission = pendingSubmissionRef.current; if (!pendingSubmission) { return; } const hasError = searchParams.has("error"); const hasSuccess = searchParams.has("success") || searchParams.has("__saved"); const navigated = currentUrl !== pendingSubmission.originUrl; if (hasError) { pendingSubmissionRef.current = null; setIsSubmitting(false); return; } if (!hasSuccess && !navigated) { return; } pendingSubmissionRef.current = null; router.refresh(); if (!searchParams.has("__saved")) { return; } const nextParams = new URLSearchParams(searchParams.toString()); nextParams.delete("__saved"); const nextQuery = nextParams.toString(); router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, { scroll: false, }); }, [currentUrl, pathname, router, searchParams]); return ( ); }