// oxlint-disable react-hooks/exhaustive-deps "use client"; import { Divide } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import type { FormSubmitResult } from "./form-submit"; import { DialogClose } from "./ui/dialog"; import { Label } from "./ui/label"; import { Tabs, TabsList, TabsTrigger } from "./ui/tabs"; const AUTOSAVE_TTL_MS = 10 * 60 * 1000; const STORAGE_PREFIX = "form:autosave:"; type FormValues = Record; type FormContextValue = { id: string; values: FormValues; errors: Record; loading: boolean; setValue: (name: string, value: unknown) => void; updateValues: (updater: (prev: FormValues) => FormValues) => void; clear: () => void; submit: () => Promise; closeDialog: () => void; setErrors: React.Dispatch>>; }; const FormContext = React.createContext(null); export function useFormContext() { const ctx = React.useContext(FormContext); if (!ctx) throw new Error("FormInput/FormAction must be inside FormProvider"); return ctx; } function storageKey(id: string) { return `${STORAGE_PREFIX}${id}`; } function safeParseAutosave(value: string | null) { if (!value) return null; try { return JSON.parse(value) as { savedAt: number; values: FormValues }; } catch { return null; } } function pruneAutosaveValues(values: FormValues) { const pruned: FormValues = {}; for (const [key, value] of Object.entries(values)) { if (value == null) continue; if (value instanceof Blob) continue; if (Array.isArray(value)) { const items = value.filter( (item) => typeof item === "string" || typeof item === "number" || typeof item === "boolean", ); if (items.length) pruned[key] = items; continue; } if ( typeof value === "string" || typeof value === "number" || typeof value === "boolean" ) { pruned[key] = value; } } return pruned; } function appendFormValue(form: FormData, key: string, value: unknown) { if (value == null) return; if (Array.isArray(value)) { for (const item of value) appendFormValue(form, key, item); return; } if (value instanceof Blob) { form.append(key, value); return; } form.append(key, typeof value === "string" ? value : String(value)); } function getInputValue(eventOrValue: unknown) { const event = eventOrValue as React.ChangeEvent; const target = event?.target as | (HTMLInputElement & { files?: FileList | null }) | undefined; if (target) { if (target.type === "checkbox") return target.checked; if (target.files) return target.multiple ? Array.from(target.files) : target.files[0] || null; if (target instanceof HTMLSelectElement && target.multiple) return Array.from(target.selectedOptions).map((opt) => opt.value); return target.value; } return eventOrValue; } type FormProviderProps = Omit< React.ComponentProps<"form">, "id" | "onSubmit" > & { // Unique Autosave Key id: string; onSubmit?: (form: FormData) => FormSubmitResult | Promise; values?: FormValues; inDialog?: boolean; }; export function FormProvider({ id, onSubmit, className, children, inDialog = true, values: defaultValues = {}, ...props }: FormProviderProps) { const [values, setValues] = React.useState(defaultValues); const [errors, setErrors] = React.useState>({}); const [loading, setLoading] = React.useState(false); const formRef = React.useRef(null); const [storageLock, setStorageLock] = React.useState<"r" | "w" | null>("r"); const closerRef = React.useRef(null); React.useEffect(() => { const stored = safeParseAutosave(localStorage.getItem(storageKey(id))); if (!stored) { setStorageLock(null); return; } if (Date.now() - stored.savedAt > AUTOSAVE_TTL_MS) { localStorage.removeItem(storageKey(id)); setStorageLock(null); return; } setValues(stored.values || {}); setStorageLock(null); }, [id]); React.useEffect(() => { if (storageLock === "r") return; setStorageLock("w"); const pruned = pruneAutosaveValues(values); localStorage.setItem( storageKey(id), JSON.stringify({ savedAt: Date.now(), values: pruned }), ); setStorageLock(null); }, [id, values, storageLock]); const setValue = React.useCallback((name: string, value: unknown) => { setValues((prev) => ({ ...prev, [name]: value })); setErrors((prev) => { if (!prev[name]) return prev; const next = { ...prev }; delete next[name]; return next; }); }, []); const updateValues = React.useCallback( (updater: (prev: FormValues) => FormValues) => { setValues((prev) => updater(prev)); }, [], ); const clear = React.useCallback(() => { setValues({}); setErrors({}); localStorage.removeItem(storageKey(id)); formRef.current?.reset(); }, [id]); const closeDialog = React.useCallback(() => { closerRef.current?.click(); }, []); const submit = React.useCallback(async () => { const formData = new FormData(); for (const [key, value] of Object.entries(values)) { appendFormValue(formData, key, value); } setLoading(true); setErrors({}); try { const res = await onSubmit?.(formData); if (res && typeof res === "object") { if ("error" in res) { const { error } = res; if (typeof error === "string") { toast.error(error); } else if (Array.isArray(error)) { const fieldErrors: Record = {}; for (const e of error) { fieldErrors[e.where] = e.what; } setErrors(fieldErrors); } else if (error) { setErrors({ [error.where]: error.what }); } } else { setErrors({}); } if ("toast" in res) { const { toast: toastMsg } = res; if (typeof toastMsg === "string") toast(toastMsg); } if ("close" in res) { const { close } = res; if (typeof close === "boolean") { if (close) closerRef.current?.click(); } } if ("reset" in res) { const { reset } = res; if (reset) clear(); } } } catch (error) { const message = error instanceof Error ? error.message : typeof error === "string" ? error : "Something went wrong"; if (message.includes("NEXT_REDIRECT")) return; toast.error(message); } finally { setLoading(false); } }, [onSubmit, values, clear]); const contextValue = React.useMemo( () => ({ id, values, errors, loading, setValue, updateValues, clear, submit, closeDialog, setErrors, }), [ id, values, errors, loading, setValue, updateValues, clear, submit, closeDialog, setErrors, ], ); return (
label]:-mb-2", className)} onSubmit={(event) => { event.preventDefault(); submit(); }} > {children}
{inDialog && }
); } type FormInputProps = { name: string; // biome-ignore lint/suspicious/noExplicitAny: typescript too strict :< override?: (original: any) => Partial | void; label?: string; subLabel?: string; children: React.ReactElement; className?: string; }; export function FormInput({ name, override, children, label, subLabel, className, }: FormInputProps) { const { values, setValue, updateValues, errors } = useFormContext(); const childProps = (children.props ?? {}) as Record; const value = values[name] ?? childProps?.defaultValue ?? ""; const nextProps: Record = { name, defaultValue: undefined }; const applyValue = React.useCallback( (nextValue: unknown) => { if (!override) { setValue(name, nextValue); return; } updateValues((prev) => { const nextValues = { ...prev, [name]: nextValue }; const overrides = override(nextValues); if (!overrides || typeof overrides !== "object") return nextValues; return { ...nextValues, ...overrides }; }); }, [name, override, setValue, updateValues], ); // biome-ignore lint/correctness/useExhaustiveDependencies: must run only on initial render React.useEffect(() => { if (childProps.defaultValue !== undefined) applyValue(childProps.defaultValue); }, []); nextProps.onCheckedChange = (checked: boolean) => { applyValue(checked); (childProps.onCheckedChange as ((value: boolean) => void) | undefined)?.( checked, ); }; nextProps.onValueChange = (nextValue: unknown) => { applyValue(nextValue); (childProps.onValueChange as ((value: unknown) => void) | undefined)?.( nextValue, ); }; nextProps.onChange = (event: unknown) => { const nextValue = getInputValue(event); applyValue(nextValue); (childProps.onChange as ((value: unknown) => void) | undefined)?.(event); }; if (value !== undefined) { if (typeof value === "boolean") nextProps.checked = value; else { nextProps.value = value; nextProps.checked = Boolean(value); } } if (label) { nextProps.id = `forminput-${name}`; } if (children.$$typeof === Symbol.for("react.lazy")) { children = React.use((children as any)._payload); } return ( <> {label ? (
{React.cloneElement(children || Divide, nextProps)} {errors[name] && ( {errors[name]} )}
) : (
{React.cloneElement(children || Divide, nextProps)} {errors[name] && ( {errors[name]} )}
)} ); } type FormActionProps = Omit< React.ComponentProps<"button">, "type" | "children" > & { type: "clear" | "submit" | "action"; action?: () => FormSubmitResult | Promise; children?: React.ReactNode; loading?: React.ReactNode; asChild?: boolean; }; export function FormAction({ type, children, asChild, action, loading: loadingComponent, ...props }: FormActionProps) { const { loading: formLoading, clear, closeDialog, setErrors, } = useFormContext(); const [actionLoading, setActionLoading] = React.useState(false); const loading = formLoading || actionLoading; const buttonProps = { ...props, type: (type === "submit" ? "submit" : "button") as "submit" | "button", disabled: type !== "clear" && loading, onClick: async (event: React.MouseEvent) => { props.onClick?.(event); if (!event.defaultPrevented && type === "action") { setActionLoading(true); try { const res = await action?.(); if (res && typeof res === "object") { if ("error" in res) { const { error } = res; if (typeof error === "string") { toast.error(error); } else if (Array.isArray(error)) { const fieldErrors: Record = {}; for (const e of error) { fieldErrors[e.where] = e.what; } setErrors(fieldErrors); } else if (error) { setErrors({ [error.where]: error.what }); } } else { setErrors({}); } if ("toast" in res) { const { toast: toastMsg } = res; if (typeof toastMsg === "string") toast(toastMsg); } if ("close" in res) { const { close } = res; if (typeof close === "boolean") { if (close) closeDialog(); } } if ("reset" in res) { const { reset } = res; if (reset) clear(); } } } catch (error) { const message = error instanceof Error ? error.message : typeof error === "string" ? error : "Something went wrong"; toast.error(message); } finally { setActionLoading(false); } } if (!event.defaultPrevented && type === "clear") clear(); }, }; if (asChild && React.isValidElement(children)) { return React.cloneElement( children as React.ReactElement, buttonProps, ); } return ( ); } export function FormRow({ className, ...props }: React.ComponentProps<"div">) { return (
); } export function FormTab({ label, name, tabs, children, }: { label?: React.ReactNode; name: string; tabs: { label: React.ReactNode; value: string }[]; children: React.ReactNode; }) { const { values, setValue } = useFormContext(); const active = (values[name] as string | undefined) ?? tabs[0]?.value; React.useEffect(() => { if (values[name] === undefined && tabs[0]) { setValue(name, tabs[0].value); } // biome-ignore lint/correctness/useExhaustiveDependencies: only run on mount to seed default }, []); return (
{label} setValue(name, v)}> {tabs.map((tab) => ( {tab.label} ))}
{React.Children.map(children, (child) => { if (!React.isValidElement(child)) return null; const childValue = (child.props as Record).value; return childValue === active ? child : null; })}
); } export function FormChoice({ children, }: { // biome-ignore lint/style/useExportType: not a type value: string; children: React.ReactNode; }) { return <>{children}; } export function FormIf({ children, ...conditions }: { children: React.ReactNode; } & Record) { const { values } = useFormContext(); const match = Object.entries(conditions).every( ([name, equals]) => values[name] === equals, ); return
{children}
; } export function FormWrapper({ children, ...props }: React.ComponentProps<"div">) { return (
{React.Children.map(children, (child) => React.isValidElement(child) ? React.cloneElement(child, props as Record) : child, )}
); }