252 lines
7.8 KiB
TypeScript
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>
|
|
);
|
|
}
|