fix admin save state architecture
This commit is contained in:
@@ -14,6 +14,7 @@ import { getLocalizedPath } from "@/lib/locale";
|
||||
import { removeManagedMediaFile } from "@/lib/media-storage";
|
||||
import { mediaFieldInputSchema } from "@/lib/media-validation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { isCheckedFormValue } from "@/lib/form-data";
|
||||
import {
|
||||
assetInputSchema,
|
||||
categoryInputSchema,
|
||||
@@ -40,7 +41,7 @@ function withMessage(pathname: string, type: "success" | "error", message: strin
|
||||
}
|
||||
|
||||
function normalizeCheckboxValue(formData: FormData, key: string) {
|
||||
return formData.get(key) === "on";
|
||||
return isCheckedFormValue(formData.get(key));
|
||||
}
|
||||
|
||||
function parseJsonArray(rawValue: FormDataEntryValue | null, key: string) {
|
||||
|
||||
@@ -78,6 +78,8 @@ export default async function RootPortfolioProjectPage({
|
||||
logoutAction={logoutAction}
|
||||
headerTitle={copy.title}
|
||||
headerDescription={copy.subtitle}
|
||||
saveFormId="portfolio-project-form"
|
||||
saveButtonLabel={copy.saveProject}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{searchParams?.success ? (
|
||||
@@ -100,7 +102,6 @@ export default async function RootPortfolioProjectPage({
|
||||
project={project}
|
||||
formId="portfolio-project-form"
|
||||
redirectPath={`/root/portfolio/projects/${project.id}`}
|
||||
submitLabel={copy.saveProject}
|
||||
/>
|
||||
</MotionFade>
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export default async function RootNewPortfolioProjectPage({
|
||||
logoutAction={logoutAction}
|
||||
headerTitle={copy.title}
|
||||
headerDescription={copy.subtitle}
|
||||
saveFormId="portfolio-project-form"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{searchParams?.error ? (
|
||||
@@ -73,7 +74,6 @@ export default async function RootNewPortfolioProjectPage({
|
||||
mediaOptions={mediaOptions}
|
||||
formId="portfolio-project-form"
|
||||
redirectPath="/root/portfolio/projects/new"
|
||||
submitLabel="Projekt anlegen"
|
||||
/>
|
||||
</MotionFade>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
|
||||
import { isRedirectError } from "next/dist/client/components/redirect";
|
||||
|
||||
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||
import { isCheckedFormValue } from "@/lib/form-data";
|
||||
import {
|
||||
getMailSettings,
|
||||
updateMailSettings,
|
||||
@@ -53,7 +54,7 @@ function parseMailSettingsFormData(
|
||||
smtp: {
|
||||
host,
|
||||
port: parsePort(portValue || String(existingSettings.smtp.port)),
|
||||
secure: formData.get("smtpSecure") === "on",
|
||||
secure: isCheckedFormValue(formData.get("smtpSecure")),
|
||||
username,
|
||||
password: password.trim() ? password : existingSettings.smtp.password,
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
|
||||
import { isRedirectError } from "next/dist/client/components/redirect";
|
||||
|
||||
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||
import { isCheckedFormValue } from "@/lib/form-data";
|
||||
import {
|
||||
getContactProtectionSettings,
|
||||
updateContactProtectionSettings,
|
||||
@@ -39,10 +40,10 @@ function parseContactProtectionFormData(
|
||||
formData: FormData,
|
||||
existingSettings: ContactProtectionSettings,
|
||||
): ContactProtectionSettings {
|
||||
const turnstileEnabled = formData.get("turnstileEnabled") === "on";
|
||||
const turnstileEnabled = isCheckedFormValue(formData.get("turnstileEnabled"));
|
||||
const turnstileSiteKey = String(formData.get("turnstileSiteKey") ?? "").trim();
|
||||
const turnstileSecretKey = String(formData.get("turnstileSecretKey") ?? "");
|
||||
const rateLimitEnabled = formData.get("contactRateLimitEnabled") === "on";
|
||||
const rateLimitEnabled = isCheckedFormValue(formData.get("contactRateLimitEnabled"));
|
||||
|
||||
if (turnstileEnabled && !turnstileSiteKey) {
|
||||
throw new Error("Turnstile site key is required when Turnstile is enabled.");
|
||||
|
||||
@@ -39,7 +39,9 @@ export function FormSaveButton({
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const baselineRef = useRef<Map<string, string>>(new Map());
|
||||
const dirtyFormsRef = useRef<Set<string>>(new Set());
|
||||
const activeFormIdRef = useRef<string | null>(formId ?? null);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
const pendingSubmissionRef = useRef<{
|
||||
formId: string;
|
||||
originUrl: string;
|
||||
@@ -54,6 +56,7 @@ export function FormSaveButton({
|
||||
setActiveFormId(null);
|
||||
setIsDirty(false);
|
||||
setIsSubmitting(false);
|
||||
dirtyFormsRef.current.clear();
|
||||
}
|
||||
}, [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());
|
||||
|
||||
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);
|
||||
@@ -80,33 +88,74 @@ export function FormSaveButton({
|
||||
const availableIds = forms.map((form) => form.id).filter(Boolean);
|
||||
const fallbackFormId = availableIds[0] ?? null;
|
||||
|
||||
const readDirtyState = (nextActiveFormId: string | null) => {
|
||||
if (!nextActiveFormId) {
|
||||
setIsDirty(false);
|
||||
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;
|
||||
}
|
||||
|
||||
const nextForm = forms.find((form) => form.id === nextActiveFormId);
|
||||
|
||||
if (!nextForm) {
|
||||
setIsDirty(false);
|
||||
if (current !== baseline) {
|
||||
dirtyFormsRef.current.add(form.id);
|
||||
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) => {
|
||||
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);
|
||||
};
|
||||
|
||||
const handleFormActivity = (form: HTMLFormElement) => {
|
||||
activeFormIdRef.current = form.id;
|
||||
setActiveFormId(form.id);
|
||||
setIsSubmitting(false);
|
||||
setIsDirty(serializeForm(form) !== baselineRef.current.get(form.id));
|
||||
scheduleSync(form.id);
|
||||
};
|
||||
|
||||
const handleSubmit = (form: HTMLFormElement, event: SubmitEvent) => {
|
||||
@@ -162,9 +211,17 @@ export function FormSaveButton({
|
||||
: fallbackFormId;
|
||||
activeFormIdRef.current = nextActive;
|
||||
setActiveFormId(nextActive);
|
||||
readDirtyState(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?: {
|
||||
@@ -214,6 +271,7 @@ export function FormSaveButton({
|
||||
if (hasError) {
|
||||
pendingSubmissionRef.current = null;
|
||||
setIsSubmitting(false);
|
||||
dirtyFormsRef.current.delete(pendingSubmission.formId);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,6 @@ type PortfolioProjectFormProps = {
|
||||
project?: PortfolioProjectView | null;
|
||||
formId: string;
|
||||
redirectPath: string;
|
||||
submitLabel: string;
|
||||
};
|
||||
|
||||
const localeFieldConfig = [
|
||||
@@ -286,7 +285,6 @@ export function PortfolioProjectForm({
|
||||
project,
|
||||
formId,
|
||||
redirectPath,
|
||||
submitLabel,
|
||||
}: PortfolioProjectFormProps) {
|
||||
const [activePanel, setActivePanel] = useState<PanelKey>("basic");
|
||||
const [projectState, setProjectState] = useState<ProjectFormState>(
|
||||
@@ -864,19 +862,6 @@ export function PortfolioProjectForm({
|
||||
</section>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ export function SidebarMaintenanceControl({
|
||||
return (
|
||||
<form id="sidebar-maintenance-form" action={action} className="space-y-2">
|
||||
<input type="hidden" name="redirectPath" value={pathname} />
|
||||
<input type="hidden" name="enabled" value={enabled ? "true" : "false"} />
|
||||
|
||||
<label
|
||||
htmlFor="sidebar-maintenance-enabled"
|
||||
@@ -66,7 +65,10 @@ export function SidebarMaintenanceControl({
|
||||
|
||||
<Checkbox
|
||||
id="sidebar-maintenance-enabled"
|
||||
name="enabled"
|
||||
checked={enabled}
|
||||
checkedValue="true"
|
||||
uncheckedValue="false"
|
||||
onCheckedChange={(checked) => setEnabled(checked === true)}
|
||||
className="sr-only"
|
||||
/>
|
||||
|
||||
@@ -5,22 +5,27 @@ import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useHiddenInputSync } from "@/components/ui/use-hidden-input-sync";
|
||||
|
||||
type CheckboxProps = React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> & {
|
||||
name?: string;
|
||||
checkedValue?: string;
|
||||
uncheckedValue?: string;
|
||||
};
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
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 isControlled = checked !== undefined;
|
||||
const currentChecked = isControlled ? checked === true : internalChecked;
|
||||
const hiddenValue = currentChecked ? checkedValue : uncheckedValue;
|
||||
const hiddenInputRef = useHiddenInputSync(hiddenValue);
|
||||
|
||||
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
|
||||
ref={ref}
|
||||
checked={checked}
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useHiddenInputSync } from "@/components/ui/use-hidden-input-sync";
|
||||
|
||||
type SelectProps = React.ComponentPropsWithoutRef<typeof SelectPrimitive.Root> & {
|
||||
name?: string;
|
||||
@@ -23,10 +24,11 @@ function Select({
|
||||
const [internalValue, setInternalValue] = React.useState(defaultValue ?? "");
|
||||
const isControlled = value !== undefined;
|
||||
const currentValue = (isControlled ? value : internalValue) as string;
|
||||
const hiddenInputRef = useHiddenInputSync(currentValue);
|
||||
|
||||
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
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function isCheckedFormValue(value: FormDataEntryValue | null) {
|
||||
return value === "on" || value === "true" || value === "1";
|
||||
}
|
||||
Reference in New Issue
Block a user