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
+71 -13
View File
@@ -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"
/>
+7 -2
View File
@@ -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}
+3 -1
View File
@@ -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}
+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;
}