Files
sass-mohfarawati/components/root/form-save-button.tsx
T
2026-03-07 19:46:51 +01:00

252 lines
7.8 KiB
TypeScript

"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<string | null>(formId ?? null);
const [isDirty, setIsDirty] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const baselineRef = useRef<Map<string, string>>(new Map());
const activeFormIdRef = useRef<string | null>(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;
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 (
<Button type="submit" form={activeFormId ?? undefined} disabled={!activeFormId || !isDirty || isSubmitting}>
{isSubmitting ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{isSubmitting ? "Speichert..." : label}
</Button>
);
}