fix admin save state architecture

This commit is contained in:
MOH
2026-03-10 05:24:50 +01:00
parent e219295327
commit f2b5a15191
12 changed files with 123 additions and 38 deletions
+2 -1
View File
@@ -14,6 +14,7 @@ import { getLocalizedPath } from "@/lib/locale";
import { removeManagedMediaFile } from "@/lib/media-storage"; import { removeManagedMediaFile } from "@/lib/media-storage";
import { mediaFieldInputSchema } from "@/lib/media-validation"; import { mediaFieldInputSchema } from "@/lib/media-validation";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { isCheckedFormValue } from "@/lib/form-data";
import { import {
assetInputSchema, assetInputSchema,
categoryInputSchema, categoryInputSchema,
@@ -40,7 +41,7 @@ function withMessage(pathname: string, type: "success" | "error", message: strin
} }
function normalizeCheckboxValue(formData: FormData, key: string) { function normalizeCheckboxValue(formData: FormData, key: string) {
return formData.get(key) === "on"; return isCheckedFormValue(formData.get(key));
} }
function parseJsonArray(rawValue: FormDataEntryValue | null, key: string) { function parseJsonArray(rawValue: FormDataEntryValue | null, key: string) {
+2 -1
View File
@@ -78,6 +78,8 @@ export default async function RootPortfolioProjectPage({
logoutAction={logoutAction} logoutAction={logoutAction}
headerTitle={copy.title} headerTitle={copy.title}
headerDescription={copy.subtitle} headerDescription={copy.subtitle}
saveFormId="portfolio-project-form"
saveButtonLabel={copy.saveProject}
> >
<div className="space-y-6"> <div className="space-y-6">
{searchParams?.success ? ( {searchParams?.success ? (
@@ -100,7 +102,6 @@ export default async function RootPortfolioProjectPage({
project={project} project={project}
formId="portfolio-project-form" formId="portfolio-project-form"
redirectPath={`/root/portfolio/projects/${project.id}`} redirectPath={`/root/portfolio/projects/${project.id}`}
submitLabel={copy.saveProject}
/> />
</MotionFade> </MotionFade>
+1 -1
View File
@@ -58,6 +58,7 @@ export default async function RootNewPortfolioProjectPage({
logoutAction={logoutAction} logoutAction={logoutAction}
headerTitle={copy.title} headerTitle={copy.title}
headerDescription={copy.subtitle} headerDescription={copy.subtitle}
saveFormId="portfolio-project-form"
> >
<div className="space-y-6"> <div className="space-y-6">
{searchParams?.error ? ( {searchParams?.error ? (
@@ -73,7 +74,6 @@ export default async function RootNewPortfolioProjectPage({
mediaOptions={mediaOptions} mediaOptions={mediaOptions}
formId="portfolio-project-form" formId="portfolio-project-form"
redirectPath="/root/portfolio/projects/new" redirectPath="/root/portfolio/projects/new"
submitLabel="Projekt anlegen"
/> />
</MotionFade> </MotionFade>
</div> </div>
+2 -1
View File
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
import { isRedirectError } from "next/dist/client/components/redirect"; import { isRedirectError } from "next/dist/client/components/redirect";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { isCheckedFormValue } from "@/lib/form-data";
import { import {
getMailSettings, getMailSettings,
updateMailSettings, updateMailSettings,
@@ -53,7 +54,7 @@ function parseMailSettingsFormData(
smtp: { smtp: {
host, host,
port: parsePort(portValue || String(existingSettings.smtp.port)), port: parsePort(portValue || String(existingSettings.smtp.port)),
secure: formData.get("smtpSecure") === "on", secure: isCheckedFormValue(formData.get("smtpSecure")),
username, username,
password: password.trim() ? password : existingSettings.smtp.password, password: password.trim() ? password : existingSettings.smtp.password,
}, },
+3 -2
View File
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
import { isRedirectError } from "next/dist/client/components/redirect"; import { isRedirectError } from "next/dist/client/components/redirect";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { isCheckedFormValue } from "@/lib/form-data";
import { import {
getContactProtectionSettings, getContactProtectionSettings,
updateContactProtectionSettings, updateContactProtectionSettings,
@@ -39,10 +40,10 @@ function parseContactProtectionFormData(
formData: FormData, formData: FormData,
existingSettings: ContactProtectionSettings, existingSettings: ContactProtectionSettings,
): ContactProtectionSettings { ): ContactProtectionSettings {
const turnstileEnabled = formData.get("turnstileEnabled") === "on"; const turnstileEnabled = isCheckedFormValue(formData.get("turnstileEnabled"));
const turnstileSiteKey = String(formData.get("turnstileSiteKey") ?? "").trim(); const turnstileSiteKey = String(formData.get("turnstileSiteKey") ?? "").trim();
const turnstileSecretKey = String(formData.get("turnstileSecretKey") ?? ""); const turnstileSecretKey = String(formData.get("turnstileSecretKey") ?? "");
const rateLimitEnabled = formData.get("contactRateLimitEnabled") === "on"; const rateLimitEnabled = isCheckedFormValue(formData.get("contactRateLimitEnabled"));
if (turnstileEnabled && !turnstileSiteKey) { if (turnstileEnabled && !turnstileSiteKey) {
throw new Error("Turnstile site key is required when Turnstile is enabled."); throw new Error("Turnstile site key is required when Turnstile is enabled.");
+71 -13
View File
@@ -39,7 +39,9 @@ export function FormSaveButton({
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const baselineRef = useRef<Map<string, string>>(new Map()); const baselineRef = useRef<Map<string, string>>(new Map());
const dirtyFormsRef = useRef<Set<string>>(new Set());
const activeFormIdRef = useRef<string | null>(formId ?? null); const activeFormIdRef = useRef<string | null>(formId ?? null);
const frameRef = useRef<number | null>(null);
const pendingSubmissionRef = useRef<{ const pendingSubmissionRef = useRef<{
formId: string; formId: string;
originUrl: string; originUrl: string;
@@ -54,6 +56,7 @@ export function FormSaveButton({
setActiveFormId(null); setActiveFormId(null);
setIsDirty(false); setIsDirty(false);
setIsSubmitting(false); setIsSubmitting(false);
dirtyFormsRef.current.clear();
} }
}, [formId, formIds, formSelector, formSelectors]); }, [formId, formIds, formSelector, formSelectors]);
@@ -69,7 +72,12 @@ export function FormSaveButton({
const forms = Array.from(new Map([...formsById, ...formsBySelector].map((form) => [form.id, form])).values()); const forms = Array.from(new Map([...formsById, ...formsBySelector].map((form) => [form.id, form])).values());
if (forms.length === 0) { if (forms.length === 0) {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
baselineRef.current.clear(); baselineRef.current.clear();
dirtyFormsRef.current.clear();
activeFormIdRef.current = formId ?? null; activeFormIdRef.current = formId ?? null;
setActiveFormId(formId ?? null); setActiveFormId(formId ?? null);
setIsDirty(false); setIsDirty(false);
@@ -80,33 +88,74 @@ export function FormSaveButton({
const availableIds = forms.map((form) => form.id).filter(Boolean); const availableIds = forms.map((form) => form.id).filter(Boolean);
const fallbackFormId = availableIds[0] ?? null; const fallbackFormId = availableIds[0] ?? null;
const readDirtyState = (nextActiveFormId: string | null) => { const selectDirtyFormId = () => {
if (!nextActiveFormId) { const dirtyIds = availableIds.filter((id) => dirtyFormsRef.current.has(id));
setIsDirty(false);
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; return;
} }
const nextForm = forms.find((form) => form.id === nextActiveFormId); if (current !== baseline) {
dirtyFormsRef.current.add(form.id);
if (!nextForm) {
setIsDirty(false);
return; return;
} }
setIsDirty(serializeForm(nextForm) !== baselineRef.current.get(nextActiveFormId)); 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) => { const syncBaseline = (form: HTMLFormElement) => {
baselineRef.current.set(form.id, serializeForm(form)); baselineRef.current.set(form.id, serializeForm(form));
readDirtyState(form.id === activeFormIdRef.current ? form.id : activeFormIdRef.current ?? fallbackFormId); dirtyFormsRef.current.delete(form.id);
syncDirtyState();
setIsSubmitting(false); setIsSubmitting(false);
}; };
const handleFormActivity = (form: HTMLFormElement) => { const handleFormActivity = (form: HTMLFormElement) => {
activeFormIdRef.current = form.id;
setActiveFormId(form.id);
setIsSubmitting(false); setIsSubmitting(false);
setIsDirty(serializeForm(form) !== baselineRef.current.get(form.id)); scheduleSync(form.id);
}; };
const handleSubmit = (form: HTMLFormElement, event: SubmitEvent) => { const handleSubmit = (form: HTMLFormElement, event: SubmitEvent) => {
@@ -162,9 +211,17 @@ export function FormSaveButton({
: fallbackFormId; : fallbackFormId;
activeFormIdRef.current = nextActive; activeFormIdRef.current = nextActive;
setActiveFormId(nextActive); setActiveFormId(nextActive);
readDirtyState(nextActive); for (const form of forms) {
evaluateForm(form);
}
syncDirtyState();
return () => { return () => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
for (const form of forms) { for (const form of forms) {
const handlers = (form as HTMLFormElement & { const handlers = (form as HTMLFormElement & {
__saveButtonHandlers?: { __saveButtonHandlers?: {
@@ -214,6 +271,7 @@ export function FormSaveButton({
if (hasError) { if (hasError) {
pendingSubmissionRef.current = null; pendingSubmissionRef.current = null;
setIsSubmitting(false); setIsSubmitting(false);
dirtyFormsRef.current.delete(pendingSubmission.formId);
return; return;
} }
@@ -80,7 +80,6 @@ type PortfolioProjectFormProps = {
project?: PortfolioProjectView | null; project?: PortfolioProjectView | null;
formId: string; formId: string;
redirectPath: string; redirectPath: string;
submitLabel: string;
}; };
const localeFieldConfig = [ const localeFieldConfig = [
@@ -286,7 +285,6 @@ export function PortfolioProjectForm({
project, project,
formId, formId,
redirectPath, redirectPath,
submitLabel,
}: PortfolioProjectFormProps) { }: PortfolioProjectFormProps) {
const [activePanel, setActivePanel] = useState<PanelKey>("basic"); const [activePanel, setActivePanel] = useState<PanelKey>("basic");
const [projectState, setProjectState] = useState<ProjectFormState>( const [projectState, setProjectState] = useState<ProjectFormState>(
@@ -864,19 +862,6 @@ export function PortfolioProjectForm({
</section> </section>
</div> </div>
</div> </div>
<AppCard>
<CardContent className="flex flex-col gap-3 p-6 lg:flex-row lg:items-center lg:justify-between">
<p className="text-sm text-muted-foreground">
{validation.allDone
? "Alles bereit zum Speichern."
: "Du kannst jetzt speichern. Falls Pflichtfelder fehlen, bekommst du oben eine Fehlermeldung."}
</p>
<Button type="submit">
{submitLabel}
</Button>
</CardContent>
</AppCard>
</form> </form>
); );
} }
@@ -29,7 +29,6 @@ export function SidebarMaintenanceControl({
return ( return (
<form id="sidebar-maintenance-form" action={action} className="space-y-2"> <form id="sidebar-maintenance-form" action={action} className="space-y-2">
<input type="hidden" name="redirectPath" value={pathname} /> <input type="hidden" name="redirectPath" value={pathname} />
<input type="hidden" name="enabled" value={enabled ? "true" : "false"} />
<label <label
htmlFor="sidebar-maintenance-enabled" htmlFor="sidebar-maintenance-enabled"
@@ -66,7 +65,10 @@ export function SidebarMaintenanceControl({
<Checkbox <Checkbox
id="sidebar-maintenance-enabled" id="sidebar-maintenance-enabled"
name="enabled"
checked={enabled} checked={enabled}
checkedValue="true"
uncheckedValue="false"
onCheckedChange={(checked) => setEnabled(checked === true)} onCheckedChange={(checked) => setEnabled(checked === true)}
className="sr-only" className="sr-only"
/> />
+7 -2
View File
@@ -5,22 +5,27 @@ import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react"; import { Check } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useHiddenInputSync } from "@/components/ui/use-hidden-input-sync";
type CheckboxProps = React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> & { type CheckboxProps = React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> & {
name?: string; name?: string;
checkedValue?: string;
uncheckedValue?: string;
}; };
const Checkbox = React.forwardRef< const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>, React.ElementRef<typeof CheckboxPrimitive.Root>,
CheckboxProps CheckboxProps
>(({ className, name, checked, defaultChecked, onCheckedChange, ...props }, ref) => { >(({ className, name, checked, defaultChecked, onCheckedChange, checkedValue = "on", uncheckedValue = "false", ...props }, ref) => {
const [internalChecked, setInternalChecked] = React.useState(defaultChecked === true); const [internalChecked, setInternalChecked] = React.useState(defaultChecked === true);
const isControlled = checked !== undefined; const isControlled = checked !== undefined;
const currentChecked = isControlled ? checked === true : internalChecked; const currentChecked = isControlled ? checked === true : internalChecked;
const hiddenValue = currentChecked ? checkedValue : uncheckedValue;
const hiddenInputRef = useHiddenInputSync(hiddenValue);
return ( return (
<> <>
{name ? <input type="hidden" name={name} value={currentChecked ? "on" : "false"} /> : null} {name ? <input ref={hiddenInputRef} type="hidden" name={name} value={hiddenValue} /> : null}
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
ref={ref} ref={ref}
checked={checked} checked={checked}
+3 -1
View File
@@ -5,6 +5,7 @@ import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react"; import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useHiddenInputSync } from "@/components/ui/use-hidden-input-sync";
type SelectProps = React.ComponentPropsWithoutRef<typeof SelectPrimitive.Root> & { type SelectProps = React.ComponentPropsWithoutRef<typeof SelectPrimitive.Root> & {
name?: string; name?: string;
@@ -23,10 +24,11 @@ function Select({
const [internalValue, setInternalValue] = React.useState(defaultValue ?? ""); const [internalValue, setInternalValue] = React.useState(defaultValue ?? "");
const isControlled = value !== undefined; const isControlled = value !== undefined;
const currentValue = (isControlled ? value : internalValue) as string; const currentValue = (isControlled ? value : internalValue) as string;
const hiddenInputRef = useHiddenInputSync(currentValue);
return ( return (
<> <>
{name ? <input type="hidden" name={name} value={currentValue} required={required} /> : null} {name ? <input ref={hiddenInputRef} type="hidden" name={name} value={currentValue} required={required} /> : null}
<SelectPrimitive.Root <SelectPrimitive.Root
value={value} value={value}
defaultValue={defaultValue} defaultValue={defaultValue}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import * as React from "react";
export function useHiddenInputSync(value: string) {
const inputRef = React.useRef<HTMLInputElement | null>(null);
const mountedRef = React.useRef(false);
React.useEffect(() => {
const input = inputRef.current;
if (!input) {
return;
}
if (!mountedRef.current) {
mountedRef.current = true;
return;
}
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
}, [value]);
return inputRef;
}
+3
View File
@@ -0,0 +1,3 @@
export function isCheckedFormValue(value: FormDataEntryValue | null) {
return value === "on" || value === "true" || value === "1";
}