"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; reloadDocumentOnSuccess?: boolean; }; 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", reloadDocumentOnSuccess = false, }: 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 dirtyFormsRef = useRef>(new Set()); const activeFormIdRef = useRef(formId ?? null); const frameRef = useRef(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); dirtyFormsRef.current.clear(); } }, [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) { if (frameRef.current !== null) { cancelAnimationFrame(frameRef.current); frameRef.current = null; } baselineRef.current.clear(); dirtyFormsRef.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 selectDirtyFormId = () => { const dirtyIds = availableIds.filter((id) => dirtyFormsRef.current.has(id)); if (dirtyIds.length === 0) { return activeFormIdRef.current && availableIds.includes(activeFormIdRef.current) ? activeFormIdRef.current : fallbackFormId; } if (activeFormIdRef.current && dirtyFormsRef.current.has(activeFormIdRef.current)) { return activeFormIdRef.current; } return dirtyIds[0] ?? fallbackFormId; }; const syncDirtyState = () => { const nextActiveFormId = selectDirtyFormId(); activeFormIdRef.current = nextActiveFormId; setActiveFormId(nextActiveFormId); setIsDirty(dirtyFormsRef.current.size > 0); }; const evaluateForm = (form: HTMLFormElement) => { const baseline = baselineRef.current.get(form.id); const current = serializeForm(form); if (baseline === undefined) { return; } if (current !== baseline) { dirtyFormsRef.current.add(form.id); return; } dirtyFormsRef.current.delete(form.id); }; const scheduleSync = (nextActiveFormId?: string) => { if (nextActiveFormId) { activeFormIdRef.current = nextActiveFormId; } if (frameRef.current !== null) { cancelAnimationFrame(frameRef.current); } frameRef.current = requestAnimationFrame(() => { frameRef.current = null; for (const form of forms) { evaluateForm(form); } syncDirtyState(); }); }; const syncBaseline = (form: HTMLFormElement) => { baselineRef.current.set(form.id, serializeForm(form)); dirtyFormsRef.current.delete(form.id); syncDirtyState(); setIsSubmitting(false); }; const handleFormActivity = (form: HTMLFormElement) => { setIsSubmitting(false); scheduleSync(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); for (const form of forms) { evaluateForm(form); } syncDirtyState(); return () => { if (frameRef.current !== null) { cancelAnimationFrame(frameRef.current); frameRef.current = null; } 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); dirtyFormsRef.current.delete(pendingSubmission.formId); return; } if (!hasSuccess && !navigated) { return; } pendingSubmissionRef.current = null; if (reloadDocumentOnSuccess) { window.location.reload(); return; } 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, reloadDocumentOnSuccess, router, searchParams]); return ( ); }