This commit is contained in:
@@ -1,12 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { Save } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
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;
|
||||
formId?: string;
|
||||
formIds?: string[];
|
||||
formSelector?: string;
|
||||
formSelectors?: string[];
|
||||
label?: string;
|
||||
};
|
||||
|
||||
@@ -21,41 +25,220 @@ function serializeForm(form: HTMLFormElement) {
|
||||
|
||||
export function FormSaveButton({
|
||||
formId,
|
||||
formIds,
|
||||
formSelector,
|
||||
formSelectors,
|
||||
label = "Speichern",
|
||||
}: 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(() => {
|
||||
const form = document.getElementById(formId);
|
||||
|
||||
if (!(form instanceof HTMLFormElement)) {
|
||||
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 initialSnapshot = serializeForm(form);
|
||||
const availableIds = forms.map((form) => form.id).filter(Boolean);
|
||||
const fallbackFormId = availableIds[0] ?? null;
|
||||
|
||||
const updateDirtyState = () => {
|
||||
setIsDirty(serializeForm(form) !== initialSnapshot);
|
||||
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));
|
||||
};
|
||||
|
||||
updateDirtyState();
|
||||
const syncBaseline = (form: HTMLFormElement) => {
|
||||
baselineRef.current.set(form.id, serializeForm(form));
|
||||
readDirtyState(form.id === activeFormIdRef.current ? form.id : activeFormIdRef.current ?? fallbackFormId);
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
form.addEventListener("input", updateDirtyState);
|
||||
form.addEventListener("change", updateDirtyState);
|
||||
form.addEventListener("reset", updateDirtyState);
|
||||
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 () => {
|
||||
form.removeEventListener("input", updateDirtyState);
|
||||
form.removeEventListener("change", updateDirtyState);
|
||||
form.removeEventListener("reset", updateDirtyState);
|
||||
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;
|
||||
}
|
||||
};
|
||||
}, [formId]);
|
||||
}, [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 (
|
||||
<Button type="submit" form={formId} disabled={!isDirty}>
|
||||
<Save className="h-4 w-4" />
|
||||
{label}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user